Back to All Projects
Real-Time Multiplayer · System Design · WebSockets · 2026

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.

Node.js Socket.IO Express React Vite Tailwind State Machine
Status
Working
Year
2026
Role
Solo Developer
Backend
In-memory · Socket.IO
Persistence
None (by design)
01 Project Overview

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.

The one rule that shapes everything: the client never decides anything. It sends intents (“I choose Attack”), never results (“I won”). The server is the single source of truth. A modified client can lie about what it picked, but it can never declare itself the winner — because it isn't the one doing the math. This is the same principle every real online game runs on.
02 Server-Authoritative Architecture

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.

Round lifecycle
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.

03 Simultaneous Action Resolution

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.

013×3 Action Matchup Table
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.
04 Engineering Decisions
Intents in, results out
Clients emit only the action they chose. Damage, win/loss, and HP are computed exclusively on the server. Tampering with the client changes what you claim to pick, never the outcome — the resolution code never runs in the browser.
Resolve once, or time out
The server resolves the instant both actions are in, OR when the 5s timer expires — whichever comes first — and guards against resolving the same round twice when the second submission and the timeout race each other.
Disconnect grace period
A dropped connection doesn't instantly kill the match. There's a grace window before teardown, so a brief network blip doesn't rob the other player of a game — a real production concern for any persistent-connection system.
Rendering decoupled from logic
Fighters are pure SVG/CSS, with poses driven by game state — zero image assets. Swapping in real sprites would touch only Fighter.jsx with no backend changes, because presentation and game rules never mix.
Tech Stack
Node.js Express Socket.IO React Vite Tailwind CSS In-memory state SVG / CSS WebSockets
The game is the excuse. The system design is the point.
An authoritative server, simultaneous blind resolution, server-enforced cooldowns, and disconnect handling — the real machinery behind every online multiplayer game, built small enough to read end to end.
View on GitHub