DEV Community

Cover image for A pure, seeded game engine gives you multiplayer almost for free
Sharang Parnerkar
Sharang Parnerkar

Posted on

A pure, seeded game engine gives you multiplayer almost for free

I built a board game as a browser app (Samudra Manthana), and adding online multiplayer turned out to be almost anticlimactic - not because netcode is easy, but because one early design constraint did most of the work.

The constraint: the engine is a pure, deterministic, serialisable function.

export function applyAction(state: GameState, action: Action): GameState {
  const s: GameState = structuredClone(state); // never mutate the input
  // ... validate + apply, entirely from s and action ...
  return s;
}
Enter fullscreen mode Exit fullscreen mode

No Date.now(), no Math.random(), no I/O, no reaching outside. Same input, same output, forever. Every bit of randomness comes from a seeded PRNG whose state lives inside GameState.

The RNG is a single uint32

It is mulberry32 - a whole PRNG in one 32-bit integer of state, seeded from a string hash:

// string seed -> uint32 starting state
function hashSeed(seed: string): number {
  let h = 1779033703 ^ seed.length;
  for (let i = 0; i < seed.length; i++) {
    h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
    h = (h << 13) | (h >>> 19);
  }
  return (h ^ (h >>> 16)) >>> 0;
}

class Rng {
  state: number;
  next(): number {
    let t = (this.state = (this.state + 0x6d2b79f5) | 0);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  }
  int(maxExclusive: number) { return Math.floor(this.next() * maxExclusive); }
}
Enter fullscreen mode Exit fullscreen mode

Because the RNG state serialises with everything else, a game is fully described by its seed plus the list of actions taken. That single property buys you a lot.

Multiplayer falls out of it

The server does not reimplement any rules. It holds the authoritative GameState and runs the exact same applyAction. Clients send actions; the server validates and applies through a thin adapter, then broadcasts the new state:

interface GameAdapter {
  currentSeat(state): string | null;
  isTerminal(state): boolean;
  winner(state): string | null;
  // Validate + apply an action from a seat; return the new state, or null if illegal.
  apply(state, seat, action): unknown | null;
  botAction(state, seat, persona): unknown | null;
}
Enter fullscreen mode Exit fullscreen mode

No client-side prediction to reconcile, no desync class of bugs, and replays are free (seed + actions). The adapter is even game-agnostic, so one server process serves two different games.

The one bug worth warning you about

The interesting failure was in action canonicalisation. Clients often omit optional fields ("spawn a unit" without saying where, defaulting to home). The server matches an incoming action against the legal set with a key() function - and if key() does not resolve the same defaults the engine does, a perfectly legal move gets rejected as illegal. Non-mercenary clans could not spawn online until the key resolved at ?? homeHex exactly the way the engine did.

Lesson: the client and server must canonicalise actions identically. The determinism gives you correctness for free everywhere except the boundary where an under-specified action becomes a concrete one. Pin that down and the rest really is almost free.

Play it in the browser (hotseat, vs bots, or online): https://mighty840.itch.io/samudra-manthan

Top comments (0)