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;
}
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); }
}
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;
}
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)