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/.
The race server (party/)
Section titled “The race server (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 |
Rooms and joining
Section titled “Rooms and joining”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_PLAYERSand the host can raise it up toMAX_PLAYERS(both innet/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_TRACKSincore/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
startonce everyone present (at leastMIN_PLAYERS) is ready. - A connection to a full or racing room gets
fulland is closed. - After a result, everyone is set back to not ready, so a rematch is another round of readying up.
Room browser and quick match
Section titled “Room browser and quick match”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()inparty/src/roomDirectory.tskeeps public rooms in the lobby phase with a free seat that reported withinSTALE_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 forQUICK_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.
Protocol
Section titled “Protocol”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.
Clock sync and the shared race clock
Section titled “Clock sync and the shared race clock”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) / 2When 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.
Inputs, prediction and the replay verdict
Section titled “Inputs, prediction and the replay verdict”Sending
Section titled “Sending”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.
Server checks and relay
Section titled “Server checks and relay”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.
Showing the other cars
Section titled “Showing the other cars”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.
Judging
Section titled “Judging”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:
- A runner is done after
done, after leaving (outcomeleft, a forfeit), or when the race is cut off. - When the first runner reports
donewith a finishing log,RoomRace.startGraceresets the alarm to the finish grace (finishGraceFrames()insim/finishGrace.ts, which grows with distances past the quarter mile) plusGRACE_SLACK_MS. A runner who stopped sending does not hold up the race. - 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). - On the alarm,
Heat.cutOff()ends every runner still going, judged on the frames it reported. applyFinishGrace()turns a finish more than the grace behind the first car intotimeout.placings()ranks by outcome (finishbeatsblownandtimeout, which beatleft), 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.
Heat logs and grades
Section titled “Heat logs and grades”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.
SIM_VERSION and force update
Section titled “SIM_VERSION and force update”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.tsexportsSIM_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 checkcatches a forgotten bump:scripts/sim-version-check.tsraces every career race and an online run per car with scripted input, the way the server judges them, and compares each result withscripts/sim-fingerprint.json. A changed result with the sameSIM_VERSIONfails. After a bump,npm run sim:fingerprintrecords 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-Versionheader (player/account/accountApi.ts).party/src/api/serverStop.tschecks it before any route, sign-in included, and answers426with{ "stop": "update" }on a mismatch. Calls without the header (the website’s leaderboard, the AdMob callback) pass. - The room checks
vinjointhe same way and answersoutdated(a missingvmismatches too). - Both refusals end in
reportServerStop(core/serverStop.ts), the one path for every “server says stop”.boot/scenes/ServerStop.tsstops 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;
Preloaderdoes 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.
Running it locally
Section titled “Running it locally”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.