Node.js / WebSocket / authoritative server / shared rules code

DankGame is a tactical fantasy strategy game that runs in a browser tab, and its Duel mode lets you send a friend a link and play the same map against each other: both of you move your hero through the same day at the same time, the day turns when both have ended it, and the duel ends in a final battle. Between the two browsers sits a small Node process that acts as a referee. Unlike the earlier relay write-up, whose server passed moves along without ever looking at the rules, this one runs the game’s own rules on every move, refuses the illegal ones, and is the only thing that ever changes a duel’s state. This page walks through how it does that, from the message format down to the WebSocket frames.

  • Size: 1,258 lines in 7 files, Node built-ins only, no npm package
  • Wire: JSON text over a WebSocket; 5 message types from the browser, 11 from the server
  • Checking: the server imports the same 4 engine modules the page imports, under the same versioned URLs
  • Per move: one state diff to each player; median 687 bytes over 635 measured actions
  • Storage: memory only; a restart ends every live duel

Everything below was read from the game’s repository at commit 56d6f8c on 2026-10-09, and every number was measured there with a command that this site keeps beside the page. Nothing in that repository was changed. The browser reaches the referee at the public endpoint wss://dankgame.com/dankgame-duel.

Seven files, one process

The referee lives in one directory, server/duel/. Counted with wc -l:

FileLinesWhat it does
server.mjs507HTTP server, the WebSocket route, joins, quick match, rule reloads, the idle sweep
match.mjs164one duel: its seats, its state, the referee’s act method, the tape
timers.mjs150the day and battle-turn clocks
presence.mjs138who is connected, when the computer takes over, when a win can be claimed
ws.mjs134the WebSocket frame layer
engine.mjs96loads the page’s own rules modules
diff.mjs69state diffs and patches

That is 1,258 lines, of which 880 are code and the rest comments and blank lines. Every static import in the seven files names either a node: built-in or one of the seven files, so there is nothing to install; the only other code the referee loads is the game’s own rules modules, described next.

The message vocabulary

Every message, in both directions, is one JSON object in one WebSocket text frame, with its type in a field t. The server’s dispatcher answers 5 types from the browser:

From the browserWhat it asks
jointake a seat in a match whose id is 4 to 32 letters, digits, dashes or underscores, creating the match if it does not exist; it may also carry a seat token (to take a seat back), the page’s rules version, the player’s people, and a create-only or existing-only flag
actapply one engine action for this connection’s side, with the browser’s own counter, which comes back in the answer
statesend the full state again (a resync)
claimclaim the win while the other seat counts as away
quickwait to be paired with another quick-match player

and sends 11 types of its own:

From the serverWhen
joinedthe seat was taken: side, seat token, map, seed, full state, its hash, both seats’ status, the rules version
readyboth seats are taken; play
okyour action was applied (its diff follows)
refusedthe rules said no; nothing changed, and here are the current sequence number and hash
diffto both players, for every accepted action, including timeouts and the computer’s moves
seata player came, went, was replaced by the computer, or can now be claimed against
overthe duel has ended: the result and the whole tape
statethe full state, the tape and the seats (the answer to a resync)
errora protocol mistake, as opposed to a game refusal
queuedquick match: waiting for a partner
pairedquick match: here is a fresh match id; join it

The split between refused and error matters to the browser. A refusal is the game saying no to a legal message (walk too far, act after ending the day) and carries the referee’s sequence number and state hash, so the page can confirm it is still in step. An error is a message the referee could not use at all: bad JSON, an unknown type, a second join on one connection.

How a move is checked

The referee does not have its own copy of the rules. engine.mjs imports the page’s rules modules from the page’s own directory, html/game/engine/: duel.js, duel-bot.js, encounter.js and map.js, 3,216 lines between them. It imports them under the same ?v= URLs the page’s import lines carry, read out of those lines at start-up. That detail is not cosmetic. The game keeps some registries inside module instances, and Node keeps one instance per full URL, so loading duel.js without its stamp would give the server a second, empty set of registries and a lair fight the browser allows would be “not something the hero can do” on the server. These are the three imports:

  const D = await import(url(path.join(eng, 'duel.js'), stamps.duel));
  const B = await import(url(path.join(eng, 'duel-bot.js'), stamps.bot));
  const E = await import(url(path.join(eng, 'encounter.js'), stamps.encounter));

Every act message reaches Match.act in match.mjs. Before the rules see anything, it refuses an action when both seats are not yet taken, when the duel is over, when the duel has already taken 20,000 actions, when the action is not a JSON object, or when its JSON is longer than 2,048 bytes. Then it stamps the side and hands the action to the engine, lines 118 to 131:

    if (a.side !== undefined && a.side !== side) return this.refuse(`you play side ${side}, not side ${String(a.side)}`);
    a.side = side;

    const before = this.json;
    let r;
    try { r = this.engine.D.mapAct(this.state, a); } catch (e) { r = { ok: false, error: 'that action could not be applied to this duel' }; }
    if (!r || r.ok !== true) {
      // a refusal changes nothing: make sure of it
      if (JSON.stringify(this.state) !== before) {
        this.state = JSON.parse(before);
        this.restored += 1;
      }
      return this.refuse(r && typeof r.error === 'string' ? r.error : 'that action was refused');
    }

