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.
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.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.ghost.body— if you leave it empty the ghost borrowsblob.bodyand 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 ownoffset/rotation/scaleapply 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
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.
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.