Art and audio pipeline
All art and most audio is generated (Gemini image, video, music and TTS models), then cut out and
processed by small Node scripts in tools/, and loaded by path from public/assets/. Raw
generations live in art/source/ next to a .json sidecar holding the prompt; they are not shipped.
The game never sees a source file, only the processed copy. The step-by-step workflows (with the
rules the user set) are the project skills in .claude/skills/ (car-sprites, track-sprites,
sound-effects) and art/README.md; this page explains the mechanisms behind them.
To browse the shipped files see /media/sprites/ and /media/audio/; car and paint data are on /reference/cars/ and /reference/paints-decals/.
Loading
Section titled “Loading”boot/scenes/Preloader.ts sets the loader path to assets and calls one loader function per
area, so each module owns its own file list:
| Function | File | Loads |
|---|---|---|
loadArt |
player/art.ts |
per car: side sprite, side paint layer, 3/4 hero sprite, hero paint layer; cars.json; decal images (via loadDecals); garage and menu backdrops, start tree, intro video |
loadTracks |
race/view/tracks.ts |
lane markings, then every layer file of every track (deduplicated) |
loadRaceAudio |
race/audio/raceAudio.ts |
engine loops, nitrous hit and hiss, tyre screech, crowd cheers |
loadMenuMusic |
menu/menuMusic.ts |
the menu tracks |
loadVictoryVideo |
race/scenes/Victory.ts |
the win video |
loadPrototypeArt |
race/prototypes/prototypeViews.ts |
art for the abandoned camera experiments |
loadBootArt in player/art.ts loads the loading screen’s own art earlier, from Boot. Voice lines
and boss and guide portraits are not preloaded: they load on demand when a scene first needs them
(see Voice lines).
Texture keys come from helpers (carTextureKey, carPaintTextureKey, carHeroTextureKey,
carHeroPaintTextureKey, layerKey), never from string literals in scenes. Videos list the MP4
first: only MP4 is precached for offline play (vite/pwa.mjs), and Phaser plays the first format the
browser supports.
Two views per car
Section titled “Two views per car”Each car id in CAR_SPRITES (player/art.ts) has:
| View | File | Used by |
|---|---|---|
| Side, facing right | public/assets/sprites/cars/<id>.webp |
the race (player/view/CarView.ts) |
| Three-quarter front | public/assets/sprites/cars34/<id>.webp |
menus, dealer, garage (player/view/HeroCar.ts, MenuCar.ts) |
Each view has a matching <id>-paint.webp layer. The sprite id is the car’s visual identity, separate
from the buyable model: CarModel.sprite in player/carModels.ts points a model (dealer
CAR_MODELS or boss BOSS_MODELS) at a sprite.
Generation and cut-out
Section titled “Generation and cut-out”tools/generate-image.mjsmakes candidates on a flat green background (16:9). The prompt is a per-car description plus shared sentences kept verbatim in.claude/skills/car-sprites/references/prompts.md; each car’s exact prompt is in its sidecar.tools/edit-image.mjsturns the chosen side view into the 3/4 view.tools/chroma-key.mjs --green [--flip]removes the background (--flipwhen the model drew the car facing left). Green paint is therefore impossible: the key would remove it.- The keyed image is saved as WebP into
public/assets/sprites/cars[34]/.
Written rules in the skill: tinted near-black windows in every view, inspired by one named real car but never a copy (no badges or signature grilles), dark paints must still show the body shape. A new style starts with a one-car pilot the user approves before batching.
cars.json: wheels and hero ground
Section titled “cars.json: wheels and hero ground”public/assets/sprites/cars/cars.json holds per car, in sprite pixels:
wheels: hub centre and tyre radius, rear wheel first. Written bytools/wheel-spots.mjsfrom a rough guess per wheel read off a grid sheet (.claude/skills/car-sprites/scripts/grid.mjs). The tool snaps the hub to where the spun rim wobbles least.CarViewuses the wheels twice: the lowest tyre edge sets the car’s ground line, and for each wheel it cuts a disc ofRIM_FRACTIONtimes the radius out of the sprite and rotates it on top. Only the rim spins, so fenders that overlap the tyre stay still, and no separate wheel art is needed.heroGround: where the 3/4 view’s near tyres touch the ground, written bytools/hero-ground.mjs(the longest edge of the lower hull of the silhouette). The hero underglow is laid along this line (player/view/heroUnderglow.ts), and the menu car’s reflection in the wet road is mirrored across it (player/view/heroReflection.ts).
Both tools read and write the file through tools/carsMeta.mjs, one car per line.
Paint layer and repainting
Section titled “Paint layer and repainting”The paint shop does not recolour the whole sprite. It recolours a separate paint layer drawn over the untouched car, so glass, lights, tyres, carbon and chrome keep their colour.
- A mask per view:
tools/edit-image.mjsrepaints the green-screen source flat magenta on the body panels only (mask prompt inart/README.md), saved inart/source/[cars34/]paint-masks/<id>.webp. tools/paint-layer.mjs [<id>]and--hero [<id>]find the sprite’s crop inside the source, take the masked pixels and store light in the red channel (0 shadow, 1 full colour) and highlight in green (0 paint, 1 white).- In game,
applyPaint(player/view/paint.ts) adds a ColorMatrix filter to the layer that mixeslight * paint + highlight * (white - paint). Shadows go to black through the full colour and highlights fade to white, so a named paint looks the same on every car whatever its factory colour.Paint.liftinplayer/paints.tsscales the light for very light paints (white, silver). A null paint hides the layer and shows the factory colour.
The skill warns against colour-threshold masks: the low-key lighting leaves dark lower bodies that a colour test misses, which repaints two-tone.
Underglow factory colour
Section titled “Underglow factory colour”FACTORY_GLOW in player/underglow.ts maps each sprite id to the glow colour used with the factory
paint (the median of its paint layer, lifted). A sprite missing there falls back to FALLBACK_GLOW.
Decals
Section titled “Decals”Sponsor stickers and graphics are cut from Nano Banana sheets (art/source/decals/):
tools/chroma-key.mjs keys the sheet, tools/split-sheet.mjs cuts it into one WebP per item in
public/assets/sprites/decals/, named in reading order. Race numbers and racing stripes are drawn in
code (player/view/numberDecal.ts, stripeDecal.ts), so they have no image. The catalog (id, name,
price, kind) is DECALS in player/decals/decals.ts.
Slots: slots.json
Section titled “Slots: slots.json”tools/decal-slots.mjs writes public/assets/sprites/decals/slots.json. A slot is a parallelogram
(origin, u along, v down) in sprite pixels.
- Side view: computed from the wheel spots and the paint mask, no hand work. The stripe band runs
the body’s length at door height; layers are placed along that band. Cars whose side is broken by
intakes get a looser solid-paint share in
SOLID_PER_CAR. - Hero view: no slots of its own. A
matchpairs points found on both images (body ends, wheel hubs, door top corners; hand placed intools/decal-hero-points.json, rear first), andplayer/decals/planeWarp.tsmaps hero pixels back to side pixels.
Baking: decalLayer.ts
Section titled “Baking: decalLayer.ts”decalLayerTexture (player/view/decalLayer.ts) bakes all of a car’s decals into one canvas texture
the size of the sprite, cached by car, view and decal set:
- Draw the stripe, then each layer (later on top), on the side sprite’s band.
- For the hero view, resample that side image through the
match. Hero images face left, so text is pre-mirrored;graphicdecals are not. - Clip to the paint layer and shade with the car’s own lighting (
shadeDecalandpaintLuminanceinplayer/decals/decalGeometry.ts), so a sticker picks up the body’s light.
Hood stripes: street pictures only
Section titled “Hood stripes: street pictures only”A car’s decals.hood is a stripe over the hood and front bumper. The side view barely shows the hood,
so only street pictures draw it (player/view/hoodDecalLayer.ts, called from ui/carSceneDecals.ts):
- Draw the stripe flat, across the car by along it, windscreen to the bottom of the bumper. Its
width, shift and the gap between a pattern’s stripes (
player/decals/stripeBands.ts) set where. - Lay it onto the picture through
hoodinui/carScenes.ts: rows of three points (far edge, centreline, near edge) from the windscreen’s base, over the hood (a row mid hood for its crown) and its front edge, down the bumper (a row at each feature its centreline passes) to just past the bottom of the bumper’s painted face.player/decals/hoodWarp.tsmaps across each row with the one perspective (1D projective) map its three points fix, bent through them, so a centred stripe sits on the real centreline and twin stripes come out the same width; down the car a smooth curve runs through the rows, so stripes round over the hood’s front edge instead of folding. The near point must mirror the far one (the same spot on the other side), or stripes come out uneven. - Clip to the picture’s body (its alpha leaves out the grille, lights and glass) and shade like the side decals. The texture covers only the hood’s region and is redrawn in place on every change.
The decal shop shows a HOOD chip only for cars with a street picture. Its preview
(shop/decals/decalPreview.ts) keeps both views: side decals are placed on the flat side view, which
is exact and what the race draws, with the street picture in an inset (a second camera); on the HOOD
chip the two swap.
Recolouring: regions.json
Section titled “Recolouring: regions.json”tools/decal-regions.mjs finds up to a few colour centres per decal (k-means on solid pixels, merged
by distance and hue) and writes public/assets/sprites/decals/regions.json. In game,
recolorDecal (player/decals/recolor.ts) assigns every pixel to its nearest centre; a region with
a new colour takes that colour’s hue and saturation and keeps the pixel’s lightness offset from the
centre, so highlights and outlines survive. Antialiased pixels between two regions are blended.
ensureDecalTexture (player/view/decalTextures.ts) caches each recoloured variant as its own
texture.
Tracks
Section titled “Tracks”A race is one fixed road in a side view; the backdrop is a stack of horizontal layers scrolling at
different speeds. TRACKS in race/view/tracks.ts maps each track id (TRACK_IDS in
core/trackIds.ts) to its layers: { file, top, height, parallax, reflection? } in game pixels,
array order is draw order. Each career district names its track (District.track in
player/career/types.ts); see /reference/career/.
| Kind | Examples | Processing |
|---|---|---|
| Opaque | sky, road | tools/layer-finish.mjs |
| Cut-out | towers, buildings, crowd | chroma-key.mjs --green, then layer-finish.mjs --drop-specks |
| Repeating | road texture, crowd | tools/tile-layer.mjs --crossfade (textures only) or tools/mirror-tile.mjs (objects) |
layer-finish makes every pixel fully solid or clear (half-keyed smears would show as a tint) and
scales to at most 4096 px wide, the safe texture width on phones. Layers are generated fresh per
layer at 8:1; the skill records why editing a key art into layers failed. Other helpers:
split-layer.mjs and spread-layer.mjs (separate or space out objects in a keyed row).
Shared parts:
- Road and curb: every track except
stripspreads the sharedROADlayers and starts its asphalt atSHARED_ROAD_TOP(hasSharedRoad). Never generate a road per track. - Markings:
tools/road-markings.mjsdraws the lane dash and the double yellow centre line as clean textures intopublic/assets/sprites/tracks/markings/; they are not generated.loadTracksloads them with the start and finish band (MARK_LINE,MARK_DASH,MARK_DOUBLE). - Lanes and car rows: shared by all tracks (
race/view/TrackView.ts,groundPerspective.ts).
Dev pages: map.html?track=<id> runs the race’s TrackView without cars, with per-layer controls
and “Copy as code” for the layer array; sprites.html?track=<id> shows each layer alone. Register a
layer’s source image in boot/dev/layerSources.ts so those pages can show the original next to the
processed file.
All audio is played through Phaser’s sound manager or straight through its Web Audio context. Four
volume channels (master, music, engine, effects) live in core/audioSettings.ts, stored in
localStorage apart from the save. Race audio builds one gain node per channel (channelBus in
race/audio/raceAudio.ts).
| Sound | Source | Code |
|---|---|---|
| Engine | loops from Gemini video clips, synth fallback | LoopEngine.ts, EngineSound.ts, engineProfiles.ts |
| Opponent engines | same engine, quieter, lower pitch, panned by the gap | OpponentEngine.ts |
| Nitrous | clip: one-shot hit, then a looping hiss while boosting | NitrousSound.ts |
| Tyre screech | clip, once when a launch goes into wheelspin | TyreScreech.ts |
| Shifts, steam, blow-up, start beeps | synthesized | RaceSfx.ts |
| Crowd cheer | clips, alternated, only on a loss | raceAudio.ts |
| Menu music | Lyria tracks, alternated | menu/menuMusic.ts |
| UI click, tick, buy, deny | synthesized | core/uiSound.ts |
| Boss and guide lines | Gemini TTS | hub/audio/bossVoice.ts, ui/guide/guideVoice.ts |
Race paths live under race/audio/ unless shown otherwise.
Engine: loops vs synthesis
Section titled “Engine: loops vs synthesis”createEngine (LoopEngine.ts) returns a LoopEngine when the loops loaded and the simpler
EngineSound synth otherwise (saw at firing frequency, square sub-octave, intake noise, drive,
lowpass). Both expose update(revRatio, throttle, boost), stall() and destroy().
LoopEngine plays every loop in ENGINE_LOOPS (raceAudio.ts, each recorded at a fixed RPM) at
once, from random start points so they do not phase. Each frame it maps the sim’s rev ratio to an RPM
over the car’s EngineProfile range (ENGINE_BY_SPRITE in engineProfiles.ts), pitches every loop
by rpm / loopRpm (clamped to MIN_RATE..MAX_RATE), and gives the two loops either side of that
RPM equal-power crossfade weights (cos and sin). Throttle opens a lowpass and raises the volume;
nitrous raises it further. All cars share one V8 loop set today; the profile only changes the RPM
range. updateRaceAudio.ts drives every engine once per rendered frame.
Why loops at fixed RPMs: the skill found that steady clips crossfade cleanly and clips with a moving
RPM do not, and that pitching one clip far sounds wrong, hence several RPM steps. tools/engine-loop.mjs
cuts a loop from the steady middle of a clip and crossfades its tail into its head so it wraps
without a click.
Clips and how they were cut
Section titled “Clips and how they were cut”The nitrous, tyre and crowd clips came from tools/generate-video.mjs (Gemini video audio), cut with
ffmpeg and high-passed to remove engine rumble the model added (the cut points and filters are in the
comments above the keys in raceAudio.ts). A clip that fails to load is skipped: every player checks
the cache first, because Phaser throws on a missing key.
Menu music starts from startMenuMusic, which hooks every scene’s START event once: scenes in
SILENT_SCENES stop the music, any other scene plays it. A new menu scene therefore needs no music
code.
Voice lines
Section titled “Voice lines”Boss and guide lines are voiced by tools/boss-lines.mjs from tools/boss-lines.json (or
--data tools/guide-lines.json --out public/assets/audio/guide for the guide; tools/guide-lines-from-doc.mjs
writes that file from the beat tables in docs/2026-11-06-story-guide-design.md, Raya’s and Iris’s lines
numbered in spoken order). It calls the
rsp:voiceover skill script for TTS, transcription check and silence trim, adds a per-voice filter,
normalises loudness, and writes mono MP3s plus a lines.json (public/assets/audio/bosses/lines.json,
public/assets/audio/guide/lines.json, keyed by voice id, each line with kind, text, file).
Unchanged lines are reused from its cache.
In game, loadBossVoice and loadBeat load lines.json and then only the clips that voice or beat
needs. sayBossLine picks a random line of a kind; sayGuideLine plays one line. One line speaks at a
time and leaving the scene stops it. Both play on the effects channel. Portraits are loaded the same
way from public/assets/sprites/bosses/ and sprites/guide/; tools/boss-portrait.mjs sizes a boss
portrait and fades its edges. The guide’s portrait is a cut-out instead, so she stands on any backdrop: her approved
portrait re-rendered on green (tools/edit-image.mjs), keyed with tools/chroma-key.mjs --green and
saved at 720 px high (sources art/source/guide/raya-cut-2.png and art/source/iris/iris-j-cut.png).
Checklist: add a new car
Section titled “Checklist: add a new car”Follow the car-sprites skill for the art itself (pilot, candidates, user approval).
- Art: side view and 3/4 view generated, keyed and saved to
public/assets/sprites/cars/<id>.webpandcars34/<id>.webp; sources and sidecars inart/source/<id>.*andart/source/cars34/<id>.*. - Register the sprite: add
<id>toCAR_SPRITESinplayer/art.ts.loadArtthen loads all four images. - Engine: add the id to
ENGINE_BY_SPRITEinrace/audio/engineProfiles.ts(aRecord, sonpm run checkfails until you do). - Wheels:
node tools/wheel-spots.mjs <id> <x,y,r> <x,y,r> --preview <dir>, rear first. - Hero ground:
node tools/hero-ground.mjs <id>. - Paint: both masks in
art/source/[cars34/]paint-masks/<id>.webp, thennode tools/paint-layer.mjs <id>andnode tools/paint-layer.mjs --hero <id>. - Decals: add the hero points to
tools/decal-hero-points.json, runnode tools/decal-slots.mjs --preview <dir>; add toSOLID_PER_CARin that tool if the side is cut by intakes. With a street picture, add itshoodpoints inui/carScenes.tsand check a stripe innpm run shot -- --scene DecalShopon the HOOD chip. - Underglow: add the factory glow colour to
FACTORY_GLOWinplayer/underglow.ts. - Model: add a
CarModelwithsprite: '<id>'toCAR_MODELS(dealer) orBOSS_MODELSinplayer/carModels.ts. Specs and balance are covered in Tuning and Career; runnpm run career:check. - Check:
.claude/skills/car-sprites/scripts/check-sheet.mjs(repaints every view so paint gaps show),npm run check,npm run shot -- --scene Dealer,PaintShop,Race.
The reference pages read carModels.ts and engineProfiles.ts at docs build time, so they pick the
car up without changes.
Checklist: add a new sound
Section titled “Checklist: add a new sound”Follow the sound-effects skill: the user approves every candidate by ear before it goes in.
- Source: generate or download it; keep the source with its prompt or URL and licence in
art/source/<group>/. Trim and filter with ffmpeg (high-pass generated effects that should not carry engine rumble). - File: put it in
public/assets/audio/<group>/. Note thatvite/pwa.mjsprecaches by extension (globPatterns): an extension missing from that list is not available offline. - Load: add a key and a
scene.load.audiocall to the loader of the module that plays it (loadRaceAudiofor race sounds,loadMenuMusicfor music). A new loader function goes in the module and gets one call inPreloader.preload. - Play:
- A race effect: a small class in
race/audio/taking(context, destination, buffer)likeTyreScreech, owned byRaceSfxand updated fromRaceSfx.update; pass the buffer fromstartRaceAudiowithcachedBuffer(null when it failed to load). - A one-off outside the race:
scene.sound.play(key, { volume: X * audioVolume('<channel>') }), guarded byscene.cache.audio.exists(key). - A short UI blip: add a case to
UiSoundincore/uiSound.tsinstead of a file.
- A race effect: a small class in
- Channel: route through the right channel (
engine,effects,music) so the volume settings apply. Start quiet. - Verify it fires: in a running race read the sound object’s state from the page
(
window.game), not only the code. The skill records a nitrous sound that never played because the sim consumed the input inside a tick.