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, 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 or outdated instead, it closes the connection and calls onRefused with the reason, and the calling scene shows a message (OUTDATED_TEXT for a version mismatch).

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 and capacity, and kick others.
  • 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, 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
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? }. car and tune feed the simulation; look (CarLook: name, sprite, paint, underglow) is 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: 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. 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. If a client ran different physics, it would see one race and be judged on another. net/simVersion.ts exports SIM_VERSION, read from a build-time constant __SIM_VERSION__, or 'dev' when unset (the Vite dev server and wrangler dev, so local play always matches).

scripts/deploy.sh stamps both sides with the same value:

  • SIM_VERSION is the short hash of the last commit that touched src/game/sim, src/game/net/protocol.ts or party/src. Commits that change only art or menus keep the version, so a deploy of those never locks players out of rooms.
  • The worker gets it through wrangler deploy --define __SIM_VERSION__:...; the game build through the define in vite/config.prod.mjs, which reads the SIM_VERSION environment variable.
  • The script 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 refused and told to reload; a new game against the old server would race unchecked.

On mismatch, RaceRoom.join sends outdated and closes the connection (a missing v, from clients before the check, also mismatches). joinRoom reports 'outdated' and the browse or lobby scene shows OUTDATED_TEXT: “The game was updated. Reload it to race online.”

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.neondrag.com); DRY_RUN=1 skips the real deploys

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