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 (2)

Collapse
 
loren_sl profile image
Loren •

Same approach can be used if you are writing a peer-to-peer networked game. If all of the peers are running the same simulation, it makes it much harder for one malicious peer to cheat.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.