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>
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.bodyis 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, becauseboundingRadiusis a gameplay number (the puddles compute belly contact from it).scaleis ignored for this slot;offsetandrotationDegare 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
MeshStandardMaterialon 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.