Arena Duel
A server-authoritative real-time multiplayer duel. Two players connect from different devices, lock in a secret action every round, and the server resolves the outcome fairly and deterministically. No database, no accounts, no AI — just the hard parts: state machines, race conditions, and cheat-proof multiplayer architecture.
Two players on separate devices pick an action each round — Attack, Defend, or Special — simultaneously and blind. The server waits for both, resolves the clash from a fixed matchup table, applies damage, and broadcasts to both screens at once. First to drop the opponent to zero HP wins.
I didn't build this to make a game — the interesting problems live underneath it: where state lives, who decides outcomes, what happens when both players act at once, and what happens when one disconnects mid-round. Those are real-time system-design questions, so I built the smallest project that forces me to solve them rather than talk about them.
All game state lives in the backend, in memory, in a single Map of active matches — nothing is persisted to disk or a database. Each match is a small object that owns its own HP, cooldowns, current phase, and the two players' submitted-but-not-yet-revealed actions for the round.
create → Player A makes a room, gets a 5-char code; Player B joins with it. No login.countdown → once both are in, a synchronized 3-second countdown plays on both clients.selecting → 5-second window; each player privately submits an action. The opponent only sees “locked in”.resolving → the instant both submit (or the timer expires), the server runs the matchup table, applies damage, updates cooldowns, and emits the result to both at once.ended → HP hits 0, or a 30-round safety cap (highest HP wins; tie is a draw).
The round timer runs on the server in the socket layer, not in either client. Clients render a countdown for feel, but the authoritative clock — the one that defaults a silent player to Defend — is the server's. Closing a tab can't freeze the other player's match.
Both players choose simultaneously and blind, so the server can't resolve until it holds both submissions — or the timer fires. “Wait for both, or time out, then resolve exactly once” is a real concurrency problem: two socket events racing toward one resolution that must fire a single time, never zero, never twice.
Attack vs Attack -> both take 12 (trade) Attack vs Defend -> defender blocks; attacker takes 6 (counter) Attack vs Special -> Attack deals 18, Special deals 30 (both land) Defend vs Defend -> nothing happens Defend vs Special -> Special breaks through for 10 Special vs Special -> both take 15 (reduced trade) Special has a 3-round cooldown, enforced server-side via isActionAvailable() — a client cannot submit Special while on cooldown; the server silently rejects the submission.
The table is symmetric and deterministic: the same two actions always produce the same result, with no hidden randomness, so two clients fed the same pair of inputs will always agree with the server. Defend hard-counters Attack (block + counter damage) but is useless against Special; Special hits hardest but is gated by a cooldown so it can't be spammed. The cooldown is the resource-management layer — and crucially it's checked on the server in isActionAvailable(), not just greyed out in the UI. Hiding a button in the client is cosmetics; rejecting the submission on the server is the actual rule.
# Round 4 — both players at 60 HP
# Player A submits Special (off cooldown), Player B submits Attack
server holds: { A: "special", B: "attack" } # both arrived -> resolve once
matchup: Attack vs Special -> Attack 18, Special 30
after: A.hp = 60 - 18 = 42
B.hp = 60 - 30 = 30
A.specialCooldown = 3 # locked for the next 3 rounds
broadcast { aHp:42, bHp:30, aAction:"special", bAction:"attack" }
# If B had simply closed the tab instead of submitting:
# the 5s server timer fires, B defaults to Defend, round still resolves.Fighter.jsx with no backend changes, because presentation and game rules never mix.