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
loadSharedArt player/art.ts cars.json; every car’s dealer thumbnail (cars/thumbs/); decal images (via loadDecals); garage and menu backdrops, start tree, intro video. The cars’ own sprites load per screen, below; the playable ad’s loadArt loads them all
loadCarSceneCrops ui/carSceneCrops.ts scenes/crops.json, where each street picture layer’s crop sits (the pictures themselves load per car, below)
loadKeptTracks race/view/tracks.ts lane markings and the drag strip’s layers (the test run’s track); the other tracks load per race, below
loadRaceAudio race/audio/raceAudio.ts engine loops, nitrous hit and hiss, tyre screech, crowd cheers
loadMenuMusic menu/menuMusic.ts the first menu track; the next loads when the menu comes back after a race
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). The story stills (loadStills) and the career hub’s district key art (loadDistrictArt) load in their scene’s preload and leave memory with the scene, through the same lists as the cars (core/assetLists.ts). The part pictures (shop/parts/PartPicture.ts) load when the Parts shop opens and leave with it.

A street picture’s six layers are mostly clear, so they are stored cropped to what they show (crop-scene-layers.mjs in the car-sprites skill, which records each crop in scenes/crops.json). Once a car’s picture is loaded, ui/carSceneCrops.ts turns each layer into a trimmed frame, as in a packed atlas: it still measures the whole 3360 x 1440 picture and draws in place, so the layers stack as if they were full size, at about a quarter of the texture memory. Code that reads their pixels (the decal bake) goes through player/view/framePixels.ts, which draws the crop where it sits in the frame.

Loaded per screen, removed when no screen shows them

Section titled “Loaded per screen, removed when no screen shows them”

Street pictures are not preloaded (ui/carSceneFiles.ts). The current car’s picture loads at the end of the Preloader and stays in memory, so the main menu, garage, paint and decal shops never wait for it. Any other car’s loads when a screen shows it: the dealer keeps the old car up while the next one loads (only the last pick counts; past 300 ms the old car pulses), then shows it.

What goes is decided by lists, not use counts (core/assetLists.ts): a scene lists the textures it shows (listAssets(scene, name, keys)), and after a list changes or a scene shuts down every listed texture that no running scene lists and nothing keeps (keepAssets, the current car) is removed. Textures that were never listed, like everything the Preloader loads, are never removed.

A race’s track loads the same way (ensureTrack in race/view/tracks.ts): Race starts loading it in create, while it asks the race server for its ticket behind the spinner, and begins once both are in; online, net/startNetRace.ts loads it before the room or results screen starts NetRace, which the server’s clock makes safe. TrackView lists the track it draws, so it goes when the race ends. The drag strip and the markings stay loaded (keepAssets), so a test run never waits.

Cars’ race sprites (side view, paint and rim layers) and three-quarter sprites load the same way (ui/carSprites.ts): the current car’s are always in memory; a race loads its cars once the ticket says which (race/prepareRace.ts), an online race before it starts, a test run in another racer’s or the dealer’s car before it starts (race/startTestRun.ts), the boss screens in their preload, the online room as members join, and the dealer the three-quarter view of a car without a street picture. CarView and HeroCar list the sprites they draw, so a car stays while a screen shows it. The dealer’s strip draws small thumbnails (tools/car-thumbs.mjs), which are always loaded.

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.

Scaled art: smaller copies for lower render scales

Section titled “Scaled art: smaller copies for lower render scales”

The game draws at RENDER_SCALE 1, 1.5 or 2 (core/renderScale.ts, picked from the screen), but the big art is made for 2: a street picture is 1440 px tall on a 720 layout. On a screen at 1, three quarters of those pixels are never seen and still cost GPU memory (width x height x 4 bytes, whatever the file size). So the big art also comes in smaller copies, and each screen loads the smallest that still has a texture pixel per screen pixel. At render scale 1 the main menu drops from 204 to 153 MB (the street picture 34 to 8 MB, the UI images with the menu and garage backdrops 43 to 17 MB).

Graphics: Auto, High, Low (Settings, core/graphicsSetting.ts). Low draws at render scale 1 whatever the screen, so it loads these copies, and skips the blur filters (the mirrored street in the paint, the hero car’s reflection and glass). Auto is Low when navigator.deviceMemory reports 4 GB or less (Android; iPhones do not report it and get High). The level is read once at boot, so a pick that changes it reloads the game. On a retina screen the main menu drops from 221 to 143 MB (npm run shot -- --dpr 2 --storage drag-racer-graphics=low).

Nothing to run by hand. tools/scaled-art.mjs makes the copies when Vite starts (dev, npm run shot and every build, through vite/scaledArt.mjs). It redoes only files newer than their copy, so a new or changed image in a listed folder gets its copies on the next start. The copies go to public/scaled/<scale>/<path under public/assets>, are gitignored, and are listed with each original’s size and density in public/scaled/sizes.json.

