Skip to content

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/.

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.

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.

  1. tools/generate-image.mjs makes 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.
  2. tools/edit-image.mjs turns the chosen side view into the 3/4 view.
  3. tools/chroma-key.mjs --green [--flip] removes the background (--flip when the model drew the car facing left). Green paint is therefore impossible: the key would remove it.
  4. 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.

public/assets/sprites/cars/cars.json holds per car, in sprite pixels:

  • wheels: hub centre and tyre radius, rear wheel first. Written by tools/wheel-spots.mjs from 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. CarView uses the wheels twice: the lowest tyre edge sets the car’s ground line, and for each wheel it cuts a disc of RIM_FRACTION times 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 by tools/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.

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.

  1. A mask per view: tools/edit-image.mjs repaints the green-screen source flat magenta on the body panels only (mask prompt in art/README.md), saved in art/source/[cars34/]paint-masks/<id>.webp.
  2. 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).
  3. In game, applyPaint (player/view/paint.ts) adds a ColorMatrix filter to the layer that mixes light * 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.lift in player/paints.ts scales 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.

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.

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.

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 match pairs points found on both images (body ends, wheel hubs, door top corners; hand placed in tools/decal-hero-points.json, rear first), and player/decals/planeWarp.ts maps hero pixels back to side pixels.

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:

  1. Draw the stripe, then each layer (later on top), on the side sprite’s band.
  2. For the hero view, resample that side image through the match. Hero images face left, so text is pre-mirrored; graphic decals are not.
  3. Clip to the paint layer and shade with the car’s own lighting (shadeDecal and paintLuminance in player/decals/decalGeometry.ts), so a sticker picks up the body’s light.

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):

  1. 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.
  2. Lay it onto the picture through hood in ui/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.ts maps 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.
  3. 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.

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.

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 strip spreads the shared ROAD layers and starts its asphalt at SHARED_ROAD_TOP (hasSharedRoad). Never generate a road per track.
  • Markings: tools/road-markings.mjs draws the lane dash and the double yellow centre line as clean textures into public/assets/sprites/tracks/markings/; they are not generated. loadTracks loads 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.

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.

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.

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).

Follow the car-sprites skill for the art itself (pilot, candidates, user approval).

  1. Art: side view and 3/4 view generated, keyed and saved to public/assets/sprites/cars/<id>.webp and cars34/<id>.webp; sources and sidecars in art/source/<id>.* and art/source/cars34/<id>.*.
  2. Register the sprite: add <id> to CAR_SPRITES in player/art.ts. loadArt then loads all four images.
  3. Engine: add the id to ENGINE_BY_SPRITE in race/audio/engineProfiles.ts (a Record, so npm run check fails until you do).
  4. Wheels: node tools/wheel-spots.mjs <id> <x,y,r> <x,y,r> --preview <dir>, rear first.
  5. Hero ground: node tools/hero-ground.mjs <id>.
  6. Paint: both masks in art/source/[cars34/]paint-masks/<id>.webp, then node tools/paint-layer.mjs <id> and node tools/paint-layer.mjs --hero <id>.
  7. Decals: add the hero points to tools/decal-hero-points.json, run node tools/decal-slots.mjs --preview <dir>; add to SOLID_PER_CAR in that tool if the side is cut by intakes. With a street picture, add its hood points in ui/carScenes.ts and check a stripe in npm run shot -- --scene DecalShop on the HOOD chip.
  8. Underglow: add the factory glow colour to FACTORY_GLOW in player/underglow.ts.
  9. Model: add a CarModel with sprite: '<id>' to CAR_MODELS (dealer) or BOSS_MODELS in player/carModels.ts. Specs and balance are covered in Tuning and Career; run npm run career:check.
  10. 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.

Follow the sound-effects skill: the user approves every candidate by ear before it goes in.

  1. 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).
  2. File: put it in public/assets/audio/<group>/. Note that vite/pwa.mjs precaches by extension (globPatterns): an extension missing from that list is not available offline.
  3. Load: add a key and a scene.load.audio call to the loader of the module that plays it (loadRaceAudio for race sounds, loadMenuMusic for music). A new loader function goes in the module and gets one call in Preloader.preload.
  4. Play:
    • A race effect: a small class in race/audio/ taking (context, destination, buffer) like TyreScreech, owned by RaceSfx and updated from RaceSfx.update; pass the buffer from startRaceAudio with cachedBuffer (null when it failed to load).
    • A one-off outside the race: scene.sound.play(key, { volume: X * audioVolume('<channel>') }), guarded by scene.cache.audio.exists(key).
    • A short UI blip: add a case to UiSound in core/uiSound.ts instead of a file.
  5. Channel: route through the right channel (engine, effects, music) so the volume settings apply. Start quiet.
  6. 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.