Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Beyond One Device

Multiplayer is a spectrum, and honesty first: Mar’s box does not contain realtime netcode. There are no websocket rooms, no client prediction layer, no lobby service. What it contains is arguably better for the games this book is about: everything you need for asynchronous multiplayer (turn-based games, daily challenges, leaderboards) with a property most netcode teams dream of: a shared, deterministic rules engine that cannot desync. The card game in the examples is a complete two-player game built this way; this chapter is its pattern, generalized.

The architecture: one rules module, two executors

Turn a game multiplayer and the dangerous question appears: who computes the truth? The classic failure modes are trusting the client (cheaters) or duplicating the rules server-side in another language (drift, forever). Mar’s answer is structural:

Shared.mar      the rules: types, legal moves, applyMove, the LCG
Backend/…       services: create match, submit move, fetch state
Frontend/…      pages: render the match, send the moves

Shared.mar is imported by both sides of a fullstack app. The Go-powered backend executes it to validate and advance matches; the JS frontend executes the same functions to predict and render. They cannot disagree, because integer math plus pure functions is bit-identical on every runtime (the Part I promise, now load-bearing). The rules are written once, in the language your game is already in.

The match is data

A match record is the whole truth of one game between two people, stored with the Repo like any other row:

-- in Shared.mar: the moves are data, the state is derived
type Move
    = PlayCard Int
    | Pass

applyMove : Move -> MatchState -> MatchState

Store the seed and the move list, derive the state by folding applyMove; or store the state snapshot and validate each incoming move against it. The examples do the second (snapshots are simpler to query), but the first is worth knowing: it makes every finished match a replay file for free.

The service surface is small and ordinary; async multiplayer is CRUD with rules:

  • POST /matches: create, seed the deck (server picks the seed).
  • POST /matches/:id/moves: body carries the Move; the handler loads the match, checks legal move state with the shared function, applies, saves.
  • GET /matches/:id: fetch for rendering.

The server validating with the same legal function the client used to enable buttons is the anti-cheat: a hacked client can send anything and accomplish nothing.

Turns arrive by polling, and that is fine

Correspondence games do not need push. The match page subscribes to a slow clock while it is the rival’s turn and refetches:

subscriptions model =
    if model.myTurn then
        Sub.none
    else
        Time.every (Time.seconds 5) (\_ -> Poll)

Five-second polling on a tiny GET is invisible on any server and immediate enough for games measured in turns. (One field note from the shipped game: space out bursts of writes; a client firing many POSTs in quick succession will meet the framework’s rate limiter and see a polite retry error. Batch a turn into one move payload rather than five.)

Auth is the framework’s passwordless sign-in (one Auth.protect on the services); matchmaking at this scale is a table of open challenges you list and claim. A bot opponent is the cherry: the same Shared rules module, a pickMove function, and the server (or even the client) plays the other seat. The card game ships exactly this as its practice mode.

Daily seeds and honest leaderboards

The lightest multiplayer is everyone versus the same dice. Chapter 6 built it: seed the run from the date and all players face the same board. The fullstack version adds one table and one beautiful trick:

  • POST /scores submits not a number but the input recording: seed plus the player’s move list (a few hundred bytes for most arcade games).
  • The handler replays the inputs through the shared rules and computes the score itself.

A submitted score is not a claim; it is a proof, checked by the same deterministic engine, at the cost of a few milliseconds of server fold. Forged scores are structurally impossible instead of moderated after the fact. Determinism, once again, doing jobs other stacks buy infrastructure for.

When you outgrow this

Real-time rooms (everyone sees everyone move, sub-second) are genuinely out of scope today; that is a future-framework conversation, not a pattern you can fake well with polling. If your design needs 200 ms reactions between players, build that part elsewhere. But before you conclude you need it, look again at the genres that thrive async: card battlers, word duels, chess-likes, roguelike dailies, territory games, anything with turns or with parallel solo runs compared after the fact. That space is enormous, underserved, and exactly what one person can ship, which is the spirit of this whole book.