BLOBBO/docs/ASSET-SLOTS.md
Claude 324d7d4568 Lane I: asset runtime — manifest, registry, slot hooks, IndexedDB overrides
Custom GLBs drop into 14 named slots without code changes; an empty manifest
produces today's game by construction (the fallback builders are the original
code moved into a closure, and an empty registry returns the caller's own
object by identity).

- src/assets/{slots,manifest,idb,registry,blobBody}.ts — schema + validation,
  GLTF cache with per-instance material cloning, fit nodes, IndexedDB override
  layer, paintability report.
- Slot hooks in createBlob, parts, cannon, greybox, puddles, ghost. Mesh-only:
  no collider, physics or logic line is touched. Animated sub-parts (plate cap,
  belt chevrons, fan blades, cannon pivot) stay procedural so a custom model
  cannot stop them moving.
- public/assets/ — the build had NO asset copy step at all, so every asset URL
  would have 404'd in production. public/ is vite's default publicDir, so this
  needs no vite.config change.
- Tests: 63 headless checks + a farm-GLB audit that fires PaintSkin's own
  raycast against the fitted body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:37:08 +10:00

5.3 KiB

Custom assets — how the slots work

Drop a .glb into a named slot and the game uses it. No code changes. With no manifest at all, the game builds exactly what it builds today.

The one URL rule

import.meta.env.BASE_URL + 'assets/manifest.json'

Never /assets/... (that drops the /blobbo/ deploy prefix) and never a bare assets/... (that resolves against the page directory, which breaks on the demo pages one level down). assetUrl() in src/assets/manifest.ts does this for you.

Where files live

What Path Ships?
The manifest public/assets/manifest.json yes → dist/assets/manifest.json
A shipped asset pack public/assets/live/*.glb yes → dist/assets/live/*.glb
Raw farm output assets/meshes/*.glb no — outside the build

public/ is vite's default publicDir, so everything under it is copied into dist/ verbatim with the base path already applied. Before this lane, nothing copied assets/ into dist/ at all — every asset URL would have 404'd live.

The four farm GLBs are ~43 MB of raw output with embedded textures. Stage them for local work with ./scripts/stage-farm-assets.sh (gitignored). Compress them offline before any of them goes into public/assets/live/deploy.sh rsyncs and docker cps the whole of dist/ on every deploy.

Manifest format

{
  "machine.boot": {
    "url": "assets/live/boot.glb",
    "offset": [0, 0.2, 0],
    "rotationDeg": [0, 90, 0],
    "scale": 1.5,
    "idleClip": "idle"
  }
}

Everything except url is optional. A bad field is dropped with a warning and the rest of the entry still loads; a bad entry is dropped and the rest of the pack still loads; a file that isn't JSON at all falls back to {}. A GLB that fails to load leaves the built-in version in place and logs one warning.

Load order, later wins: public/assets/manifest.json, then the editor's IndexedDB override (blobbo-workshop). An override url may be idb:<key>, which resolves to a blob stored in the same database — that is what makes a custom asset testable on the live site with zero deploys.

Slots

Slot id Replaces Stays procedural
blob.body the paintable body
blob.face the googly eyes
ghost.body the ghost racer (defaults to blob.body)
cannon.barrel cannon base + tube the aiming pivot
machine.plate the plate frame the pressed pad (it lights up)
machine.boot pad + coils
machine.bucket the shell the paint fill (it is re-tinted)
machine.arch posts, bar, bubbles
machine.belt the belt slab the scrolling chevrons
machine.fan the housing the spinning blades
machine.seesaw the plank the fulcrum
course.scenery.cereal the giant cereal box
course.scenery.block the purple block
fx.puddle the puddle slab

Sub-parts that move every frame stay procedural on purpose: a custom model can't accidentally stop the belt scrolling, the fan spinning or the plate lighting up, and those motions are what make each machine readable.

Modelling conventions

Y-up, metres, one material, embedded textures ≤ 2048².

  • blob.body is the only slot that is not purely cosmetic. It must be a single mesh with a single material and one UV island inside 0..1 — the paint is stamped through those UVs. It is automatically re-centred and re-scaled to the 1.0u body, because boundingRadius is a gameplay number (the puddles compute belly contact from it). scale is ignored for this slot; offset and rotationDeg are baked into the geometry before the refit. If the mesh fails the paint checks the built-in blob is kept and the reason is logged — paint never breaks silently.
  • machine.bucket — origin at the tipping edge, pours toward +X.
  • machine.boot — origin at the base; it shakes about its own origin.
  • machine.fan — faces +Z.
  • machine.seesaw — plank only, long axis along X, origin at the centre.
  • Course boxes keep their collider whatever you drop in, so a custom prop can never open a hole in the course or grow an invisible wall. Fill the volume.
  • Materials are converted to MeshStandardMaterial on load if they aren't one already: the machine danger-flash only drives standard materials, so a Basic or Phong export would silently kill the telegraph.

Wiring (integration)

src/main.ts needs one line before installGame(world):

await initAssets()

Without it the registry stays empty and every slot falls back — which is safe, but nothing ever swaps. It must be awaited: the frozen game.ts captures blob.mesh by reference the moment createBlob returns, so the body asset has to be in the cache before the game is built.

Checking your work

npm run build
node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/manifest.test.ts
node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/registry.test.ts
node --import ./scripts/ts-resolve.mjs --experimental-strip-types scripts/farm-assets.check.ts

The last one parses the real farm GLBs, runs the paintability check on each and fires PaintSkin's own outside-in raycast against the fitted blob body, so you can confirm a splat would land without opening a browser.