Skip to content

Online races

Online races put two to four players on the same strip at once. Each browser simulates its own car from live input and shows the others as predictions from their relayed input. The server never trusts what a client says about its race: it collects every runner’s input log and replays it through the same simulation the game runs, and that replay decides the places. The client side lives in net/, the server in party/.

party/ is a Cloudflare Worker built on Durable Objects with the partyserver package (routePartykitRequest, Server) and deployed with wrangler. party/wrangler.jsonc binds two Durable Object classes:

Class File URL Role
RaceRoom party/src/RaceRoom.ts /parties/race-room/<code> (WebSocket) One room code: lobby, race, verdict
RoomIndex party/src/RoomIndex.ts /parties/room-index/global (HTTP) The single global list of rooms, for the room browser and quick match

party/src/index.ts routes requests with routePartykitRequest and refuses any non-GET request to the room index from outside: only rooms report to it, from inside the worker, so nobody can list fake rooms.

The server imports the game’s own code directly (../../src/game/sim/..., ../../src/game/net/protocol.ts). That is why sim/ must stay free of Phaser and three (see Architecture): the worker runs the exact same simulation files as the browser.

RaceRoom only wires connections. The state is split into plain classes without connections or timers:

File Holds
party/src/lobby.ts (Lobby) Members in join order, host, capacity, public flag, distance, track, readiness
party/src/roomRace.ts (RoomRace) One race: start message, input relay, finish grace, timeout, verdict broadcast
party/src/heat.ts (Heat) Each runner’s input log and done/forfeit state, judging once all are done
party/src/judge.ts Replaying one run, the finish grace rule, placings
party/src/inputGuard.ts Validating an input batch before it is accepted
party/src/messages.ts JSON parse, send, and a shape check of a joining Entry
party/src/roomDirectory.ts Which listed rooms are open, and random room codes

A room is identified by its code. Any code works: the first player to connect to /parties/race-room/<code> creates the room by joining it and becomes host. NetLobby lets players type a code (letters without I and O, since codes get read out loud); NetBrowse lists public rooms and offers quick match.

joinRoom.ts opens a RaceConnection and moves to the NetRoom scene as soon as the first room state arrives. If the server answers full instead, it closes the connection and calls onRefused, and the calling scene shows a message. An outdated answer shows the stop screen instead (see SIM_VERSION and force update below).

Lobby rules (party/src/lobby.ts, RaceRoom.onLobbyMessage):

  • Capacity starts at MIN_PLAYERS and the host can raise it up to MAX_PLAYERS (both in net/protocol.ts), never below the players already present.
  • The player who opens the room chooses whether it is public. The host can change visibility, distance, track and capacity, and kick others. The track starts as random (a different one each race) and the host can pin one of the race tracks (RACE_TRACKS in core/trackIds.ts).
  • When the host leaves, the longest-present player takes over. When the room empties, its settings reset.
  • The race starts by itself when the room is full and everyone is ready, or when the host sends start once everyone present (at least MIN_PLAYERS) is ready.
  • A connection to a full or racing room gets full and is closed.
  • After a result, everyone is set back to not ready, so a rematch is another round of readying up.

On every state change a room POSTs a RoomListing to RoomIndex (RaceRoom.reportToIndex); a room with no players removes its listing. The index keeps the listings in Durable Object storage so they survive eviction.

roomIndex.ts has the two client calls:

  • fetchOpenRooms(): GET the index. openRooms() in party/src/roomDirectory.ts keeps public rooms in the lobby phase with a free seat that reported within STALE_MS, fullest first.
  • quickMatchCode(): GET with ?quick=1. Returns the fullest open room that races the default distance (the quarter mile). If there is none, the index hands out a fresh code and remembers it for QUICK_ROOM_REUSE_MS, so two players pressing quick match at the same moment land in the same new room. A pending code whose host has since switched distance is not reused.

All messages are JSON over one WebSocket, typed as ClientMessage and ServerMessage in net/protocol.ts, which both sides import.

Client to server:

t Fields Who Meaning
ping c (client time) anyone Clock sync sample; answered with pong
join entry, public?, v?, token? anyone Join with tune and look; v is the client’s SIM_VERSION (a number), token the account session
ready ready member Toggle ready in the lobby
capacity n host Room size
kick id host Remove a player
visibility public host Show or hide the room in the browser
distance d host Race distance
track track host A race track, or null for a random one each race
start host Start early once everyone present is ready
emote i member Preset emote by index into EMOTES, rate limited by EMOTE_COOLDOWN_MS
input e, at racer Input events, and the number of ticks the sender has completed
done racer The sender’s run is over

Server to client:

