Architecture
Neon Drag is a side-view drag racing game: you launch, shift and fire nitrous against an
opponent over a fixed distance, then spend the winnings on cars, parts, paint and tunes. It
runs a career of districts with events and bosses offline, plus live online races against
up to three other players. The code is Phaser 4 + TypeScript (strict) + Vite, with a separate
Cloudflare server in party/ for online play.
Modules and the import rule
Section titled “Modules and the import rule”src/game/ is split into modules. Imports go one way only, and eslint.config.mjs enforces
it with no-restricted-imports per module (see moduleBoundaries() there).
| Layer | Module | Holds |
|---|---|---|
| Shared 1 | sim |
The pure race simulation. No Phaser or three (ESLint blocks both), because the party/ server runs it too. |
| Shared 2 | core |
Basics that know nothing of the player: theme, track ids, render scale, audio, unit and vibration settings, UI sound. |
| Shared 3 | player |
Everything the player owns: cars, tunes, paint, decals, the save, and the career’s data and rules (player/career/). |
| Shared 4 | ui |
Widgets, dialogs, screen layout (ui/ShopFrame.ts, neon buttons, sliders). |
| Shared 5 | race |
Race scenes, HUD, track view, race audio. race/prototypes/ holds abandoned camera experiments. |
| Feature | net, garage, shop, menu, hub |
One feature each. May import every shared module, never each other. |
| Wiring | boot |
Boot, Preloader and dev tools. May import anything. |
The rules, as moduleBoundaries() computes them:
- A shared module may import only the shared modules before it in the list
sim, core, player, ui, race. - A feature module may import all shared modules (including
race) but no other feature. bootmay import everything.
Why: the order keeps the simulation free of rendering so the server can run the same code
and judge races identically, and the feature split keeps parallel work in separate folders.
When two features need the same code, it moves down into a shared module. For example, the
career’s rules live in player/career/ rather than in hub, because race needs them too.
Inside a module, scenes/, view/ and audio/ hold those layers. Scenes only orchestrate:
they create views, read input and call into modules. ESLint also caps files at 150 lines and
functions at 40 (blank lines and comments excluded), so features land as new files rather
than growing a scene.
noUncheckedIndexedAccess is on. For an index valid by construction, use at(list, i) from
sim/at.ts, which throws instead of letting undefined turn into NaN.
src/playable/ is a separate entry (the single-file playable ad, built by
vite/config.playable.mjs) and sits outside these module rules.
Scenes
Section titled “Scenes”Every scene is registered in main.ts. The canvas is a 1280x720 layout scaled by
RENDER_SCALE (core/renderScale.ts) with Scale.EXPAND, so wider screens see extra width.
| Scene | File | Purpose |
|---|---|---|
| Boot | boot/scenes/Boot.ts |
Loads only the loading screen’s art, applies master volume, mounts the dev menu in dev builds. |
| Preloader | boot/scenes/Preloader.ts |
Loads all assets, starts menu music, waits for the display font, then starts firstScene(). |
| Intro | menu/scenes/Intro.ts |
Press-to-start gate (it also unlocks browser audio), then the intro video. Skipped in dev builds. |
| FlashbackIntro, FlashbackEnd | menu/scenes/ |
The first start’s flashback: two lines over a still, the coached flashback race in Raya’s GX, three lines over a still, then the Prologue. |
| Prologue | menu/scenes/Prologue.ts |
Raya tells her story over four stills, then the main menu. The Intro sends only a first start here, through the flashback; Settings’ “Watch the story” replays both. |
| MainMenu | menu/scenes/MainMenu.ts |
Career, Garage, Dealer, Online, Settings. |
| Settings | menu/scenes/Settings.ts |
Audio and other options. |
| Career | hub/scenes/Career.ts |
The career hub: district boss card and event cards. |
| Tournament | hub/scenes/Tournament.ts |
Bracket screen before and between tournament rounds. |
| BracketDial | hub/scenes/BracketDial.ts |
Set your dial-in before a bracket race. |
| Wager | hub/scenes/Wager.ts |
Place a bet before a wager race. |
| BossIntro | hub/scenes/BossIntro.ts |
Boss screen: pick a tune slot per round, start the best of 3. |
| BossRound | hub/scenes/BossRound.ts |
Score between boss rounds; next round, forfeit or retry. |
| PinkSlip | hub/scenes/PinkSlip.ts |
First boss win: the boss’s car is awarded. |
| DistrictIntro | hub/scenes/DistrictIntro.ts |
When a pink slip opens a district that has stills (hub/districtIntros.ts, Harbor so far): its arrive beat over one still per line, then the career hub. |
| Epilogue | hub/scenes/Epilogue.ts |
After The Ghost’s pink slip: Raya gets her father’s car back and hands over the key, over three stills, then the career hub. |
| Garage | garage/scenes/Garage.ts |
Hub for Tune, Paint, Parts, Decals. |
| Tuning | garage/scenes/Tuning.ts |
Gears, boost and cam on three tune slots. |
| TestRun | race/scenes/TestRun.ts |
Solo run with a ghost of the last run, to try a tune. |
| Dealer | shop/scenes/Dealer.ts |
Buy and sell cars. |
| PartsShop, PaintShop, DecalShop | shop/scenes/ |
Upgrades and looks for the current car. |
| Race | race/scenes/Race.ts |
One career race (event or boss round). |
| RacePause | race/scenes/RacePause.ts |
Overlay launched over a paused Race. |
| Victory | race/scenes/Victory.ts |
Win celebration video, then Results. |
| Results | race/scenes/Results.ts |
Grades the run, settles a wager, awards the race; tabs for the timeslip and telemetry charts (race/view/resultTabs.ts). |
| NetLobby, NetBrowse, NetRoom | net/scenes/ |
Online: create or join a room, browse open rooms, the room lobby. |
| NetRace, NetQuit, NetResults | net/scenes/ |
The online race, its quit overlay, and its results. |
Almost every transition is scene.start (the current scene stops). The exceptions are the
overlays: RacePause and NetQuit are scene.launched on top of a paused or running race.
- Boot → Preloader →
firstScene()(boot/devStart.ts): Intro in production, MainMenu in dev, or any scene via?scene=<Key>in dev. - Intro → FlashbackIntro → Race (the flashback) → FlashbackEnd → Prologue on the first start (until the
welcomebeat is seen) → MainMenu. - MainMenu → Career (also Enter), Garage, Dealer, NetLobby, Settings.
- Career → an event card goes through
startEventRace(hub/startRace.ts):- tournament mode → Tournament, bracket mode → BracketDial, wager mode → Wager, each of which then starts Race;
- any other mode → Race directly, after
pickEligibleCar(hub/pickEligibleCar.ts) when the current car breaks the event’s class rule (it may send you to Dealer).
- Career → the boss card → BossIntro → Race.
- Race →
EventRace.resultScene()(race/EventRace.ts) decides: boss round → BossRound, tournament round → Tournament, else win → Victory → Results, loss → Results. - BossRound → next round (Race), PinkSlip on a first boss win, retry (BossIntro), or Career.
- PinkSlip → Career; when it opens a district with stills DistrictIntro → Career; after The Ghost Epilogue → Career.
- Results → Career, retry (Race with
restart), or Tuning. - Race ↔ RacePause (Esc, P, the pause button or hiding the page;
race/racePauseKeys.ts): resume, restart (not mid-series), or quit to Career. - Garage → Tuning, PaintShop, PartsShop, DecalShop; back → MainMenu.
- Tuning ↔ TestRun; Tuning → Race (career race) or back → Garage.
- Shops and Settings use
ui/ShopFrame.ts, whose back button and Esc go to the scene passed in: Garage for parts, paint and decals, MainMenu for Dealer, Settings and Career. - NetLobby ↔ NetBrowse; joining a room (
net/joinRoom.ts) → NetRoom → NetRace → NetResults → NetRace again, NetRoom or MainMenu. NetQuit overlays NetRace.
Race reads only an event id (or restart) from its scene data and builds the opponent,
distance and track itself through EventRace, so every route into a race passes the same
small payload.
Buttons that wait
Section titled “Buttons that wait”Every neon button (ui/NeonButton.ts) is idle, loading or disabled (ui/buttonState.ts). A tap that
waits on the race server, an ad, a purchase or a sign-in goes through runAction (ui/runAction.ts):
- The button shows a spinner in place of its label.
- The scene is busy until the action ends, so every other button on it looks disabled and takes no tap.
- The action resolves to the reason it failed, or to nothing. A reason shows in a toast at the top
(
ui/toast.ts) with the deny sound, and the button works again for a retry.
setButtonDisabled(button, reason) greys a button that cannot work now, such as a Buy the player
cannot afford or Quick match while the server is unreachable. A tap still plays the deny sound and
shows the reason. lockUntil makes a scene busy for a wait the player did not start: Results stays
locked until the server has judged the race.
Server calls time out after 10 seconds (player/account/accountApi.ts), and a room join after 10
seconds (net/joinRoom.ts), so no spinner turns forever. Sliders, toggles, list rows and tabs are
not neon buttons and ignore the busy state.
State and saving
Section titled “State and saving”There are three kinds of state, kept apart on purpose.
| Kind | Where | Saved |
|---|---|---|
| The garage: cash, owned cars (model, upgrades, tunes and tune slots, paint, underglow, decals, test runs), current car, career progress, story beats seen | GarageState in player/garageData.ts, held in the Phaser registry under garage (player/garageState.ts) |
Yes: localStorage key drag-racer-save, and on the race server once it owns the garage (below) |
| Session state: a boss fight in progress, a tournament run, a wager, a bracket dial-in, an unsaved tune draft | Their own registry keys (player/career/bossFight.ts, tournamentRun.ts, wager.ts, bracketDial.ts, player/tuneEditor.ts) |
No: a reload drops it (a boss fight is forfeited, which costs nothing) |
| Settings: volume, speed units, vibration | core/audioSettings.ts, core/unitSettings.ts, core/haptics.ts |
Yes, each in its own localStorage key, so a new game keeps them |
How the garage works:
getGarage(registry)returns the state, loading it from the save on first use (ornewGame()when there is none). The Phaser registry is game-wide, so every scene sees the same object.updateGarage(registry, change)is the only way to change it: mutate insidechange, and the whole state is written tolocalStorageright after. There is no separate save button or autosave timer.player/storage.tswraps the file as{ version, state }. A save with anotherSAVE_VERSIONis ignored, and any storage error is swallowed, so private windows simply start fresh.- Older saves are upgraded in place on load:
withNamedPaintsturns a per-car paint hue into a named paint,withCareerreplaces the old ladderrungwith a fresh career. Fields added later (tunes,decals,storySeen) are optional in the types for the same reason.
Spending cash in the shops does not go through updateGarage directly. The dealer, parts, paint
and decal shops send a GarageAction to requestGarageAction (player/account/garageRequest.ts),
whose rules (player/garageActions.ts, prices in player/shopRules.ts) work out every price
themselves and refuse what the player cannot afford.
The race server owns the garage, so a modified game cannot give itself cash (design:
docs/2026-11-07-accounts-leaderboards-design.md). Every player has an account, a guest one from the
first launch (player/account/accountApi.ts). When a guest with progress links an Apple or Google
account that already has a save, the server refuses the link until the player picks which save to
keep (menu/saveChoice.ts, party/src/db/linkGuest.ts). The game loads the
garage from the server at boot, shop actions run on the server (party/src/api/garageApi.ts, the
same rules), and the value-free fields (driven car, tunes, test runs, underglow, story beats) are
synced a second after each local change (player/garageSync.ts decides which). Career
races, wagers, tournaments, ad rewards and fuel are paid and spent by the server too, so nothing in the
game writes cash itself. The career hub fetches the garage as it opens; without an answer it shows
“No connection” with Retry and Back (hub/view/offlineScreen.ts), so no career race starts that
could not count. npm run dev starts the race server next to Vite.
Career data (districts, events, bosses) is static code in player/career/; only progress
(CareerProgress from player/career/progress.ts) goes in the save. See
Career and the generated career reference.
Dev loop
Section titled “Dev loop”| Command | What it checks |
|---|---|
npm run check |
tsc for the game and scripts, tsc for party/, and ESLint (module boundaries, file and function size). Phaser has no compile step, so this is the main safety net. |
npm run shot |
scripts/shot.mjs: starts Vite, runs the game in headless Chromium, saves screenshots/latest.png and exits 1 on any browser error. Flags: --scene, --frames, --drive (full race), --setup (JS before the scene), --query, --click, --then-click (after the screenshot and drive, one more screenshot per click). |
npm test |
Vitest over tests/: sim, career, parts, tuning, decals, net grace rules and more. Tests import modules directly, with tests/fakeRegistry.ts standing in for Phaser’s registry. |
npm run career:check |
scripts/career-check.ts: a frugal scripted player runs the whole career. Exit 1 when it loses a race, a boss stays locked, a stock car of the previous district wins an event, or a boss falls to a car he must stop. |
npm run parts:check |
scripts/parts-check.ts: no part at any tier may make any car slower than stock (beyond NOISE_FRAMES of jitter). |
Also handy: npx tsx scripts/netCheck.ts (replay, opponent prediction and clock sync without a
server) and npx tsx scripts/tick-budget.ts (what shift timing costs per car).
In dev builds window.game is the running Phaser.Game, ?scene=<Key> jumps to a scene
after loading, and a DEV button (boot/dev/devMenu.ts) opens a cheat panel with a scene
jumper; its STORY section (boot/dev/devStory.ts) opens The Ghost’s pink slip, the Prologue, the Harbor intro or the
Epilogue without touching the save, to check the story without beating the career. The pre-commit hook runs eslint --fix on staged .ts files only.
The party server
Section titled “The party server”party/ is a Cloudflare Worker built on partyserver, with two Durable Objects (party/wrangler.jsonc):
RaceRoom, one per room code, holding up to four players who all race at once, and
RoomIndex, the list of open rooms that only rooms themselves may write to (party/src/index.ts).
The server imports the game’s sim/ and net/protocol.ts directly, which is why sim must
stay free of Phaser: clients send their inputs, and the server replays them through the same
simulation to judge the race (party/src/judge.ts). npm run check type-checks it with
party/tsconfig.json. See Online for the protocol and race flow, and
Race sim for the simulation itself.