A browser-based Halligalli trainer built to feel as close to the physical card game as possible. Players sit around a virtual felt table, flip cards clockwise, and race to ring the bell the moment any fruit totals exactly five. Available in single-player practice mode and real-time multiplayer rooms.
Live: https://halligalli-8xko3.ondigitalocean.app/
Cards are flipped one at a time, clockwise around the table. Each player's pile is face-up, but only the top card counts — older cards beneath it are invisible to the rule engine, just as in the physical game. The moment any single fruit across all top cards totals exactly 5, the bell window opens.
Ringing the bell is not binary. A speed bonus window rewards faster reactions — the sooner you hit the bell after the window opens, the more bonus points you earn. Miss the window entirely and every player takes a −30 penalty on the next flip.
| Event | Points |
|---|---|
| Correct ring — base | +120 |
| Collected cards | +6 per card |
| Speed bonus | up to +~50 (scales with reaction time) |
| Consecutive streak | +10 per hit in current streak |
| Wrong ring | −50 |
| Penalty cards paid | −4 per card |
| Missed bell window | −30 (applied to all players in multiplayer) |
Pressing the bell when no fruit totals five triggers a penalty: the player forfeits half the cards on the table (rounded up), shuffled to the bottom of their own pile. No cards to forfeit means no deduction, but the −50 still applies.
Three modes vary the flip interval and speed-bonus window:
| Mode | Flip speed | Speed-bonus window |
|---|---|---|
| Easy | ~1.85 s | generous |
| Normal | ~1.1 s | standard |
| Boss | ~900 ms | tight |
The deck uses an optimized distribution (COUNT_DISTRIBUTION) tuned via Monte Carlo simulation to target 4–7 card flips between bell windows across 3–6 player counts. 2-pip and 3-pip cards are most common; 5-pip cards are rare, preventing trivially obvious single-card rings.
In a multiplayer room the server runs the authoritative game loop — no client can fake a win. Bell presses are first-come-first-served: the first valid press collects the table; all other simultaneous presses receive the wrong-ring penalty. A missed bell window penalises every player equally.
Players are rendered as seats around an oval felt table, positioned using the same clockwise ordering as the physical game. The acting player is highlighted each flip so the user always knows whose turn it is without counting.
Cards use CSS rotateY + backface-visibility to produce a genuine 3D flip. The back face is visible mid-rotation; the front snaps in at the end. Flip direction is consistent with the clockwise rule.
Yang is a persistent observer who appears in Boss difficulty. After any missed bell window he delivers a taunt — a brief message rendered with a float animation above his portrait. The table also shifts to signal pressure. All animations are gated by prefers-reduced-motion.
The home screen training card tracks long-term improvement across three tabs:
- 近期 / Recent — last 5 rounds with score, accuracy, and avg reaction time
- 趋势 / Trend — SVG line chart for accuracy (%) and reaction time (ms) over the past 14 days; unlocks after 3 days of data
- 成就 / Achievements — 5 unlockable milestones (first round, 5-hit streak, perfect round, sub-200 ms reaction, 3-day daily goal streak)
A daily goal bar (5 rounds / day) resets each calendar day and feeds the streak achievement. All data lives in localStorage — no account required.
Dark felt palette, gold accent (--gold-light), tabular-numeral stat displays, and a glow-sweep button effect. The UI is fully bilingual (Chinese / English) and switches without reload. Every interactive element meets WCAG 2.5.5 minimum touch target size on mobile.
- 3–6 player table layouts with clockwise flipping
- Top-card-only bell validation matching the physical game rule
- Speed bonus, streak multiplier, and penalty logic
- Boss Mode with Yang taunts and visual pressure
- Configurable difficulty and round length
- Animated end-of-round score breakdown
- Create a room, share a 4-character match code
- Up to 6 players, ready-up lobby, host controls
- Server-authoritative game loop — fair across all clients
- First-press-wins bell races with simultaneous-press penalty
- Ranked per-player scoreboard at the end
- Same-origin socket.io, zero setup for players
- Last 5 rounds on the home screen (score, accuracy, avg reaction, mode badge)
- Rolling 100-round history in
localStorage(halligalli_history) - 14-day SVG trend charts for accuracy and reaction time
- Daily goal tracker with cross-day reset
- 5 achievements with unlock timestamps and toast notifications
- 3D card flip, bell particle burst, screen transitions
- Homepage entrance animations, glow-sweep buttons
- 3-2-1 countdown before every round
- Full
prefers-reduced-motionfallback (all keyframes covered) - Chinese / English language switch
- Sound effects with iOS audio-unlock on every game-entry button
- Screen-reader accessible:
aria-liveon feedback/penalty/boss taunt,aria-label/aria-pressedon bell,role="dialog"on lobby, screen-change focus management - WCAG 2.5.5 touch targets (44 px min-height on all interactive elements at mobile breakpoint)
- React 19 + Vite 8 + TypeScript + plain CSS (frontend)
- Node.js 24 + socket.io 4 (WebSocket server)
- Vitest for unit tests (44 tests across game logic, persistence, lifecycle, health, and stats)
- Single-service deploy on DigitalOcean App Platform
node --version # v24.x
pnpm install
pnpm run dev # Vite dev server on :5173
pnpm run dev:server # socket.io server on :3001 (in a second terminal)Vite proxies /socket.io to the server automatically. Open http://localhost:5173.
Multiplayer features will not work without the server, but single-player does:
pnpm run devpnpm run test
pnpm run typecheckdocker build -t halligalli:local .pnpm install
pnpm run build # outputs dist/
pnpm start # server serves dist/ + socket.io on port 3001Open http://localhost:3001.
src/
├── App.tsx — UI + single-player game loop
├── main.tsx — app entry
├── styles.css — all styles
├── audio/
│ └── useAudioEngine.ts — AudioContext hook (playFeedback, ensureUnlocked)
├── game/ — shared game logic (browser + server)
│ ├── constants.ts
│ ├── persistence.ts — localStorage helpers incl. history, daily goal, achievements
│ ├── rules.ts
│ ├── lifecycle.ts
│ ├── stats.ts — pure stat functions (streak, trend, daily goal streak)
│ └── types.ts — shared gameplay types
└── multiplayer/
├── protocol.ts — shared multiplayer payload types
├── socket.ts — socket.io-client singleton
└── useMultiplayerSocket.ts — room/game socket event subscriptions
server/
├── index.ts — HTTP server + socket.io router + static dist/ serving
├── health.ts — /health response shape and release identity
├── Room.ts — room/player model, match codes, host transfer
└── GameEngine.ts — server-authoritative game loop
deploy/production/app.yaml — GitOps Production Manifest
scripts/simulate-bell.ts — card-distribution tuning utility
public/yang-boss.png — Boss portrait
Deployed as a single GHCR-backed Node.js service on DigitalOcean App Platform. The server serves the Vite-built static frontend from dist/ and accepts WebSocket connections on the same origin — no CORS config, no separate CDN.
- Production manifest:
deploy/production/app.yaml - Release branch:
master - Versioning: Release Please creates human-merged release PRs and
vX.Y.Ztags - Promotion: release tags build and scan GHCR images, then open human-merged production promotion PRs
- Reconcile: GitHub Actions applies
deploy/production/app.yamlto DigitalOcean after that manifest changes onmaster - Health check:
/healthreports status, active rooms, release version, and commit SHA - Drift check: scheduled GitHub Actions compare Git, DigitalOcean, and
/health
Operations docs:
Private project.