Three choices are visible there. A seat can act only for its own side, and a message that names the other side is refused rather than quietly corrected. An engine exception becomes a refusal, so a malformed action cannot take the process down. And the referee does not trust the engine’s promise that a refusal changes nothing: it compares the serialised state before and after, puts the old state back if a refusal moved it, and counts the event in restored, a number that should stay at zero.

The refusals themselves are the engine’s. mapAct in html/game/engine/duel.js, the same function the browser calls, starts by deciding who may act at all (lines 567 to 575):

    if (state.outcome) {
      if (action.do === 'battle' && state.final && state.final.winner === null) return battleStep(state, side, action.action);
      return fail(state.final && state.final.winner === null ? 'the final battle is on: only battle actions now' : 'this duel is over');
    }
    if (action.do === 'battle') return fail('there is no final battle yet');
    const rec = state.sides[side];
    if (rec.ended) return fail('that side has ended its day; the next day starts when the other side ends theirs');
    if (action.do === 'endDay') return endDuelDay(state, side, rng);
    if (action.do === 'timeout') return { ...endDuelDay(state, side, rng), timeout: true };

Messages from the two players arrive one after another and are applied in arrival order. That would be unfair if being first on the wire could win anything, so the engine settles the one real race, two heroes reaching for the same object, by movement left rather than by who arrived first.

An accepted action is serialised, compared with the previous state by diff.mjs, hashed with SHA-256, given the next sequence number and appended to the tape. Both players receive the diff and the hash; a page that applied every diff can check that it holds exactly the referee’s bytes. The tape, together with the map name and the seed, rebuilds the duel byte for byte anywhere the engine runs. The repository’s own server test relies on that: it plays seeded bot duels through a real referee over real sockets, slips in a cheat (a walk past the day’s movement), and checks both that the state each client built from diffs equals the referee’s byte for byte and that replaying the tape gives the same final state.

New rules without a restart

The game’s build tool stamps every module import with a content hash, and it stamps them bottom-up, “so a module’s hash covers the stamps of everything it imports”. A fix deep in the battle rules therefore changes the stamp of duel.js too. The referee uses that (commit 14a2994). On a create or a join, at most once every 5,000 ms, it re-reads the four stamps from the page’s own import lines. If they changed, it imports the engine again under the new URLs, and because Node caches one module instance per full URL, that yields fresh instances of exactly the modules that changed while unchanged ones are shared.

A deploy that copies files while a join arrives could leave a half-written module, and Node would then cache that broken import for the life of the process. So every reload after start-up hashes the four files first and refuses to import any whose bytes do not match the stamp the page uses for it:

      const h = crypto.createHash('sha256').update(fs.readFileSync(path.join(eng, f))).digest('hex').slice(0, 8);
      if ('?v=' + h !== stamps[k]) throw new Error(`engine/${f} hashes to ${h} but is imported as ${f}${stamps[k]}: the tree is mid-copy or not restamped`);

A failed load is logged and changes nothing; the referee stays on the rules it has and tries again on a later join. It holds at most 3 rule versions at once. New duels are created on the newest. A duel that is already running keeps the rules it started on until it ends, so its tape stays replayable on the rules that produced it, and a version is dropped once no running duel uses it. If 3 versions are all still in use, the next one waits until a duel ends. A rollback to rules a running duel still plays simply makes that loaded version the newest again. Only a change to the referee’s own files still needs a restart.

Both players on the same rules

Two browsers on different rules would disagree about which moves are legal, so a duel only starts on matching rules (commit 248df86). The page sends its own duel.js stamp as rules in its join, and the server checks it against the match it is joining, or against the newest rules for a new match:

      if (msg.rules !== undefined && msg.rules !== (entry ? entry.engine.rules : newest.rules)) { send({ t: 'error', error: 'rules-mismatch' }); return; }

That covers both directions of a deploy. A page loaded before a rules change can still return to a duel it started on the old rules, because an existing match is checked against its own version. A page on old rules cannot create a new duel, and a page on new rules cannot join a duel running the old ones. The check also runs the other way: the page’s lobby refuses a joined reply that names rules other than its own. A player who hits the mismatch is not shown an error; the Duel panel says “The Duel server is updating to the newest rules. Try again in a few minutes, or play the computer.”

What the server keeps

Everything lives in memory, in one map from match id to an entry. Per duel the referee holds the state three ways (the live object the engine mutates, its JSON string, and a parsed copy that the next diff is computed against), the tape of accepted actions, the seed, two seat tokens of 18 random bytes each, and the presence and timer bookkeeping. Nothing is written to disk. There are no accounts: a seat token is the only proof of who you are, and for quick match the code says it plainly: “Nothing about a player is kept.”

Two limits keep memory bounded. The referee refuses to create a duel beyond 2,000 live ones, and a sweep that runs every minute drops any duel nobody has joined or acted in for 30 minutes. Measured on the 5 bot duels described below, the state of the duel-a map was 7,703 to 7,828 bytes of JSON at the start and 13,729 to 14,899 bytes at the end, and the finished tapes were 6,048 to 7,861 bytes. A source comment calls a duel state “tens of kilobytes”; on this map the largest measured state stayed below that.

