Notes · 19 October 2026

Keeping a JavaScript game engine and its Swift twin in lockstep

Composition is a daily JezzBall-style puzzle that runs one integer-only engine in the browser and on iPhone, and a script checks that 563 recorded solutions finish on the same tick in both.

You draw lines across a canvas to trap bouncing dots, and every room you close off fills with paint, so a finished board looks like a Mondrian. Everyone gets the same board each day. A finished run can be sent as a link, and whoever opens it watches it play back, in a browser or in the app, whichever they have. That only works if both platforms agree about every tick of the game. If the browser's dot slips past a line that the iPhone's dot hit, the replay ends somewhere the player never was.

30 frames a secondtick 0
Your screen's frame ratetick 0

Two copies of this site's engine.js, running today's board. Swipe across either one to draw a line, and it lands in both on the same tick.

Everything is an integer

The engine is a single file, engine.js, 485 lines, ported line for line from the Swift engine in the app. There isn't a float in the simulation. A cell is 1024 units wide, so positions and speeds are whole numbers, and time moves in fixed ticks, 120 to the second.

export const UNIT = 1024;
export const TICKS_PER_SECOND = 120;

// Swift's integer division truncates toward zero.
const div = (a, b) => Math.trunc(a / b);

Floating point can be made to agree across platforms, but it takes care at every step. With integers we get the agreement for free, as long as the two languages do the same arithmetic, and that small div helper is most of what it took. JavaScript's / gives a fraction where Swift's gives a whole number, and Math.floor would round negative numbers the other way.

Randomness that travels

Each day's board starts from a seed made from the date, and the dots are placed by SplitMix64, the same generator the Swift side uses. It needs 64-bit arithmetic, so in JavaScript it runs on BigInt.

next() {
  this.state = (this.state + 0x9E3779B97F4A7C15n) & M64;
  let z = this.state;
  z = ((z ^ (z >> 30n)) * 0xBF58476D1CE4E5B9n) & M64;
  z = ((z ^ (z >> 27n)) * 0x94D049BB133111EBn) & M64;
  return z ^ (z >> 31n);
}

It keeps working after the start, too. It also picks the colour of every room you close, so the two engines have to draw the same numbers in the same order for the whole game, or the same swipes would paint a different picture.

A run is its strokes

Because the engine is deterministic, we don't record a run, only its inputs. Each stroke is seven bytes, four for the tick and one each for the column, the row and the direction, and the whole run goes into the link as base64url. Playing it back means building the day's board and stepping forward, applying each stroke on its tick. Should a link hold something the app would never write, like strokes out of order or one a tick late, the page turns it down.

The test that holds it together

parity.sh compiles the Swift engine with swiftc, replays the same games and routes through both engines, and diffs what they print line by line. Each game ends with a line like this one:

study1 end won t876 filled1454 lines7 lives6 cs730 1663125125214246908 colours[2, 3, 1] largetrue

That long number is a 64-bit FNV-1a fingerprint of everything that decides what happens next, from every dot's position and velocity to each growing line, the lives left, the clock and the generator's own state. Today's run checks 92 games, 366 daily boards and 563 recorded solutions, and the two engines have to print the same 1,033 lines. If one character differs, the check fails and nothing ships.

Any frame rate, the same game

Screens don't refresh at 120 Hz, so the page keeps the leftover time between frames and runs as many whole ticks as have passed, then draws the dots part of the way to the next one. The game underneath never sees a frame. One test replays the same run at 30, 60 and 120 Hz and checks that it reaches the exact same state, which is what the two boards above are doing. Only a long gap, like a tab you've switched away from, is different. Rather than racing through ticks nobody saw, the board covers itself and holds still until you're back.

Today's board is free at playcomposition.com/board, with no account, and the engine is there to read, comments and all.