t Fields Meaning
pong c, s (server time) Clock sync reply
full Room full or racing; connection closed
outdated Join refused: other race physics; connection closed
room room: RoomState Lobby state, sent to every member on any change; you is the receiver’s id
kicked You were removed by the host
emote from, i Someone’s emote
start lane, startAt, runners, track, distance Race begins; startAt is server epoch ms when the lights start
input e, at, lane Another runner’s accepted input batch, relayed
result runs, ids, places The verdict, by lane: outcome and finish frame, member id, place

An Entry is { car, tune, look?, racer? }. car and tune feed the simulation; look (CarLook: name, sprite, paint, underglow, decals) and racer (RacerTag: user id, nickname, portrait, level) are display only. net/entry.ts builds the local entry from the garage and sanitises other players’ looks, falling back to a stock car for anything unknown. On the server, isEntry checks the shape, then ownedEntry (party/src/race/onlineEntry.ts) replaces the car and look with what the player owns (decals included, so the game only checks their shape): it checks the session token the join carries (RaceConnection gets it from ensureSignedIn), reads the account’s garage and builds the selected car’s stats from its model and parts. Only the tune comes from the client: it carries no value and the simulation clamps it. A join without a valid session is closed. ownedEntry also sets racer from the account (the leaderboard nickname, the garage’s portrait, the level from its XP), so nobody races under a name or rank they made up. The room rows, results, emotes and the race’s progress bars show the nickname and portrait instead of the car; the room rows add the rank badge, the car as a subtitle and a “friend” tag for players on the friend list (matched by userId). The race builds its cars from the server’s start.runners, never the local entry, which has no tag. The room browser lists rooms by the host’s nickname (hostName). party/scripts/twoClients.ts joins with a claimed 5000 hp car and checks the results replay with the guest’s starter car.

Every client must start the lights at the same moment. On connect, RaceConnection sends PING_COUNT pings a short interval apart, before the join, so the replies are in before any start arrives. Each pong gives a sample of (sent, server, received).

clockOffset() in net/clockSync.ts picks the sample with the shortest round trip, since it leaves the least room for asymmetric delay, and assumes the request and reply took equally long:

offset = server - (sent + received) / 2

When start arrives, localStartAt = startAt - offset turns the server’s start time into local Date.now() time. The server sets startAt START_DELAY_MS (party/src/roomRace.ts) in the future so the message reaches everyone first.

NetRaceClock (net/NetRaceClock.ts) then times the race against the wall clock instead of summed frame deltas, so a browser that stutters catches up instead of drifting. It uses the same start-light rhythm as the offline RaceClock: green comes four lamp intervals after startAt, and framesDue(now) is the simulation frame that should have run by now, floor((now - greenAt) / (1000 / SIM_HZ)), negative during the countdown. NetRace.update() runs ticks until the session’s frame reaches framesDue, starting from the first lamp so players can rev in neutral before green.

The simulation is a pure function of car, tune and input (see Race simulation), so a run can be sent as its input instead of its positions. sim/inputRecorder.ts turns live controls into an event log of { f, k }: gas and nitrous are held states, logged only on their on/off edges; shifts are single events. Frame 0 is the first tick after green, the countdown runs from -STAGING_FRAMES.

After each tick NetRaceSession.flush() returns a batch when events are due, or a heartbeat every HEARTBEAT_FRAMES without new input. at in a batch means “every event before this frame has been sent”, which tells the others how far the run is known even when nothing was pressed. When the local run ends (finish, blown engine, or timeout), the last batch is forced out and done follows.

acceptBatch() (party/src/inputGuard.ts) only accepts a batch that extends the run in order: at never goes back and stays within MAX_RACE_FRAMES, every event is well formed, frames never decrease, no event lands at or after at, and a run holds at most MAX_EVENTS events. Accepted batches are appended to the runner’s log in Heat and broadcast to the other members with the sender’s lane.

NetRaceSession simulates the local car directly. Each remote car runs an OpponentPredictor (sim/opponentPredictor.ts): exact up to the last reported frame, and beyond it a guess with gas and nitrous held as last known and shifts at the best shift point. When late input corrects the prediction, the drawn car closes the gap by CORRECTION_RATE per tick, so the correction shows as a short slide instead of a jump.

The client ends its own run on the finish grace only when another car’s confirmed input (not a prediction) puts it over the line. A predicted car over the line might belong to a hidden tab that stopped sending.

judgeRun() in party/src/judge.ts replays each log with replayRun() (sim/inputLog.ts), the same stepping code the browser ran, with AI off. Because the sim is deterministic, the replay reproduces what that player saw, so the server can judge without trusting any client’s claimed time. scripts/netCheck.ts checks exactly this: a run recorded live replays to the same result, the predictor converges on the exact run, and clockOffset recovers a known offset.

