# 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 ```ts 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 cp`s the whole of `dist/` on every deploy. ## Manifest format ```json { "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:`, 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)`: ```ts 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.