BLOBBO/docs/ASSET-SLOTS.md
Claude e34773d35f harden asset runtime: guard the swap path, bound the boot, reconcile slots
The three that stopped a pack shipping:
 - a mesh-less GLB (armature-only export) fired onSwap anyway, hiding the
   primitive and adding nothing — an invisible prop with a live collider.
   Mesh count is now checked BEFORE the scene is touched, in one guard that
   covers every consumer including slotObject/blob.face.
 - the fulfilment path had no try/catch, so a throw became an unhandled
   rejection. Split in two: a build failure bails out before onSwap can hide
   anything; an onSwap failure keeps the replacement parented, because
   consumers hide their primitive on onSwap's first line and removing the fit
   node there would manufacture the one forbidden state.
 - preload() awaited every url forever. Each is now raced against a 10s
   deadline with allSettled semantics: a stalled host costs one warning and a
   fallback prop, not a game that never boots.

Also:
 - instantiate/instanceSync never throw — createBlob's call site is frozen and
   unguarded, so a throw there is a black screen.
 - ghost and blob share fitBodyToRadius(); a borrowed blob.body was rendering
   the ghost 2.17x oversized with the farm mesh.
 - skinned blob.body is rejected loudly instead of silently half-working; the
   idle-clip mixer is gone (it animated an orphaned skeleton in the fixed step).
 - paintableInfo counts UV islands: blobbo-base.glb passes every other check
   and still paints wrong at 1140 charts. Warning, not rejection.
 - slots.ts gains cannon.base, course.finish, course.tunnel, course.tramp, all
   hooked except tramp (built in frozen game.ts).
 - manifest ignores _-prefixed metadata keys instead of calling them typos.

Empty-manifest parity verified byte-for-byte: scripts/sacred-parity.check.ts
fingerprints the built scene and reports 49df4f20 on main and on this branch.
2026-07-19 12:12:07 +10:00

10 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
  }
}

Keys starting with _ (the editor writes _readme and _generatedBy) are metadata and are ignored silently. idleClip is accepted by the schema but currently ignored by the runtime — see "Rigged bodies are not supported" below.

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.base the cannon's stand
cannon.barrel the 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
course.finish the pink finish podium
course.tunnel the MINI tunnel roof slab the warning bar and the posts
course.tramp the orange gap launcher pad
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.

course.tramp is built in src/game.ts, which is frozen for the workshop lanes; the id and label exist so the editor can offer it, and integration wires the hook. Until it does, dropping a file on that slot changes nothing.

One slot, many objects

Most slots are instanced more than once — nine fx.puddle strips, six cannons, every machine of a given kind. Each instance gets its own clone of the asset at its own pose, with its own materials. That is the contract both the runtime and the editor follow: dropping one puddle model reskins all nine puddles, and tinting one of them (each puddle keeps its zone colour) cannot bleed into the others. Verified in src/assets/harden.test.ts.

Single-instance slots — the ones the game builds exactly once — are listed in SINGLE_INSTANCE_SLOTS in src/assets/slots.ts: blob.body, blob.face, ghost.body, course.finish, course.tunnel and the two course.scenery.*.

What gets rejected, and what only gets a warning

Asset Outcome
GLB fails to load / 404s slot keeps its built-in version, one warning
GLB takes longer than 10 s boot continues on the built-in version, one warning
GLB contains no mesh (armature/empty/camera only) slot keeps its built-in version — the primitive is never hidden for a replacement that isn't there
blob.body with a skeleton/armature rejected, built-in blob kept (see below)
blob.body with no UVs, UVs outside 0..1, or >1 material rejected, built-in blob kept
blob.body whose UVs are an auto-atlas accepted with a warning — it paints, just messily

Rigged bodies are not supported

A blob.body that is a SkinnedMesh is rejected and the built-in blob is kept. This is a real limitation, not a missing check: createBlob re-parents the body mesh alone into its own group, so the GLB's bones are left outside the scene graph and never get a matrixWorld update — the pose would never advance. And the body's geometry is re-centred and re-scaled to the collider radius, which invalidates the skeleton's bind matrices, so any deformation that did resolve would be offset. idleClip is ignored for the same reason and no animation mixer is created — animating an invisible skeleton in the fixed step would cost CPU for nothing. Export the body with the armature applied.

The atlas warning

blob.body reports uvCharts (connected UV islands) and seamRatio. UVs inside 0..1 with one material is not enough: the farm's own blobbo-base.glb passes both and still paints wrong, because its unwrap is a 1140-island auto-atlas (29% of its vertices sit on a seam) and a splat disc in UV space therefore speckles a thousand unrelated triangles. Above 24 islands the report says so. For reference, three's SphereGeometry is 3 islands and a BoxGeometry is 6 — a hand unwrap is nowhere near the threshold.

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.
  • ghost.body — if you leave it empty the ghost borrows blob.body and is put through the exact same fit, so it always matches the blob's size. Fill it only when you want the ghost to look like something else; then its own offset/rotation/scale apply normally.
  • course.tunnel — roof slab only. Model its underside FLAT at the bottom of the model: the clearance a MINI blob squeezes through is the collider's and never changes, so a model that hangs lower than the collider will look like it blocks a gap it does not.
  • 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.

initAssets() cannot hang the boot: every url in the manifest is raced against a 10 s deadline and settled independently, so a stalled host costs one warning and the built-in prop rather than a game that never starts.

course.tramp still needs one line in game.ts, next to where the trampoline pad mesh is built:

assets().attachSlot('course.tramp', trampMesh, {
  onSwap: () => { (trampMesh.material as THREE.Material).visible = false },
})

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 src/assets/harden.test.ts
node --import ./scripts/ts-resolve.mjs --experimental-strip-types scripts/farm-assets.check.ts

harden.test.ts is the regression net for the failure modes that used to be silent: a mesh-less GLB blanking a slot, a throw escaping the swap path, a stalled url hanging the boot, an oversized ghost, a rigged body being accepted, and one dropped asset serving nine puddles.

The sacred property — empty manifest builds today's game — has its own check:

./node_modules/.bin/esbuild scripts/sacred-parity.check.ts --bundle \
  --platform=node --format=esm --outfile=/tmp/sacred.mjs && node /tmp/sacred.mjs

It fingerprints every node of the scene the course, zones, blob and a cannon build with an empty registry. The number must not move.

The farm check 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.