The replay returns a fractional finish frame (interpolated inside the crossing tick, so two cars crossing in the same tick still have an order), or blown, or no finish. A run is only replayed up to cutAt, the frames its runner reported, so a replay never holds the last input on to a finish the runner did not drive.

When the race ends:

  1. A runner is done after done, after leaving (outcome left, a forfeit), or when the race is cut off.
  2. When the first runner reports done with a finishing log, RoomRace.startGrace resets the alarm to the finish grace (finishGraceFrames() in sim/finishGrace.ts, which grows with distances past the quarter mile) plus GRACE_SLACK_MS. A runner who stopped sending does not hold up the race.
  3. If no one finishes, the Durable Object alarm fires after RACE_TIMEOUT_MS (party/src/RaceRoom.ts: start delay, lamps, MAX_RACE_FRAMES, plus slack).
  4. On the alarm, Heat.cutOff() ends every runner still going, judged on the frames it reported.
  5. applyFinishGrace() turns a finish more than the grace behind the first car into timeout.
  6. placings() ranks by outcome (finish beats blown and timeout, which beat left), then by finish frame. Each place is one plus the number of runs that beat it, so ties share a place.

The result goes to everyone, the lobby is unreadied, and the room is back in the lobby phase. If the room empties mid-race, the race is dropped without a verdict.

The client keeps its own copy of every lane’s input in HeatLogs (net/HeatLogs.ts): its own batches as it sends them, the others’ as they are relayed. When the result arrives, grade() sorts each lane’s events by frame and runs gradeRun() (sim/runGrades.ts) to get the launch and shift grades that NetResults shows next to the server’s places. The places come from the server; the grades are computed locally from the same logs.

The server judges on its own copy of the sim: online races in the room, career races by replaying the input log (party/src/api/raceApi.ts). A game on other physics would see one race and be judged or paid for another, so the server refuses it and the game asks the player to update.

  • sim/simVersion.ts exports SIM_VERSION, a number bumped by hand when a change alters race results (times, trap speeds, who wins), and only then. The game and the worker import the same constant, so dev and deployed builds from one commit always match. Each bump forces every installed app to update, and a store release takes days to reach players, so batch physics changes.
  • npm run check catches a forgotten bump: scripts/sim-version-check.ts races every career race and an online run per car with scripted input, the way the server judges them, and compares each result with scripts/sim-fingerprint.json. A changed result with the same SIM_VERSION fails. After a bump, npm run sim:fingerprint records the new results. New races are recorded on the next write without a bump: an old app cannot enter a race it does not know.
  • Every API call sends it in the X-Sim-Version header (player/account/accountApi.ts). party/src/api/serverStop.ts checks it before any route, sign-in included, and answers 426 with { "stop": "update" } on a mismatch. Calls without the header (the website’s leaderboard, the AdMob callback) pass.
  • The room checks v in join the same way and answers outdated (a missing v mismatches too).
  • Both refusals end in reportServerStop (core/serverStop.ts), the one path for every “server says stop”. boot/scenes/ServerStop.ts stops every running scene and shows “Update needed”: the app’s button opens the store (Play listing; the site on iOS until the App Store listing exists), the web build’s reloads the page. Nothing leads away from it.
  • At launch the account start already calls the server, so an old app gets the screen before the menu; Preloader does not start the menu once the stop screen is up. Offline, or with the server unreachable, the game starts as before.
  • Only answers that end the game belong on this path. Maintenance mode is not one of them: the game keeps running and only server actions show the message (a 503, party/src/maintenance.ts, core/maintenance.ts).

scripts/deploy.sh deploys HEAD from a git archive export, never the working tree, because other sessions’ unfinished edits must not go live. The server goes first: an old game against the new server is told to update; a new game against the old server would race unchecked.

The game connects to VITE_PARTY_HOST, defaulting to ws://localhost:8787 (net/RaceConnection.ts); .env.production points production builds at the deployed worker.

Command What it does
cd party && npm run dev wrangler dev: the race server on port 8787
npm run dev (repo root) The game; online play then talks to the local server
cd party && npm run test:room party/scripts/twoClients.ts: scripted WebSocket clients against a running local server, covering lobby, start, relay, verdict, rematch, full room, disconnect, four players and distances
npx tsx scripts/netCheck.ts Replay, prediction and clock-offset checks, no server or browser
node scripts/netShot.mjs Starts Vite and the race server (or reuses a running one) and plays a race in headless browsers; --players 4, --quick, --latency <ms>
npm run check Also type checks party/ (tsc -p party/tsconfig.json)
npm run deploy scripts/deploy.sh: worker, then game to Cloudflare (play.nitrolegend.com); DRY_RUN=1 skips the real deploys

In dev, RaceConnection exposes itself as window.netConnection so scripts/netShot.mjs can read the room.