Career
The career is the single-player progression: four districts, each with a crew of racers who hold the events and a boss at the end. Its content is plain data in player/career/ (no Phaser), its rules are small pure functions next to it, and the screens that show and start it live in the hub/ feature module. Design intent is in docs/2026-11-06-career-design.md; this page describes what the code does. For every district, event and boss with its numbers, see the career reference.
Structure
Section titled “Structure”| Level | Type (player/career/types.ts) |
What it holds |
|---|---|---|
| District | District |
name, track, fits (the dealer models of its class), carClass (the one class it races), crew, boss |
| Crew member | CrewMember |
a name and a list of events |
| Event | CareerEvent |
stable id (saves key stars on it), mode, opponent, distance, prize, plus mode settings (rule, entrants) |
| Boss | Boss |
id, name, portrait texture key, opponent, prize |
DISTRICTS in player/career/career.ts lists them in unlock order (Street, Harbor, Chinatown, Strip). Each district is one file in player/career/districts/, written with the one-line helpers from player/career/eventSpec.ts: headToHead, restricted, bracket, wager, roll, tournament and bossOpponent. A car is written as a shorthand tuple [modelId, parts, paint], so a district file reads as a table of races.
career.ts also flattens everything into CAREER_RACES: one CareerRace per event and per boss, carrying what the race scenes need (opponent, distance, prize, track, boss flag, the event for its mode, and the rule of cars it lets in). findCareerRace(id) is how every other part of the game turns a race id back into a race.
One class per district
Section titled “One class per district”District N races class N, in every event and in the boss fight (docs/2026-11-08-progression-design.md, “Classes”), so a higher class bought early does not help in an earlier district. career.ts puts { carClass: district.carClass } into every CareerRace’s rule, on top of a restricted event’s own rule, and everything that checks a car reads that one rule: the hub before a race (hub/startRace.ts, which offers an owned car of the class or the dealer through hub/pickEligibleCar.ts), the race server’s ticket (party/src/race/raceTicket.ts) and the balance check. The district tabs show the class (hub/view/districtTabs.ts). The Strip races class 4 (H, Hyper): the Hyper R8 at the dealer and the Super VL, Victor Lau’s pink slip.
Opponents
Section titled “Opponents”An OpponentSpec is a driver name, a car spec and a sloppiness. player/career/opponent.ts builds it into a raceable Opponent:
- Car. The model with its first
partsperformance parts inBUILD_ORDER(opponent.ts: drag tyres first, then the power parts, then suspension, and the gearbox and turbo parts last, like a street racer’s build) all at onestage. Events useOPPONENT_STAGEfromeventSpec.ts;FULL_PARTSthere is the count of every performance part. A part whose requirement is not in the build does nothing (buildCarStats). Opponents always get a full nitrous tank. - Sloppiness. 0..1, how slow the AI driver reacts at green (about 1 s at 1) and how many launch and shift mistakes it makes (
sim/aiDriver.ts). District files lower it crew member by crew member, so later racers drive cleaner as well as faster. - Bosses.
bossOpponentsetsBOSS_SLOPPINESS(near zero) andprecise: true, which selects the precise driver profile insim/aiDriver.ts. A boss may also have his own stage (bossOpponent’s third argument) or apowershare (of his car’s HP and torque). Dex Moreno, Mara Kane and Victor Lau drive a car of the next district’s class, their pink slip: Dex races it stock on his own engine tune at 118% power (since traction control a built class 1 car hooks up as well as his all-wheel drive), Mara stock at 78%, Victor stock at full power. The Ghost races his Hyper GX with 20 parts at Stage 2, between a fully built Super VL and a well built Hyper R8.
Difficulty is therefore tuned with three knobs per race: the model, how many parts it carries, and how sloppily it is driven. See Race simulation for the AI driver itself and the parts reference for the part order.
raceStars in player/career/stars.ts gives 0 for a loss, otherwise 1 to 3. Each extra star is an independent test, so a clean run that only just wins still earns two:
| Star | Test |
|---|---|
| 1 | a win |
| +1 margin | won by at least MARGIN_SHARE of the opponent’s time, or the opponent never reached the line. In a bracket race this is replaced by running on your dial (onDial in bracket.ts) |
| +1 clean run | the launch plus all upshifts lost at most CLEAN_RUN_SHARE of the perfect run’s time, as graded by sim/runGrades.ts (the shift table on the results screen) |
The save keeps the best stars per event, never a lower result. Tournaments score by placing instead (see below). Bosses have no stars: the save records only whether they were beaten.
Prize money and progress
Section titled “Prize money and progress”player/career/progress.ts owns the career’s part of the save (CareerProgress: stars per event id, bossesBeaten in order, and the last boss tune slots). Which districts are open is derived from bossesBeaten, never stored separately, so it cannot drift.
settleRace applies a finished race and returns a RaceAward:
| Outcome | Cash |
|---|---|
| First win of an event or boss | the full prize |
| Any later win | REPLAY_SHARE (a quarter) of the prize: the grind between first wins, so a first win still counts for four |
| Loss after crossing the line | CONSOLATION_SHARE of the prize, so a losing streak can still buy parts |
| Loss without reaching the line | nothing |
settleCareerRace.ts applies this to a garage; only the race server runs it. Wager bets are settled separately, see below.
The race server settles every career race (see Architecture): race/judgedStart.ts fetches a ticket with the AI’s seed before the race, and the results screen sends the input log and the tune raced; the server races the same race again (race/replayEventRace.ts, built by the shared race/eventRaceSetup.ts) and pays what that earns (party/src/api/raceApi.ts). A wager’s ticket carries the bet, held to the limit (player/career/wagerRules.ts). A boss fight or tournament is a series: the server keeps its rounds (career_series, party/src/db/careerSeries.ts), each round’s ticket names its round, and the server builds that round’s distance or rival from the rounds before (player/career/seriesRules.ts) and settles the series at its end with the same rules as the game (bossFightRules.ts, tournamentRules.ts). The boss and tournament screens wait for that verdict (hub/roundVerdict.ts); without a connection the round does not count and the series is forfeited. Keep rounds won undoes the lost decider on the server for a confirmed ad.
Unlocks
Section titled “Unlocks”- Boss. Unlocks once the district’s stars reach
bossStarsNeeded:BOSS_UNLOCK_SHAREof the district’s maximum (events timesMAX_STARS), rounded up, both incareer.ts. Events inside an open district are never locked; the player can race them in any order. - Next district. Opens when the previous district’s boss is beaten for the first time (
isDistrictUnlockedinprogress.ts;settleRacereports it asunlockedDistrict). - Plain start.
nextRaceIdpicks what Enter on the main menu races: in the furthest open district, the first unwon event, else the waiting boss, else the first event short of full stars.
Boss fight
Section titled “Boss fight”A boss is a best of BOSS_ROUNDS.length, one round per distance in BOSS_ROUNDS order (eighth, quarter, half), first to BOSS_ROUNDS_TO_WIN takes it (career.ts). Before the fight, hub/scenes/BossIntro.ts lets the player assign a tune slot to each round, because one gearing rarely suits all three distances (see Tuning).
player/career/bossFight.ts keeps the fight state (bossId, slots, round results) in the Phaser registry, not in the save. Leaving or reloading mid-fight forfeits it, which costs nothing. startBossRound is called by race/EventRace.ts as each boss race starts: it selects that round’s tune slot and returns the round’s distance. After each round hub/scenes/BossRound.ts records the result, shows the score with a spoken boss line, and offers Next round or Forfeit.
When the player wins the fight, the race server settles it as a one-star race (settleBossFight in bossFightRules.ts). The first win goes to hub/scenes/PinkSlip.ts: the boss’s car joins the garage with his parts (none for the first three bosses, who race stock) and paint (bossCar in opponent.ts, skipped when that model is already owned), the prize is paid and the next district opens. A re-race win only pays the replay share.
player/career/series.ts treats a boss fight and a tournament alike: a race inside one has no restart, and quitting forfeits the whole series.
Event modes
Section titled “Event modes”EventMode in types.ts; modes.ts gives each its label on the event card.
| Mode | How it is won | Code |
|---|---|---|
headToHead |
first over the line | eventSpec.ts |
restricted |
head-to-head, but only cars passing the event’s rule may enter |
carRule.ts |
tournament |
three knockout rounds against seven entrants | tournament.ts, tournamentRun.ts |
bracket |
both dial in, the slower dial starts first, a breakout loses | bracket.ts, bracketDial.ts |
wager |
head-to-head with a bet on top of the prize | wager.ts |
roll |
both cars start rolling at ROLL_SPEED (sim/roll.ts), no launch |
sim/roll.ts |
Restricted
Section titled “Restricted”A CarRule combines optional carClass (exactly that class, set by the district), maxClass, maxHp, stock and noNitrous; every field set must hold. stock lets every car in, but races it with no part beyond the nitrous kit every car comes with and no part brands (carUnderRule, the weekly event’s stock week). noNitrous lets every car in, but underRule strips nitrous from both cars for the race (both applied in race/eventRaceSetup.ts). ruleLabel renders the rule for the card, for example “C-CLASS ONLY” or “NO NITROUS”.
Tournament
Section titled “Tournament”Eight seeds: the player is seed 0, the event’s entrants seeds 1 to 7. Round 1 pairs 0-1, 2-3, 4-5, 6-7; later rounds pair winners in order. A race between two AI entrants goes to the quicker average AI run (sim/aiRun.ts, cached), so the bracket is deterministic and the career check can play it exactly as the game does. Each round is raced with a tune slot chosen on hub/scenes/Tournament.ts, and the garage cannot be visited in between.
A tournament is one event with stars by placing (tournamentStars): 0 out before the final, 1 for losing the final, 2 for winning, 3 for winning with every round’s run clean. Placing rather than a star per round keeps one event per tournament and stops round 1 alone from paying out. Cash in settleTournament (tournamentRules.ts) follows placing too: losing the final pays FINALIST_SHARE, winning the full prize; a better placing than before pays the difference, the same placing again a replay share, going out early the consolation.
Bracket
Section titled “Bracket”Each racer sets a dial-in, the time they expect to run. The slower dial gets the head start by the difference (opponentStartFrame, in sim frames), so two perfect runs to the dial finish together. wonBracket:
- A racer who did not finish never wins.
- Running quicker than your dial is a breakout and loses; if both break out, the smaller breakout wins.
- Otherwise whoever ran closer over their dial wins.
hub/scenes/BracketDial.ts suggests a dial of the analyzer’s perfect run plus DIAL_SUGGESTION_SHARE (a perfect-run dial would break out on any imperfect run). The AI dials its median seeded run plus AI_DIAL_SHARE; the median ignores its rare wheelspin disasters. The margin star becomes “on dial”: finishing within ON_DIAL_SHARE over it without breaking out. The player’s dial is kept in the registry for the event and its retries.
hub/scenes/Wager.ts lets the player bet in steps of BET_STEP, up to WAGER_SHARE of the opponent car’s value (model price plus its parts at their stage) and never more than the cash in hand (betLimit). A win adds the bet, a loss takes it; the race server applies it before the prize (applyWager in wagerRules.ts). The bet stays set for the event, so a retry races for the same stake.
How the hub starts races
Section titled “How the hub starts races”hub/scenes/Career.ts shows district tabs (locked until the previous boss falls), one column of event cards per crew member and the boss card on the right. It reopens on the district last shown, and jumps to a newly opened one. Tapping a card goes through hub/startRace.ts:
tournament,bracketandwagerevents open their own screen first (Tournament,BracketDial,Wager), which then starts the race.- Other events start the
Racescene with the event id. If the current car fails a restricted event’s rule,hub/pickEligibleCar.tsopens a choice of owned cars that pass (it sets the picked one as current), or, when none does, offers the dealer. - The boss card starts
BossIntro, which runs the best of 3.
race/EventRace.ts turns the event id back into everything the race needs (the Phaser-free part is race/eventRaceSetup.ts): it builds the opponent (or the tournament round’s opponent), applies the rule, the dials and the roll speed, and afterwards routes the result to BossRound, Tournament, or Victory/Results.
Raya and story beats
Section titled “Raya and story beats”The guide, Raya Quinn, speaks short voiced and subtitled lines at fixed moments (“beats”). Her premise (docs/2026-11-06-story-guide-design.md): she lost her car, the Hyper GX that The Ghost drives as Strip boss, in a pink-slip race, and the four bosses are the road to winning it back. docs-site/src/content/docs/story/the-quinn-key.md is the full story and the source of her opening.
- On the first start the flashback comes first:
menu/scenes/FlashbackIntro.ts(beatflashback-intro), then the flashback race in Raya’s Hyper GX (player/story/flashback.ts: an easy rival, no prize, no race server), coached byrace/coach/flashbackCoach.ts. The coach plays theflashback-racelines throughui/guide/guideSubtitle.ts(a subtitle that does not stop the race) and slows the race throughRaceClock.timeScale: quarter speed on the lights, a hold at first gear’s shift point until the player shifts, slow motion until the nitrous fires. The race skips Results formenu/scenes/FlashbackEnd.ts(beatflashback-end), which leads into the Prologue; its pause menu skips there too (race/raceHome.ts). - Then
menu/scenes/Prologue.tsplays thewelcomebeat (14 lines) over four story stills that change with her lines, then opens the main menu. The Epilogue (hub/scenes/Epilogue.ts) does the same with thefinalebeat after The Ghost’s pink slip, before the career hub.hub/scenes/DistrictIntro.tsdoes it for a district a pink slip opens: its arrive beat (harbor-arriveand so on) over the stills listed inhub/districtIntros.ts(assets/sprites/districts/), then the career hub. Playing the beat marks it seen, so the hub does not show it again; a district without stills yet (Chinatown, the Strip) keeps its arrive beat as a panel on the hub. All three are thin scenes overui/guide/storyStills.ts, which cross-fades a still per line through the guide panel’sonLineoption. The Prologue always plays (showBeat‘sreplayoption), so Settings’ “Watch the story” can replay it, starting from the flashback (FlashbackIntro, race included; its pause menu skips to the Prologue); the Intro is what limits it to the first start. player/story/beats.tslists everyBeatId.TIP_BEATSare the tutorial tips that Settings’ “Reset tips” shows again; story beats stay seen.player/story/careerBeats.tsandresultBeats.tsdecide which beats a screen may show: district arrivals, the first boss unlock, each boss’s before, lost and after lines, the races against Iris Quinn (Raya’s sister, in Harbor’s crew) and postgame. Results plays the first-win, first-loss and first three-star lines after career races only, so a quick race or a weekly event race cannot use them up.- Iris speaks in her own beats (
iris-before,iris-after): each line carries its speaker, and the panel swaps portrait and name per line (SPEAKERSinui/guide/guideVoice.ts). player/story/seen.tskeeps seen beats in the save (storySeen).ui/guide/showBeat.tsis the one call a scene makes; it marks a beat seen as it opens, so leaving halfway does not replay it.
Balance check
Section titled “Balance check”npm run career:check (scripts/career-check.ts) plays the whole career with a simulated player under a stated daily play pattern (scripts/playLength.ts) and reports the days each district takes. The pattern: three sessions a day far enough apart for the tank to refill, each spending a full tank, plus the day’s three fuel ads and three daily bonuses, so 36 races a day. Level-ups refill the tank too; the model leaves that out.
The player races the career in order. Before a race it keeps its cars if one already wins. Otherwise it shops with all its cash (scripts/careerPlayer.ts): the part or stage with the most seconds saved per dollar, or a dealer car of the race’s class, then fits its brand tokens where they save the most. It only spends on cars that can win the race at all once fully built (maxedCar), so it saves up for the Hyper R8 instead of sinking cash into the Super VL pink slip, which The Ghost always beats. maxedCar sweeps every part to its quickest stage at most MAX_SWEEPS (4) times: the quick estimate is driven like the build before, so near the top two parts could otherwise trade places forever (they did once traction control made most top-stage parts worth only a few frames). If it still cannot win, it plays out the session replaying the best-paying race it has won, then tries again. Before a boss it replays the events where its cars now earn more stars until it has the boss’s stars. Prizes, replays, the daily bonus and tokens go through the game’s own rules (settleRace, applyDailyBonus, fitBrand). A race counts as won when a human at 85% of the perfect run beats the opponent’s seeded AI runs; the player never drives a clean run, so it earns at most 2 stars. To run in about a minute, the shopper ranks offers with a quick estimate (quickHumanTime: one race driven like the current build’s best run, simulateRunWith in sim/bestRun.ts) and as a lazy greedy (an offer’s last measured gain is kept and only the top offer is measured again until it stays on top); the race verdicts themselves use the full best-run search. It throws when the player is stuck for good, and exits 1 when:
- district 1 takes outside 2 to 3 days, or the four districts outside 6 to 8 weeks (
DISTRICT_1_DAYS,LAUNCH_DAYS); - a stock car of the district’s cheapest class car wins an event of its last two crew members (each district must force an upgrade; a car of the district before cannot enter at all);
- a boss falls to a car he must stop (
scripts/careerBosses.ts): the previous boss’s car as won, every dealer car of the district before with every affordable part, (a car of another class cannot enter, so it never takes the fight). The previous boss’s car built up may win: it is a car of the district’s class, the ticket in; - the previous boss’s car, as won, wins more than
BOSS_CAR_SHAREof the next district’s events (scripts/careerFight.ts): the pink slip is a head start, not a skip.
The same run backs the assertions in tests/careerBalance.test.ts (about 30 s), which also checks that each district takes longer than the one before and that the starter car wins the first event without buying anything. Run the check after changing any district file, opponent, price, part or sim constant. The numbers it reached are in docs/2026-11-08-progression-design.md, “Economy as built”.