What is copied is listed in src/game/core/scaledArt.json: a folder or file with its density, the texture pixels per layout pixel where the game draws it, or "height" for full-screen art (its height over 720). A copy is made only at the scales where it keeps at least one texture pixel per layout pixel.

{
"scales": [0.5, 0.75],
"art": {
"sprites/scenes": 2,
"sprites/keyart": "height",
"sprites/ui/garage.webp": 2
}
}

With "sprites/keyart": "height", a 1086 px tall key art has density 1.51: it gets a 0.75 copy (1.13 pixels per layout pixel) and no 0.5 copy (0.75, blurry at any render scale). Leave out art whose density varies within a folder: a track’s sky is drawn at about 1.1 and its road at 2 (race/view/tracks.ts), so tracks are not listed. To add art, list it, restart Vite, and compare npm run shot (render scale 1, the copies) with npm run shot -- --dpr 2 (the originals).

To check a change to how cars are drawn (layers, decals, copies), shoot every car before and after it in one browser and compare: the shots are exactly reproducible, so any difference is the change.

Terminal window
npm run gallery -- --out /tmp/before --view street # before; --view race, or none for both
npm run gallery -- --out /tmp/after --view street # after; --dpr 2 for render scale 2
node scripts/car-diff.mjs /tmp/before /tmp/after # mean difference per car, diff images

How the game stays unaware (core/scaledArt.ts): a scene plugin swaps a listed image’s URL for its copy as it is queued, and when the texture arrives gives it the original’s size. Phaser’s texture coordinates are fractions of that size, so positions, crops (scenes/crops.json), scales and decal slots all stay in the original’s pixels while the GPU samples the smaller image.

The one rule for code: read a texture’s size from its frame, never from its image, which may be the copy. ESLint rejects getSourceImage outside player/view/framePixels.ts, which has what to use instead:

import { drawFrame, frameSize, textureImage } from '../../player/view/framePixels';
// A size, for scaling or layout: the original's.
const { width, height } = frameSize(scene, key); // not getSourceImage().width
// Pixels of a texture's whole frame (crops and copies handled).
drawFrame(ctx, scene, key);
// An image to draw yourself, at the original's size.
const { image, width: w, height: h } = textureImage(scene, key);
ctx.drawImage(image, 0, 0, w, h); // not ctx.drawImage(image, 0, 0)

Textures the game builds from a copied image follow its resolution, not the frame’s: the decal bake above, and the metallic paint’s flake noise (player/view/paintFlakes.ts, 19 MB at full size, 5 MB over a 0.5 copy). A new one does the same with frameArea(scene, key).scale.

The memory estimate printed by npm run shot (scripts/textureMemory.mjs) counts each image’s own size, so it shows the copies. The web build’s service worker precaches every file in dist, copies included (about 13 MB): a web install downloads both sizes.

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. Each image showing a layer holds it (holdDecalLayer, player/view/decalLayerHold.ts). Once the last holder is destroyed or switches layers, the texture stays among the last 4 released ones and the oldest is removed: a hero bake takes about 100 ms, so the player’s own car keeps its layers between screens, while every opponent’s random stripe and every step in the decal shop is a new set that ages out (a cache kept forever grew by one car-sized canvas per race, which npm run leak caught).

  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. On a street picture (decalLayerOnPicture) the clip also leaves out the picture’s wheels layer (cutKey): its body mask runs a little onto the tyres, and a stripe would show on them.

A street picture’s bake covers only the body layer’s crop, at the body image’s resolution (half or three quarters for a scaled copy), and is then given the body’s trimmed frame (frameArea, areaPixels and fitToArea in player/view/framePixels.ts), so it draws in place like the other layers: about 4 MB (estimated from the body crop) at render scale 2 and 1 MB at 1, where it was 19 MB at the full 3360 x 1440.

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 the car’s ui/carScenes/<id>.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. glow.html runs the game with sliders for the street pictures’ underglow layers (ui/carSceneGlow.ts); they hold for every screen with a street picture until reload, and “Copy as code” gives the constants.

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. Only the playing track is in memory (about 42 MB decoded each): a silent scene fades it out and drops it from the audio cache, and the next menu scene loads the next track with its own loader and starts it once decoded, so the menu is silent for that moment.

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. A beat’s clips leave memory with the scene that played it (core/assetLists.ts); a boss’s clips and the portraits stay, as the race, BossRound and PinkSlip use them after BossIntro has closed. 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; screens then load its images when they show it (carSideImages, carHeroImages). Make its dealer thumbnail with node tools/car-thumbs.mjs <id>.
  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 its ui/carScenes/<id>.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.