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 |
|---|---|---|
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.
npm run gallery -- --out /tmp/before --view street # before; --view race, or none for bothnpm run gallery -- --out /tmp/after --view street # after; --dpr 2 for render scale 2node scripts/car-diff.mjs /tmp/before /tmp/after # mean difference per car, diff imagesHow 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.
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. 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).
- 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. 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.
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
hoodin the car’sui/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.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.
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.
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. 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.
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. 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).
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; screens then load its images when they show it (carSideImages,carHeroImages). Make its dealer thumbnail withnode tools/car-thumbs.mjs <id>. - 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 in itsui/carScenes/<id>.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.