Bomber Blast is our free browser game in the classic bomb-arena genre, built with Phaser 3 and TypeScript, with a Node.js and Socket.io server for online play. The rules fit on an index card: drop a bomb, it explodes after three seconds in a cross, flames destroy bricks and set off other bombs, and the last bomber standing wins. The engineering problem behind them is harder. The same rules have to run for a single player against bots, on an authoritative multiplayer server, and in every browser that predicts its own movement before the server confirms it. If those copies disagree, players see other bombers teleport or watch a bomb explode that the server never placed.
This article explains how we avoided writing the rules more than once. One deterministic engine is used by all three callers. We cover how chain reactions resolve within a single tick, and how the network layer stays responsive without trusting the client.
One engine, three callers
The whole game lives in a workspace package, packages/shared, which has no DOM and no Phaser dependency. Its centre is one function that advances the game by a fixed 1/60-second tick:
/**
* Advances the round by one tick.
* @param inputs Inputs indexed by player id; missing entries mean idle.
*/
export function step(state: GameState, inputs: ReadonlyArray<PlayerInput | undefined>, dt: number = DT): GameState {
state.events = [];
state.tick++;
// 1) Player intents: move, place bombs, remote-detonate.
// 2) Bombs, flames, Sudden Death.
// 3) Flame deaths and pickups.
// 4) Death animations and respawns.
// 5) Round clock. 6) Victory.
...
}
(condensed)
GameState is plain data: arrays of tiles and items, players, bombs, explosions, a Sudden Death cursor and the PRNG state. There are no classes and no closures, so a state can be cloned, serialised, sent over the network and compared in a test with toEqual. A PlayerInput is only intent: a held direction and two edge-triggered buttons. Keyboard, touch, gamepad and the AI bots all produce the same shape.
This package has three callers:
- Single-player. The browser runs
step()itself in a fixed-timestep loop, with aBotControllerproducing each bot's input. - Multiplayer server. Each room runs the same
step()at 60 Hz as the authority, fed with the inputs clients send, and broadcasts the result. - Client prediction. Each browser re-runs the engine's
movePlayer()for its own bomber, so the controls respond immediately.
Because every caller imports the same TypeScript, a rule changes in exactly one place. A mismatch between client and server, such as diagonal corner-sliding or whether you may walk off the bomb you just placed, is not possible by construction.
Determinism, without heroics
Sharing code only helps if that code produces the same result everywhere. Three choices keep the engine deterministic:
- A fixed step. The simulation always advances by exactly
1/60s. Rendering runs at whatever rate the display offers and simply draws the latest state. A slow frame runs several ticks, capped so a background tab can't spiral. - A seeded PRNG stored in the state. Map generation and power-up drops draw from a mulberry32 generator whose 32-bit state is a field of
GameState, so the same seed and the same inputs replay the same round. - Integer grid coordinates for everything discrete. Positions are floats in tile units, but bombs, flames, tiles and pickups are resolved on integer tiles (
Math.roundof the player's position), so floating-point drift never decides whether a flame hits you.
Determinism also made testing straightforward. The bot tests run complete four-bot rounds to the end in Vitest. The codec test runs eight seconds of a real match, encodes the state, decodes it and compares it field by field.
Chain reactions in a single tick
The defining moment of the genre is a chain: one blast reaches another bomb, which goes off instantly and reaches a third. If chained bombs were left for the next tick, a player could walk through the gap for one frame, and the server and client could disagree about who died. So detonate() processes a queue and keeps going until no bomb is left in reach:
export function detonate(state: GameState, first: Bomb): void {
const queue: Bomb[] = [first];
const queued = new Set<number>([first.id]);
while (queue.length > 0) {
const bomb = queue.shift()!;
state.bombs = state.bombs.filter((b) => b.id !== bomb.id);
const arms = blastArms(state, bomb); // rays stop before hard walls, ON bricks and other bombs
const explosion = { id: state.nextId++, owner: bomb.owner, x: bomb.x, y: bomb.y, ...arms, t: state.rules.flameDuration };
forEachFlameTile(explosion, (x, y) => {
if (state.tiles[idx(state, x, y)] === Tile.Soft) state.tiles[idx(state, x, y)] = Tile.Burning;
for (const other of state.bombs)
if (other.x === x && other.y === y && !queued.has(other.id)) {
queued.add(other.id);
queue.push(other);
}
});
state.explosions.push(explosion);
}
}
(condensed)
Two details matter here. First, a ray stops on a bomb (that bomb joins the queue) rather than passing through it, so each bomb contributes its own range from its own position. Second, a brick hit by a ray becomes Burning rather than vanishing. It only becomes floor when the last flame covering it expires, and only then does it roll the drop table. Two overlapping blasts therefore can't destroy a brick and immediately destroy the power-up it revealed. Because the roll happens at a fixed point in the tick and uses the stored PRNG, the drop is still deterministic.
The unit test for this builds three bombs in a row with staggered fuses and asserts that one step() leaves zero bombs, three explosions and three explosion events.
Bots that read the future
A bot that walks into its own blast is not much of an opponent, and a chain can turn a safe-looking tile deadly. The AI therefore works from a danger map: for every tile, the number of seconds until it burns. Chains are handled by propagating the earliest fuse through every bomb that another bomb's blast reaches:
// A bomb inside another bomb's blast explodes no later than that bomb.
for (let changed = true, guard = 0; changed && guard < bombs.length + 1; guard++) {
changed = false;
for (let i = 0; i < bombs.length; i++)
for (const j of covers[i])
if (times[i] < times[j]) {
times[j] = times[i];
changed = true;
}
}
(condensed)
Active flames mark their tiles as 0, and during Sudden Death the next ten tiles in the falling-block spiral are marked with their landing times. Escaping is then a breadth-first search that only enters a tile if the bot can be through it before it burns. Entering at step n costs n / speed seconds, plus half a step and a safety margin set by the difficulty. Before dropping a bomb, a bot adds a hypothetical bomb at its own position, rebuilds the danger map and requires an escape path to exist.
Difficulty is expressed as timing rather than as cheating. Easy bots re-plan every 0.6 s, notice a new foreign bomb after 0.4 s, and occasionally skip the escape check. Hard bots re-plan every 0.1 s and react within 0.05 s. In our 12-round test sweeps, Hard bots blew themselves up least often, and a Vitest case asserts that ordering on fixed seeds. The bots are just another input source, so the multiplayer server fills empty seats with them by calling the same BotController.update().
Multiplayer: send intents, not positions
The server never accepts a position. Each client sends small binary packets of input with a sequence number: six bytes per tick for the sequence number, direction and button flags. The room queues them and applies one per player per tick:
for (const [slot, q] of this.queues) {
const next = q.shift();
if (next) {
inputs[slot] = next;
this.lastDir.set(slot, next.dir);
this.acks.set(slot, next.seq);
} else {
inputs[slot] = { ...IDLE_INPUT, dir: this.lastDir.get(slot) ?? IDLE_INPUT.dir };
}
}
for (const b of this.bots) inputs[b.id] = b.update(g, DT);
step(g, inputs);
(condensed)
Since movement, collision and bomb capacity are all decided by step() on the server, a modified client can't move faster or place a fourth bomb with a capacity of three. Malformed packets fail the decoder's bounds checks and are dropped. Clients are rate-limited per second. If a client falls behind, its backlog is collapsed to the last two inputs, with any bomb press it contained preserved, so lag doesn't build into seconds of delay.
Every third tick (20 Hz) the room broadcasts a full snapshot through a hand-written DataView codec. That includes tiles, items, players, bombs, flames, the Sudden Death state, the events since the last snapshot and the last input sequence number applied for each player. A four-player mid-game snapshot is under 1 KB, and a test enforces a 1.5 KB ceiling.
Prediction and reconciliation
Waiting for the server before moving would make the game feel laggy at any real ping. So the client moves its own bomber immediately with the shared movePlayer(), remembers each input it sent, and corrects itself whenever a snapshot arrives:
private reconcile(ack: number): void {
const s = this.latest!;
const server = s.players.find((p) => p.id === this.localSlot)!;
this.pending = this.pending.filter((i) => i.seq > ack);
const before = { x: this.predicted.x + this.correction.x, y: this.predicted.y + this.correction.y };
const p: Player = { ...server };
if (p.alive && s.phase === Phase.Playing) for (const i of this.pending) movePlayer(s, p, i.dir, DT);
this.predicted = p;
// Small disagreements are smoothed out over a few frames; big ones snap.
const ex = before.x - p.x, ey = before.y - p.y;
this.correction = Math.hypot(ex, ey) < 1.5 ? { x: ex, y: ey } : { x: 0, y: 0 };
}
(condensed)
The authoritative position plus a replay of the unacknowledged inputs is where the bomber should be. Because the replay uses the very same movement code as the server, the two usually agree exactly, and the correction is zero. It only becomes non-zero when something the client couldn't know happened in between, such as another player's bomb landing in its path. That correction decays exponentially, so it shows as a slight nudge rather than a jump.
Other players are not predicted. They are drawn about 100 ms in the past, interpolated between the two snapshots surrounding that moment on the server's clock. Client time is mapped to server ticks with a running minimum of arrival time minus tick time, so a single late packet doesn't make everyone stutter.
What it adds up to
The rules engine, AI and protocol together are a small share of the codebase, but they carry most of the guarantees. Single-player, the server and prediction cannot drift apart. Chains resolve atomically. Bots reason with the same timings the flames obey. The full suite is 47 Vitest tests, covering rules, chains, power-ups, Sudden Death, pathfinding, the danger map, bot safety, the codec, and server rooms over real sockets. Another 8 Playwright tests drive real browsers, including a two-browser match where one player's movement and the other's bomb have to appear on both screens.
Bomber Blast is a free download on the DEV3 marketplace, and the single-player game runs in your browser. For the architecture, the rendering and input layers, and the numbers, read the case study.



