Skip to content
Back to work

Case study · 2026

Real-Time Chess

Two-player chess built around a server-authoritative Socket.IO protocol, persisted room state, optimistic UI, and commentary that never blocks the move path.

The chessboard is the interface. The deeper work is distributed state: validating untrusted clients, ordering events, separating slow side effects, and making the shared result observable.

ReactSocket.IONode.jsExpressPostgreSQLchess.jsOpenRouter
Real-Time Chess screenshot

The real problem was shared state

A chess interface can validate a move in the browser and still be wrong.

Two players can act against stale boards. A modified client can ignore turn rules. An external commentary service can take seconds while the other player is waiting to see the move. A reconnect creates a new socket identity even if the human is the same.

I built Real-Time Chess to work through those boundaries in a concrete product. The visible result is a two-player game. The engineering result is a small real-time protocol with an explicit authority model.

Constraints I designed around

  • The client is untrusted. Browser validation improves feedback, but cannot decide shared truth.
  • Moves must feel immediate. Commentary and storage work cannot make the board wait on an external model.
  • Both players need one event history. Room membership is the fan-out boundary for moves and commentary.
  • Local setup should stay simple. The server can run against PostgreSQL or an in-memory store with the same application interface.
  • Failure needs a visible state. Rejected moves roll back, commentary has a loading state, and model failure falls back to local rules.

The architecture

React client A ─┐                         ┌─ PostgreSQL
                ├─ Socket.IO ─ GameManager ┤  or memory Map
React client B ─┘       │                 └─ chess.js
                       │
                       └─ commentary context
                          ├─ OpenRouter
                          └─ rule-based fallback

The Socket.IO handler is deliberately thin. It receives a command, delegates the state transition to the game manager, then emits the result.

The domain path is:

Room membership → player color → current turn → chess.js legality → persistence → broadcast

That order matters. An invalid request never becomes shared state.

I treated events as a protocol

The system has three client commands:

  • create-room
  • join-room
  • move

And five server outcomes:

  • room-updated
  • move
  • commentary
  • invalid-move
  • error

Each event has one job. room-updated sends a complete snapshot after room membership changes. move is the fast live path. commentary is a later side effect tied to the accepted move by Standard Algebraic Notation (SAN).

I documented the payloads, ordering, and failure behavior in the public repository because a real-time system is only understandable when its event contract is visible.

Server authority with an optimistic client

The browser uses its own chess.js instance so a legal drag updates immediately. It then emits the move request.

The server does not trust that local result. It reloads the room, checks that the socket owns a seat, verifies the player's color against the current turn, and asks its own chess.js instance to apply the move.

If the server rejects the request, the client restores the last synchronized FEN position. That creates a useful split:

  • local prediction for responsiveness;
  • server validation for consistency.

The current protocol identifies the sender so the optimistic client can ignore its own broadcast. A production version would go further and add a client move ID plus an explicit acknowledgment.

Slow work stays off the move path

An accepted move is saved and broadcast before commentary generation starts.

save move → emit move → generate commentary → save commentary → emit commentary

The commentary pipeline receives bounded chess context rather than a raw prompt: SAN, FEN, turn, game status, captured piece, recent moves, and recent commentary. It then uses OpenRouter when configured or a local rule-based generator when no key exists or the provider fails.

The server caps output at 160 characters and the UI shows a pending entry while it waits. The board never depends on the model call completing.

That separation is the decision I would reuse in chat, collaborative editing, live dashboards, or multiplayer tools: protect the primary real-time action from secondary work.

Persistence without coupling the game manager

The room store exposes the same operations for PostgreSQL and an in-memory Map.

PostgreSQL saves:

  • player records;
  • FEN;
  • verbose move history;
  • derived game status;
  • commentary history.

Memory mode makes the project easy to run and gives the verification script an isolated test environment. PostgreSQL demonstrates how the same room snapshot can survive process memory loss while a match is active.

I kept one important limitation explicit: socket IDs are identities, and a room is deleted when its last socket disconnects. This is active-session persistence, not a permanent account or game archive.

Verification

The repository includes a two-client Socket.IO verifier that starts an isolated server and checks:

  • room creation and joining;
  • player count and initial commentary state;
  • accepted move payload shape;
  • SAN propagation;
  • sender identity;
  • move delivery before commentary;
  • commentary shape and the 160-character cap;
  • wrong-turn rejection.

I also ran the client ESLint and production Vite build. The manual browser pass used two tabs, joined both players, played e4, confirmed Black received the move and shared commentary, and found no console errors.

What I would change for production

Durable seat identity

Replace socket IDs as player identity with authenticated player IDs and signed seat tokens. A reconnect should replace a transport, not create a new player.

Concurrency control

The current store uses read-modify-write snapshots. I would add a room version, optimistic concurrency checks, and a per-room command queue before horizontal scaling.

Shared transport infrastructure

Multiple Node.js instances would need a shared Socket.IO adapter and consistent room commands, not process-local membership alone.

Acknowledgments and idempotency

Move requests should carry a client sequence or idempotency key. The server should acknowledge the accepted command so retries and latency are measurable.

Broader tests

The next test layer would cover malformed payloads, duplicate rooms, full rooms, concurrent moves, PostgreSQL integration, optimistic rollback, and provider timeouts.

Outcome

The project now has a public, runnable codebase with a real product screenshot, a complete event reference, architecture notes, local setup, and an end-to-end verifier.

More importantly, it gave me a reusable way to reason about real-time software: define authority first, make events contracts, keep the critical path short, and document the failure boundaries as clearly as the happy path.

Real-Time Chess | Case Study by Orlando Ascanio