From racecar, a real game built on GameRelay · the source
Every racer's screen must start the race at the same moment, and see the traffic, rain and hazards in the same places. racecar does it without sending any of them: it puts the race on the server's clock, and makes everything else a function of the race's time.
Why not each browser's clock
Date.now() differs between computers by anything from milliseconds to minutes. A race that starts at "now plus 6 seconds" by each player's own clock starts at different times on every screen. relay.now() is the server's clock, kept in sync from pings, so it's the same timeline for every player.
1. Start at a moment, not on a message
// The lobby's host presses Start: green is a few seconds from now, on the server's clock.
const START_LEAD_MS = 6000;
await send({ type: 'start', at: relay.now() + START_LEAD_MS }); // into the lobby's state, for everyone
// Every screen counts down to the same moment.
const secondsToGreen = (at) => Math.min(30, Math.max(0, (at - relay.now()) / 1000));The start time goes into the lobby's state. A player whose copy arrives a little late still goes green at the same moment: they just see a shorter countdown.
2. Keep the race time on that clock
// Before each step: keep this screen's race time on the server's clock.
const SNAP = 0.25; // behind by more than this (s): jump to it
const SLEW = 0.05; // otherwise close 5% of the gap each step (about a second to catch up)
function syncClock(sim, at, now) {
if (sim.phase === 'countdown') {
sim.time = sim.goTime - secondsToGreen(at); // nothing moves yet, so set it outright
return;
}
const want = sim.goTime + (now - at) / 1000;
const off = want - sim.time;
if (off > SNAP) sim.time = want; // a long pause: catch up at once
else sim.time += Math.max(-sim.dt / 2, off * SLEW); // small drift: ease it out, never back more than half a step
}
relay.tick(60, (dt) => {
syncClock(sim, lobby.at, relay.now());
sim.step(dt);
});Small differences are eased out over about a second, so nothing visibly jumps. A big gap (a stall of a few frames, a tab that was in the background) is closed at once.
3. Make the world a function of time
// Traffic, weather and hazards are functions of the race time and the race's seed,
// so every screen with the same time has them in the same places, with nothing sent.
const truckAt = (t) => pathPosition(truckPath, (t * truckSpeed) % truckPath.length);
const raining = (t) => noise(seed, t / 60) > 0.6;This is the part that saves the most work. Things nobody owns (traffic, weather, a falling sign) are worked out from the race time and the race's seed. Two screens at the same time agree on all of it, with no entities and no traffic on the network.
Things to get right
- Players' cars still sync. A clock lines up what's predictable. What players do (steering, crashes) still goes through entities and events.
- Seeded randomness only. Anything random in the world must use the race's seed, never
Math.random(), or screens disagree. - Message ages. Messages carry
meta.at, the server time they were sent. Their age isrelay.now() - meta.at, useful for placing an event where it happened.
Try it
GameRelay has a free plan, no card needed. Get a public key, then follow the quickstart, or point your AI coding agent at llms.txt.