Timeouts and disconnects

A slow or absent player can never stall a duel, because the referee runs two clocks per match in timers.mjs. The day timer gives each day 60 seconds from its start; when it runs out, every side that has not ended its day gets the engine’s own timeout action, which ends its day exactly as the End Day button would. In the final battle a turn timer gives the side whose stack is up 20 seconds, then makes that stack wait (or brace, if it may not wait). Both write ordinary entries on the tape, so a replay ends each day and each turn at the same point. In the words of the source: “The engine never reads a clock; these timers are the only clock”. The clock is injected, so the repository’s tests move time with a manual clock instead of sleeping.

presence.mjs decides what an empty seat means, and it tells two cases apart by whether the browser sent a WebSocket close frame:

  • Dropped (the connection was lost: the network went, the phone slept, the tab crashed). The hero holds still and only the timers act for it. After 90 seconds the seat is handed to the engine’s duel bot in its cautious mode, which never attacks the other hero and keeps at least half the gold its day began with, and which plays through the referee like anyone else. After 3 minutes the other player may claim the win, or ignore the offer and keep playing the bot.
  • Left (the browser closed its socket on purpose). The hero holds still under the timers with no bot, and the seat can be claimed against after 3 minutes too.

The clocks start when a seat’s last connection closes, so a player with two tabs open is still present while one remains. A join with the match id and the seat token takes the seat back at any time: the bot stops, the claim offer is withdrawn and the player receives the full state. To notice a peer that vanished without even closing TCP, ws.mjs turns on TCP keepalive at 15,000 ms.

A WebSocket layer written by hand

ws.mjs is a hand-written RFC 6455 server, 134 lines of which 104 are code, and it imports only node:crypto and node:events. Its own header explains the choice: the referee needs text frames, ping and pong, and close, which is less work to write than a dependency is to vendor, pin and audit. It accepts the upgrade for protocol version 13 only, rejects any client frame that is not masked (the RFC requires masking from clients), reassembles fragmented text messages, answers pings, echoes a close, and closes the connection on a binary frame. It negotiates no extensions, so there is no permessage-deflate compression. A message larger than 16,384 bytes closes the connection with code 1009. The core of the reader is the unmasking step:

      if (len > this.maxBytes) return this.close(1009, 'message too large');
      if (b.length < at + 4 + len) return;
      const mask = b.subarray(at, at + 4);
      const payload = Buffer.alloc(len);
      for (let i = 0; i < len; i++) payload[i] = b[at + 4 + i] ^ mask[i & 3];

Frames from the server go out unmasked, with a 2-byte header for a payload under 126 bytes and a 4-byte header for one under 65,536 bytes, so framing adds almost nothing to the sizes below.

Bytes per move, measured

The repository’s tests check that diffs are correct, but none of them records how big a diff is, so I measured it by running the repository’s own Match and engine directly, without a socket. Both sides were played by the engine’s duel bot on the duel-a map with seeds 1 to 5, and every accepted action’s diff message was serialised exactly as server.mjs builds it:

SeedActionsState at startState at endTape at end
11507,703 B14,899 B7,861 B
21167,808 B13,729 B6,048 B
31357,752 B14,656 B7,096 B
41097,828 B14,803 B6,184 B
51257,822 B14,710 B6,499 B

Over all 635 diff messages, the smallest was 173 bytes, the median 687 bytes, the 90th percentile 3,633 bytes and the largest 5,347 bytes. A large share of the smallest message is the 64-character hex SHA-256 hash it carries. Together the diffs came to 868,235 bytes. Sending the whole state after every action instead would have come to 6,413,636 bytes, about 7 times as much.

Read those figures with their limits. Each diff goes to both players, and the player who acted also gets a short ok, so the bytes leaving the server per action are a little more than twice the diff. Bots are not people, and a duel between two humans may take more actions or fewer. Nothing here measures latency, or a duel between two humans on real phones.

What it cannot do

  • Survive a restart. Duels live only in memory, so restarting the process, which a change to the referee’s own files still requires, ends every live duel.
  • Grow past one process. There is one referee and one map of matches, capped at 2,000 live duels; nothing shares duels between processes.
  • Keep secrets from a player. Both players receive the full state and every diff. Anything the screen does not show is still on the wire for someone who reads the socket.
  • Close every rules gap. A join without rules is accepted so that older open pages keep working, and such a page could be seated beside rules it does not run.
  • Know who you are. There are no accounts or ratings. Anyone holding a match id and its seat token can take that seat.
  • Compress or send binary. No permessage-deflate and no binary frames; JSON text only.
  • Match strangers yet. Quick match is in the server, but the live page hides it until there are enough players to pair.

Play it

The Duel mode is on the front page of dankgame.com: start a duel and send the link to a friend, or play the practice duel against the computer. The how-to-play page explains the battle rules that the referee enforces.

WebSocketsNode.jsGame DevelopmentMultiplayerReal-time

← Back to Projects

Back to the top