← Original custom skills library Ryan's portfolio

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

  1. 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 CarConcept sample (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.
  2. 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.
  3. 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 in public/models/rides/credits.html. Also embed it with the glTF exporter's export_copyright field. CC BY requires attribution forever; CC0 does not, but record it anyway.

Route A: original procedural authoring (how EV9 and Bolt were built)

  1. 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 as Coat Weight and Transmission Weight differ across Blender versions, so check them when upgrading.
  2. 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.
  3. Build curved body panels as parametric surfaces: a helper evaluates fn(u, v) over a grid, writes vertices and quads, and recalculates normals with bmesh.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.
  4. Create materials by name, because the runtime depends on names. The body colour must be RidePaint, or Blender's suffixed RidePaint.001 form. Every panel that should take the player's paint and receive decals must use it. Other materials are CarbonTrim, Glass, Rubber, Alloy, Chrome, BrakeDisc, LED, TailLED, and Interior. Use opaque tinted glass with metalness instead of transmission; transmission costs extra render passes on phones.
  5. Create exactly four wheel pivot empties named WheelFrontL, WheelFrontR, WheelRearL, WheelRearR, each at its wheel centre. Parent the tire, barrel, spokes, tread, hub, and BrakeRotor to the pivot while preserving world transforms. Name calipers or pads BrakePad so the runtime leaves them static while the wheel rolls. Coordinates are nose +X, Y up in glTF, and wheel axle along +Z.
  6. Before export, convert curves to meshes. Then reduce draw calls by joining meshes that share the same parent and material, except the root-level RidePaint panels, which stay separate for decal projection. Without this step a detailed car can exceed 300 draw calls.
  7. Save the editable .blend to assets/rides/<car>.blend and export GLB to public/models/rides/<car>.glb with animations, cameras, and lights disabled. Delete any .blend1 backups Blender leaves behind; do not commit them.

Route B: adapting a licensed model (how the dream car was built)

  1. 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.
  2. 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 WheelRearL axle and rotates the front pivots to match before baking. Check every new source for posed wheels, doors, or suspension.
  3. 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 RidePaint and the secondary paint to CarbonTrim.
  4. 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.
  5. 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 with export_copyright attribution. The script prints a RIDE_ASSET JSON line with bytes, meshes, pivots, triangles, and dimensions; copy the numbers into provenance.json.

Runtime asset budgets and loading contract

  1. 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.js enforces finite geometry, budgets, wheel ground clearance, and straight wheel axles.
  2. Load with await prepareDetailedRide(), which loads all three GLBs through the vendored GLTFLoader using Promise.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() }. createRideView and createDriveView are both async; show progress and a retryable error instead of a blank canvas.
  3. Clone per instance: source.clone(true), then clone each geometry and material. Apply paint only to cloned RidePaint materials (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.
  4. For wheels, the pivot rotates Y by -steering for front wheels only. A generated Rolling child holds everything except BrakePad and 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.
  5. 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

  1. The design is exactly { version: 1, carId, paint, decal, stickers }. Car IDs are ev9, bolt, dream; never rename them, because saved designs and the racer depend on them. Read PAINTS, DECALS, and STICKERS from ride-data.js rather than hard-coding copies.
  2. 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.
  3. Project artwork with the vendored DecalGeometry against every RidePaint body mesh, skipping empty results. Use a transparent material with depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4, and renderOrder = 2 to avoid z-fighting. Side decals project from both sides at a fixed body-relative box; stickers use the stored point and normal with a 0.32 m box.
  4. Place stickers by raycasting the pointer against bodyMeshes only, 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.
  5. Garage sound comes from the shared procedural public/arcade-audio.js (Web Audio synthesis, no files, CSP-safe). ride.js calls playSound only from UI events: swap on car change, paint on respray or reset, decal, sticker on placement (error when the 20-sticker limit is hit), click for tool/undo, photo when the share snapshot is taken, and success/error for the share result. The renderer stays silent. A mute button (#ride-sound, class arcade-sound) sits just before the Full screen/Exit control and shares the arcade-wide localStorage mute.
  6. Garage theme music: ride.js calls playMusic(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, via Generate-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 with tools/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 in public/audio/provenance.json. Play themes with playMusic(THEMES.<game>.url) from arcade-audio.js: fetch plus decodeAudioData (covered by connect-src 'self'), a looping AudioBufferSourceNode on a music bus at -10 dB under the effects, a 1.5 s fade-in, crossfade between themes, and stopMusic({ fade }). Each THEMES entry stores its exact loop length, and playback sets loopStart/loopEnd to 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

  1. Validate every edit, import, and save with validateRide: at most 20 stickers, each with only kind, point, and normal; points finite within ±6; normal components within ±1 and length 0.9–1.1; no extra fields, unknown cars, or unsupported colours.
  2. serializeRide produces the design string, capped at 12,000 characters. Shared Firestore records in arcadeRides are document version 2: a serialized design plus a matching carId, a 1–60 character title, and a JPEG data-URL photo up to 180,000 characters checked by validatePhoto. Readers use parseSavedRide. Older version-1 photo-only records are labelled "photo only — not drivable", never reconstructed from pixels.
  3. Sharing is a single batched write: the ride document plus the user's arcadeRidePublishers/{uid} record, each stamped with request.time and cross-checked with getAfter. 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 local firestore.rules before blaming the client. A real outage came from rules that had never been deployed. Deploying rules needs explicit owner approval and emulator tests first.
  4. Render untrusted titles with text nodes, keep pagination (PAGE_SIZE 12) with a cursor, and drop stale responses after account changes.

Hosting, CSP, and offline

  1. Embedded GLB textures become blob: URLs. Production CSP must allow blob: in connect-src (ImageBitmapLoader path) and in img-src (the TextureLoader fallback used when createImageBitmap is 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 force window.createImageBitmap = undefined to exercise the fallback, and listen for securitypolicyviolation.
  2. 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.
  3. 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

  1. 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.
  2. Run node --test tests/firestore-rules.test.js against the Firestore emulator for any sharing change. Never use production data as fixtures.
  3. 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.
  4. 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.