Vol. 07 · Dispatch2026-07-06

Two arrays of nullable templates — how Sound Race stays playable when a GLB fails to load

Sound Race spawns pickups and hazards from cloned Hunyuan-generated GLB templates. Any single template can be null — because OSS was slow, or that specific asset failed to parse, or we hit a network timeout. The spawn code checks each slot and falls back to a shared procedural polytope when the GLB isn't there. Same collision box, same behavior, just a different-looking mesh. Here's the exact shape and why it's worth building even for a small feature.

by MetaWorldOS Engineering
Three.jsTypeScriptGameDevGraceful Degradation

TL;DR — Every entity type in Sound Race has two spawn paths: clone a GLB template if one is loaded, otherwise build a procedural mesh from a shared polytope geometry. pickupTemplates: (THREE.Group | null)[] holds up to four color-keyed pickup templates; hazardTemplates: (THREE.Group | null)[] holds up to three hazard variants. Any slot can independently be null. template ? template.clone(true) : buildProceduralMesh() is the whole gate. The consequence: a partial OSS load — 5 out of 13 GLBs missing, network timeout mid-manifest — yields a race where some pickups are floppies and others are icosahedrons, but every pickup still hits, still awards points, and the beatmap plays through end to end.

Code: src/features/gamecenter/games/sound-race/game/render/Entities3D.ts

The Sound Race asset pipeline is Hunyuan-3D-generated GLBs, uploaded to OSS, fetched by the client via the game-assets API, decoded through GLTFLoader, and cached in IndexedDB (which I wrote about here and here). Every step in that chain has failure modes:

  • OSS returns 403 (bucket ACL misconfiguration — this actually happened during rollout).
  • One specific GLB fails to Draco-decode because its geometry has a NaN.
  • The Draco decoder wasm 404s because the deploy skipped /public/draco/gltf/.
  • The user’s IndexedDB is full and can’t cache the fetched bytes (which is fine per-fetch, but slower on subsequent visits).
  • The network drops halfway through and 6 of the 13 requests time out.

Any of those makes some subset of the templates arrive as null. The interesting engineering question isn’t “how do we prevent all these failures” — the answer is “we can’t, most of them are outside our system boundary” — but “what does the game do when they happen”.

The templates array

SceneAssets.loadSoundRaceAssets() returns a SoundRaceAssets object with 13 optional slots. Entities3D takes that object and pulls out the ones it needs, in the order the game uses them:

// Order MUST match BLOCK_PALETTE indices in ../palette.ts:
//   0 = 0xff3d6e (red-pink) → arcade coin
//   1 = 0xff5dc8 (magenta)  → cassette
//   2 = 0x5df0ff (cyan)     → floppy
//   3 = 0xfff066 (yellow)   → vinyl
this.pickupTemplates = [
  assets?.pickupCoin ?? null,
  assets?.pickupCassette ?? null,
  assets?.pickupFloppy ?? null,
  assets?.pickupVinyl ?? null,
]

// Hazard variants: 0=barrier, 1=saw, 2=pillar
this.hazardTemplates = [
  assets?.hazardBarrier ?? null,
  assets?.hazardSaw ?? null,
  assets?.hazardPillar ?? null,
]

The type is (THREE.Group | null)[]. Each slot is independent — slot 0 being null doesn’t say anything about slot 1. If the ship GLB fetches successfully but the coin GLB times out, pickupCoin is null and pickupCassette is a real Group.

The assets? optional chain covers the case where SceneAssets returns nothing at all (top-level fetch of the asset manifest failed, no OSS API responded, etc). In that case every slot resolves to null and the game runs entirely on the procedural fallback.

The fork

Every mesh spawn goes through one of two functions — buildPickupMesh or buildHazardMesh — and both have the same shape:

private buildPickupMesh(colorIdx: number): THREE.Object3D {
  const template = this.pickupTemplates[colorIdx % this.pickupTemplates.length]
  if (template) {
    const clone = template.clone(true)
    clone.rotation.y = Math.random() * Math.PI * 2
    return clone
  }

  // Fallback: original procedural polytope.
  const group = new THREE.Group()
  const color = blockColorHex(colorIdx)
  const bodyMat = this.quality.pbrEntities
    ? new THREE.MeshStandardMaterial({
        color, emissive: color, emissiveIntensity: 0.7,
        roughness: 0.45, metalness: 0.25,
      })
    : new THREE.MeshBasicMaterial({ color })

  const shape = this.sharedGeoms.pickupShapes[colorIdx % this.sharedGeoms.pickupShapes.length]!
  const body = new THREE.Mesh(shape.body, bodyMat)
  if (this.quality.entityShadows) body.castShadow = true
  group.add(body)
  // ... plus an EdgesGeometry outline for the low-poly look.
  return group
}

The gate is the first two lines. Everything below the return is only reached when template is null — which is why it’s cheap to keep both paths in the same file: they don’t run on the same call.

clone(true) (deep clone) is important. Group.clone() without the true argument does a shallow clone — the returned Group has copies of its child arrays but the meshes inside are shared with the original. Mutate one instance’s material and you mutate the template. Deep clone gives every spawned pickup its own materials and its own transform, so per-clone rotation (clone.rotation.y = Math.random() * Math.PI * 2) works without side-effects on the template.

The Math.random() yaw is a small detail with a real payoff: a beatmap section with three yellow pickups in a row would look like a stamped copy without it. Randomized yaw makes them read as three individual objects. The GLB scale, position offset, and material state all come from the auto-fit pass in SceneAssets — the deep clone preserves those, so we don’t have to re-normalize per instance.

