Ride customizer
Original authored for Ryan's arcade, with AI assistance, from the project's own garage, asset pipeline, and racer contracts. No downloaded or third-party skill text is included. This is not a blanket license for project content, vehicle models, or vehicle trademarks. Manufacturer websites were used only as visual references; no manufacturer photos, CAD, badges, or logos ship in any asset.
Architecture at a glance
public/ride-data.js owns the design schema and validation. public/ride-detailed.js loads the three local GLBs once and clones per-instance cars. public/ride-model.js exposes the shared buildRideModel(design) API. public/ride-art.js draws decal and sticker artwork as canvas textures. public/ride-3d.js is the garage renderer (createRideView), public/ride-ui.js and public/ride.js are the garage controller and sharing, and public/drive-3d.js reuses the same models for racing. Editable sources live in assets/rides/*.blend; runtime GLBs live in public/models/rides/ with provenance.json and credits.html; the Blender scripts live in tools/.
Choosing how to make a custom vehicle asset
- Decide the source route before modelling, and record it. There are three acceptable routes. Route A is original procedural authoring in Blender from code; this is how the EV9-inspired SUV and Bolt-inspired hatch were made. Route B is adapting a permissively licensed model; this is how the dream car was made from the Khronos
CarConceptsample (CC BY 4.0). Route C is a locally generated AI mesh, for example from an image-to-3D model. Route C was evaluated but not used for shipped cars because generated meshes need heavy retopology, have no named wheel pivots or paint slots, and carry tool-licence questions. If you use it, treat the output like Route B input: clean it, rig named pivots, check the tool's output licence, and document it. - Never ship a downloaded paid asset, a manufacturer CAD/photo texture, a badge, a licence plate, or a brand logo. Reference photos may guide proportions only. Call the result "inspired" or an "approximation", never an official model, and never imply endorsement.
- For Route B, pin the exact source commit URL, read the licence file, and keep the author, copyright owner, year, licence URL, upstream origin, and every modification in
public/models/rides/provenance.json. Show the human-readable attribution inpublic/models/rides/credits.html. Also embed it with the glTF exporter'sexport_copyrightfield. CC BY requires attribution forever; CC0 does not, but record it anyway.
Route A: original procedural authoring (how EV9 and Bolt were built)
- Use
tools/author-electric-rides.py, run headless:blender --background --python tools/author-electric-rides.py. It was written for Blender 5.2 LTS. Principled BSDF socket names such asCoat WeightandTransmission Weightdiffer across Blender versions, so check them when upgrading. - Start each car from
bpy.ops.wm.read_factory_settings(use_empty=True)so runs are deterministic. Drive every dimension from a few real-world numbers per car: length, width, height, axle offset, tire radius, belt line, and roof and glass stations. The EV9-inspired body is 5.01 m × 1.98 m × 1.78 m with 0.395 m tires; the Bolt-inspired hatch is 4.22 m × 1.8 m × 1.62 m with 0.35 m tires. - Build curved body panels as parametric surfaces: a helper evaluates
fn(u, v)over a grid, writes vertices and quads, and recalculates normals withbmesh.ops.recalc_face_normals. Add separate meshes for glass, wheel arches and wells, lamps, trim, mirrors, and a simple interior (seats, headrests, dashboard) so the glass reads correctly. - Create materials by name, because the runtime depends on names. The body colour must be
RidePaint, or Blender's suffixedRidePaint.001form. Every panel that should take the player's paint and receive decals must use it. Other materials areCarbonTrim,Glass,Rubber,Alloy,Chrome,BrakeDisc,LED,TailLED, andInterior. Use opaque tinted glass with metalness instead of transmission; transmission costs extra render passes on phones. - Create exactly four wheel pivot empties named
WheelFrontL,WheelFrontR,WheelRearL,WheelRearR, each at its wheel centre. Parent the tire, barrel, spokes, tread, hub, andBrakeRotorto the pivot while preserving world transforms. Name calipers or padsBrakePadso the runtime leaves them static while the wheel rolls. Coordinates are nose +X, Y up in glTF, and wheel axle along +Z. - Before export, convert curves to meshes. Then reduce draw calls by joining meshes that share the same parent and material, except the root-level
RidePaintpanels, which stay separate for decal projection. Without this step a detailed car can exceed 300 draw calls. - Save the editable
.blendtoassets/rides/<car>.blendand export GLB topublic/models/rides/<car>.glbwith animations, cameras, and lights disabled. Delete any.blend1backups Blender leaves behind; do not commit them.
Route B: adapting a licensed model (how the dream car was built)
- Download the pinned source into a scratch folder, not the repository, then run
blender --background --python tools/prepare-ride-asset.py -- SOURCE.glb public/models/rides/concept.glb assets/rides/concept.blend. - Inspect the source's baked pose before anything else. The Khronos concept car shipped with its front wheels visibly steered. Baking that pose made the wheels roll crooked at runtime. The script measures each front pivot's axle against the
WheelRearLaxle and rotates the front pivots to match before baking. Check every new source for posed wheels, doors, or suspension. - Remove what you cannot ship: licence plates, logo-bearing paint textures, the engine, pedals, and interior parts nobody can see. Unlink texture inputs from paint materials so no artwork survives under a solid colour. Rename the main paint slot to
RidePaintand the secondary paint toCarbonTrim. - Normalize: find the nose using a known part (the hood), rotate so the nose is +X, scale to the arcade length (4.7 m for the dream car, with width scaled to 85% so it fits the racing lanes), centre it, and put the ground at zero. Bake world transforms into mesh data and keep only the four wheel pivots as empties, re-centred on their wheel meshes' bounds.
- Fit the mobile budget: apply a Decimate modifier (ratio 0.4) to meshes over 400 polygons, resize every image to at most 512 px, pack resources, save the
.blend, and export the GLB withexport_copyrightattribution. The script prints aRIDE_ASSETJSON line with bytes, meshes, pivots, triangles, and dimensions; copy the numbers intoprovenance.json.
Runtime asset budgets and loading contract
- Keep each car under 90,000 triangles, 180 mesh draw calls, and 6 MB. Current figures: EV9 about 1.97 MB and 84,596 triangles; Bolt about 1.90 MB and 81,716 triangles; dream about 5.25 MB and 85,173 triangles.
tests/ride-model.test.jsenforces finite geometry, budgets, wheel ground clearance, and straight wheel axles. - Load with
await prepareDetailedRide(), which loads all three GLBs through the vendoredGLTFLoaderusingPromise.allSettled. A retry loads only the sources that failed and keeps the ones that loaded. After that,buildRideModel(design)is synchronous and returns{ group, bodyMeshes, wheels, animateWheels(distance, steering), dispose() }.createRideViewandcreateDriveVieware both async; show progress and a retryable error instead of a blank canvas. - Clone per instance:
source.clone(true), then clone each geometry and material. Apply paint only to clonedRidePaintmaterials (metalness 0.65, roughness 0.23, clearcoat 1).dispose()must be idempotent and free only that instance's clones and generated textures, never the cached source textures shared by other cars. - For wheels, the pivot rotates Y by
-steeringfor front wheels only. A generatedRollingchild holds everything exceptBrakePadand rotates Z by-distance / radius, where the radius comes from each wheel's bounding box. Drive rolling from signed simulated distance, never render time, so wheels do not spin while paused or teleport-spin after a reset. - Frame the garage camera from the model's bounding sphere so tall SUVs and long sports cars both fit on a 320 px-wide phone.
Customization: paint, decals, and stickers
- The design is exactly
{ version: 1, carId, paint, decal, stickers }. Car IDs areev9,bolt,dream; never rename them, because saved designs and the racer depend on them. ReadPAINTS,DECALS, andSTICKERSfromride-data.jsrather than hard-coding copies. - Generate all artwork in code with
createRideArtwork(kind)on a canvas: decals (stripes,flames,lightning) are 1024×256, stickers 256×256, and the texture colour space is sRGB. No image files are needed, which keeps the asset licence simple. - Project artwork with the vendored
DecalGeometryagainst everyRidePaintbody mesh, skipping empty results. Use a transparent material withdepthWrite: false,polygonOffset: true,polygonOffsetFactor: -4, andrenderOrder = 2to avoid z-fighting. Side decals project from both sides at a fixed body-relative box; stickers use the storedpointandnormalwith a 0.32 m box. - Place stickers by raycasting the pointer against
bodyMeshesonly, storing the hit point and world normal. Separate a sticker tap from an orbit drag by movement threshold, and make both work with mouse and touch. - Garage sound comes from the shared procedural
public/arcade-audio.js(Web Audio synthesis, no files, CSP-safe).ride.jscallsplaySoundonly from UI events:swapon car change,painton respray or reset,decal,stickeron placement (errorwhen the 20-sticker limit is hit),clickfor tool/undo,photowhen the share snapshot is taken, andsuccess/errorfor the share result. The renderer stays silent. A mute button (#ride-sound, classarcade-sound) sits just before the Full screen/Exit control and shares the arcade-widelocalStoragemute. - Garage theme music:
ride.jscallsplayMusic(THEMES.pimpMyRide.url)at load, and it starts on the first gesture. Generate with a local text-to-music model (we used ACE-Step 1.5 XL-SFT, MIT, viaGenerate-Audio.cmd -Style <preset> -Prompt "..." -Duration 48 -Seed 42) using an original instrumental prompt that names no artists or songs and asks for steady energy with no intro, breakdown, or ending. Do not trust the preset BPM: measure the real repeat period withtools/make-music-loop.py(onset autocorrelation plus a spectral self-similarity lag curve; the maze take came out at 110 BPM, not the preset 112), cut a bar-aligned 16-bar loop at the most self-similar start, crossfade the seam for 120 ms (equal power), apply linear gain to about -16 LUFS with at least 1 dB true-peak headroom, and encode AAC 112 kbps M4A (roughly 0.4 to 0.55 MB). Record model, preset, seed, steps, prompt, loop start, and loop length inpublic/audio/provenance.json. Play themes withplayMusic(THEMES.<game>.url)fromarcade-audio.js: fetch plusdecodeAudioData(covered byconnect-src 'self'), a loopingAudioBufferSourceNodeon a music bus at -10 dB under the effects, a 1.5 s fade-in, crossfade between themes, andstopMusic({ fade }). EachTHEMESentry stores its exact loop length, and playback setsloopStart/loopEndto that window (skipping any AAC priming silence the browser keeps, with a 2 ms tolerance), so the loop never clicks or gaps. Music waits for the first gesture, shares the mute button, pauses with the hidden page, and fails silently if a file is missing. The service worker caches theme files at runtime (cache-first) rather than precaching about 1.5 MB, so filenames are immutable: ship a new take under a new name.
Validation, saving, and Snap & share
- Validate every edit, import, and save with
validateRide: at most 20 stickers, each with onlykind,point, andnormal; points finite within ±6; normal components within ±1 and length 0.9–1.1; no extra fields, unknown cars, or unsupported colours. serializeRideproduces the design string, capped at 12,000 characters. Shared Firestore records inarcadeRidesare document version 2: a serializeddesignplus a matchingcarId, a 1–60 character title, and a JPEG data-URL photo up to 180,000 characters checked byvalidatePhoto. Readers useparseSavedRide. Older version-1 photo-only records are labelled "photo only — not drivable", never reconstructed from pixels.- Sharing is a single batched write: the ride document plus the user's
arcadeRidePublishers/{uid}record, each stamped withrequest.timeand cross-checked withgetAfter. The rules allow one share per 30 seconds per user, verified Google/GitHub accounts only, and immutable photos. If sharing fails with a permission error, compare the live rules with the localfirestore.rulesbefore blaming the client. A real outage came from rules that had never been deployed. Deploying rules needs explicit owner approval and emulator tests first. - Render untrusted titles with text nodes, keep pagination (
PAGE_SIZE12) with a cursor, and drop stale responses after account changes.
Hosting, CSP, and offline
- Embedded GLB textures become
blob:URLs. Production CSP must allowblob:inconnect-src(ImageBitmapLoader path) and inimg-src(the TextureLoader fallback used whencreateImageBitmapis missing). Do not add it to script or style directives. Test the full garage page under the exact production CSP: an isolated loader check missed 13 blocked texture loads that the full page exposed. Also forcewindow.createImageBitmap = undefinedto exercise the fallback, and listen forsecuritypolicyviolation. - Add every new GLB, script, and vendored addon (
GLTFLoader,BufferGeometryUtils,SkeletonUtils,DecalGeometry) to the service-worker precache, and bump the cache name once per release. - Homepage preview images are real in-game renders captured with a headless browser, using intercepted test-only account responses, saved as 960×540 WebP, and lazily loaded with alt text. Never use a mock-up that the game cannot actually produce.
Testing
- From
web:node --test tests/ride.test.js tests/ride-model.test.js tests/ride-ui.test.js tests/drive.test.js. Cover round-trip serialization, malformed and oversize rejection, mismatched wrapper car IDs, photo-only handling, model budgets, wheel axles, per-instance material independence, disposal, and retry-only-failed loading. - Run
node --test tests/firestore-rules.test.jsagainst the Firestore emulator for any sharing change. Never use production data as fixtures. - Browser-check with headless Chromium and software WebGL (
--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader) at 320×568, 390×844, 844×390, and desktop sizes. Intercept only the account and Firestore test routes; never mock the code under test. Software-WebGL timings are not real-device performance claims. - Visually inspect screenshots of all three cars with paint, a decal, and stickers before reporting success. Passing geometry tests do not prove a car looks right.
Completion
Summarize the asset source route, licence, budgets measured, schema compatibility, and verified tests. If the schema changes intentionally, migrate writers, readers, and rules together; do not silently invalidate existing shared designs or loosen validation. Never deploy hosting or rules without explicit owner approval.
Install this custom skill
Review and read the raw SKILL.md or download SKILL.md. Create a folder named ride-customizer in your agent's supported skills directory and save the file there as SKILL.md.
Project-local Copilot example: .github\skills\ride-customizer\SKILL.md. Personal installation, only if you choose it: .agents\skills\ride-customizer\SKILL.md under your profile. Copy only this authored file, not any downloaded skill collection. Inspect another project's code before adapting these Ryan-specific workflows.
The instruction file is usable independently of MCP. Local MCP setup is a separate option; no public MCP protocol endpoint is deployed.
Catalog name: ride-customizer. Source: original-custom. Size: 16042 UTF-8 bytes. JSON catalog and provenance.