The procedural side

The fallback isn’t a single “generic” mesh — each pickup color has its own polytope so the four colors read as four shapes, not four palette-shifted copies of the same shape:

const R = PICKUP_SIZE / 2
const p0 = new THREE.IcosahedronGeometry(R * 1.05, 0)   // red-pink
const p1 = new THREE.OctahedronGeometry(R * 1.15, 0)    // magenta
const p2 = new THREE.TorusKnotGeometry(R * 0.6, R * 0.22, 48, 8, 2, 3)  // cyan
const p3 = new THREE.TetrahedronGeometry(R * 1.2, 0)    // yellow

Four different silhouettes. When the GLBs load, the pickups become floppy / cassette / vinyl / coin — four different silhouettes. When they don’t, the pickups become icosahedron / octahedron / torus knot / tetrahedron — still four different silhouettes. The player’s ability to identify colors at speed doesn’t depend on which mode the game is in. That property matters more than the specific look.

Hazards work the same way: barrier / saw / pillar → cone / cylinder / short-cone-plus-bar procedural equivalents. The three hazard variants share a threat color (red) so all of them read as “dodge me” whether they’re rendered as GLBs or as procedural silhouettes.

Why not preload-then-block?

An alternative design: wait for all 13 assets to load. If any fail, show an error screen. Don’t spawn until you have the full set.

That’s the wrong tradeoff for a browser game. It moves the failure surface from “the pickup that’s supposed to be a floppy is instead a cyan icosahedron” (imperceptible unless you’re looking for it) to “the game refused to start because one CDN request timed out” (obvious, and users leave). Sound Race is a two-minute rhythm race. If we can’t start the race, we’ve lost the user; if we can start with 90% of the visual polish, most players will finish.

The other alternative: fetch on demand as each pickup is about to spawn. That produces stalls in the beatmap timing (fetching a 90MB GLB mid-race is not going to happen inside a 200ms lookahead window) and would require queueing an entire asset budget worth of fetches upfront anyway.

Best-effort preload with per-slot fallback is the shape that matches the fetch behavior. The upfront preload is fire-and-forget; whatever’s ready by the time the player hits Play is what the race spawns from; slots that arrive late replace their fallbacks on the next spawn call because pickupTemplates[i] is a live reference (though in the current code we don’t specifically re-check on late arrivals — a slot that resolves after a race starts stays resolved for the next race). If we wanted late-arrivals to swap in mid-race, that’d be one line: instead of holding a fixed reference, ask SceneAssets for the current template each spawn. We haven’t needed it.

The quality-tier orthogonal

The fallback path also observes the current quality tier (this.quality.pbrEntities, this.quality.entityShadows), which is set from a device probe in detectQuality(). On a low-end phone pbrEntities is off and we build the fallback with MeshBasicMaterial instead of MeshStandardMaterial — no PBR shading, no bloom-catching emissive, just flat color.

The GLB path doesn’t observe quality in the same way — the templates arrive as-materialed by SceneAssets, which uses MeshStandardMaterial unconditionally. That’s a small inconsistency: on a low-end device, an OSS-loaded run gets PBR-lit pickups, and an OSS-failed run gets flat-shaded ones. It hasn’t caused issues because devices that load OSS well tend to be phones on wifi + capable of PBR, and the correlation isn’t perfect but it’s tight enough that we don’t see the mismatch in practice.

If we wanted to be strict, SceneAssets would take the quality config and swap material types accordingly. Not doing this cost us zero support tickets, so it stays as-is. Worth noting that the shape is available if we ever need it.

What this pattern generalizes to

The template-or-fallback shape is the right one whenever:

  1. You have a “nice” version of an asset that lives outside your bundle (CDN, OSS, dynamically loaded).
  2. You have a “not nice but functional” version you can synthesize locally.
  3. The gameplay contract only cares that something renders — not that the nice version is the one rendering.

Icons in an admin UI aren’t this — an SVG file that fails to load is a broken UI. Enemy character models in a shooter aren’t this — a T-posed placeholder is a bug report. Pickups in a rhythm game are this — the game is about hitting things at the right time; the shape of the thing is aesthetic.

Every game has this class of asset, and the pattern for them is the same: two spawn paths, one gate, best-effort load. It’s fifty lines including the shared geometry helpers. It converts a class of “the asset pipeline broke and now the game doesn’t run” bugs into “the asset pipeline broke and the pickups look generic for one race”, which is a much better failure mode.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

Three.js

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

Read dispatch
Three.js

Rapier boots async, your engine boots sync — here's the seam

Rapier's wasm loader returns a Promise, but a three.js render loop starts on `new`. If you naively `await RAPIER.init()` in the constructor, you get a black screen while wasm downloads. If you fire off `.then(...)` and forget about it, your first ticks crash on `world.step` because the world doesn't exist yet. Tank Battle threads this needle with a `physReady` flag, a purely-cosmetic pre-physics render loop, and a check-and-wait "deploy" gate.

Read dispatch
Three.js

Three ways Rapier tells you two things touched, and when to use which

Rapier surfaces contact detection through three interfaces on top of the same underlying check — the raw event queue, R3F's `onCollisionEnter` prop, and sensor colliders with `onIntersectionEnter`. Same feature, three ergonomics, different tradeoffs. This post walks through each shape with real call sites from Tank Battle and 3D Race, and where each one belongs.

Read dispatch

Recibe un email cuando publiquemos un nuevo artículo — análisis técnicos, ~una vez al mes.