← Back to Documentation Index

Cinematic 3D Cart Guide

Sponsor

A playbook for building rich, cinematic Nova64 carts that mix a rendered 3D world with a 2D HUD/overlay — drawn from the build of examples/the-last-save-file, a multi-scene "recovered save file" campaign cart (boot → loader → glitch → room → city → invite).

If you read nothing else, read Rule #0 (the tech) and the Brand & Persona section (the soul). A cart that is technically correct but soulless misses the point; a cart with the right soul and the wrong tech just renders black.


Brand & Persona — the aesthetic north star

Nova64 is "a new machine from the timeline where the weird games won." Our audience and our taste are not "retro for nostalgia's sake" — they're makers, outsiders, and signal-chasers. Every cinematic sequence should feel like it was built by, and for, that crowd. Idolize these influences and let them drive colour, motion, type, and copy:

Persona / voice: an underground operator who builds beautiful things and distrusts the mainstream. Earnest about craft, allergic to corporate gloss. Speaks in short, evocative lines, never marketing-speak. The cart is a found artifact, not an ad — even when it is an ad.

Palette anchors: cyan 0x2ee9ff · magenta 0xff4bd8 · green 0x73ff9f · amber 0xffcc77 · sunset 0xff6a3d→0xff4d72 · near-black grounds. Glow it all.

Litmus test for any sequence: would this look right on a skate deck, a rave flyer, a cracktro, and a Banksy wall at the same time? If yes, ship it.


Rule #0 — A 3D cart MUST clear the 2D layer transparent, not opaque

Every cart's draw() runs over a 2D framebuffer that is composited on top of the rendered 3D scene. cls(color) fills that layer. The catch:

// runtime/api.js — cls() with a plain Number is OPAQUE
cls(0x000000); // → fills the 2D layer solid black, alpha 255
               // → composites over and HIDES the entire 3D scene

A 2D-only cart wants that (an opaque background colour). A 3D cart does not — an opaque clear paints a solid sheet over your beautiful 3D world and you see nothing but the HUD you draw. The only reason any 3D peeks through is wherever you happen to draw a semi-transparent 2D shape.

Do this instead (clears the 2D layer fully transparent so the 3D shows):

export function draw() {
  const d = nova64.draw;
  if (typeof d.cls3D === 'function') d.cls3D(); // explicit transparent clear
  else d.cls(d.rgba8(0, 0, 0, 0));              // fallback: alpha-0 via BigInt color
  // ... draw HUD on top ...
}

Why the fallback works: rgba8() / packRGBA64() return a BigInt RGBA64, and cls() honours the alpha of a BigInt. A plain Number like 0x000000 carries no usable alpha, so cls() defaults it to opaque. cls3D() is the self-documenting helper (added to runtime/api.js); cls(rgba8(0,0,0,0)) is the older idiom (see examples/demoscene).

Symptom to recognize: "the 3D is rendering (HUD says triangles/draw calls are live) but the screen is black/empty except my 2D text, and only a small patch of 3D shows through a translucent box." → You are clearing opaque. Use cls3D().

2D-only scenes inside a 3D cart (boot screens, BIOS, color bars) are fine — just have them paint their own opaque fullscreen rect (e.g. rectfill(0,0,W,H, uiColor(0x000000)), where uiColor packs alpha 255). The transparent cls3D() underneath is harmless because they cover it.


Lighting — dark materials need real lights, not ambient

Ambient light is multiplicative: dark_material × dark_ambient ≈ black. Cranking setAmbientLight intensity barely lifts near-black surfaces (e.g. 0x141827 walls). A "lit" room built from moody dark materials will read as a black void with ambient alone.

Bloom threshold — the neon gotcha

enableBloom(strength = 1.0, radius = 0.5, threshold = 0.6). The default threshold 0.6 only blooms near-white pixels. Saturated neon sits below it (magenta 0xff4bd8 ≈ 0.56, sunset orange ≈ 0.57 luminance), so a vaporwave scene of pure-colour emissives renders flat with no glow even though bloom is "on" — the classic "bloom works on the white TV but is broken on the neon city."

Set the threshold deliberately per look:

// glowing neon / outrun — strong, wide, LOW threshold so colours bloom
nova64.fx.enableBloom(1.15, 0.85, 0.18);
// only bright sources glow (a TV in a dark room) — default-ish threshold is fine
nova64.fx.enableBloom(0.85); // threshold ~0.6

Bloom is per-pipeline: set it on scene-enter and reset it when leaving the scene.

Glitch — use the real post-process, not a hand-drawn 2D fake

There's a real GPU glitch pass (channel split + block tearing — the same damage-glitch Space Harrier uses): nova64.fx.enableGlitch(intensity) / setGlitchIntensity / disableGlitch. For a one-shot juicy burst (hits, scene stings, signal interference) use the convenience helper — it ramps and auto-decays itself, no cart-side timer needed:

nova64.fx.glitchBurst(0.7, 0.3); // intensity 0–1, duration seconds

Prefer this over drawing your own RGB-shift in 2D — it looks better, is one line, and is consistent across carts. (Gate frequency/intensity behind reduced-motion; keep flashes accessible — see Accessibility.)

Self-illuminating screens (a playing TV, a monitor)

nova64.video.loadTexture(url).applyToMesh(meshId) binds a video as the mesh's diffuse map only — under dim light it reads dark. To make a CRT glow with its own picture, promote the video to an emissive map (Three-specific escape hatch, guarded so other backends no-op):

const node = nova64.scene.getMesh(tvScreen);
node?.traverse?.(child => {
  if (!child?.isMesh) return;
  for (const mat of [].concat(child.material)) {
    if (mat && 'emissiveMap' in mat && tv.texture) {
      mat.emissiveMap = tv.texture;
      mat.emissive?.set?.(0xffffff);
      mat.emissiveIntensity = 1.1;
      mat.needsUpdate = true;
    }
  }
});

Animate emissiveIntensity per frame for a CRT flicker. With bloom on, the screen becomes the brightest thing in frame and lights the room for free.

Material cache caveat: getCachedMaterial keys on {color, emissive, ...}, so mutating a material affects every mesh that shares that key. A unique color+emissive combo (like a white screen with a one-off emissive tint) is safe to mutate; common combos are not.


Camera — make motion cinematic

const push = easeInOut(clamp(t / 22, 0, 1));     // main dolly over ~22s
const creep = clamp((t - 22) / 30, 0, 1);        // slow ongoing creep
setCamera(camX + sway*0.06, camY, 6.4 - push*3.1 - creep*0.7,
          targetX, targetY, targetZ, 56 - push*19 - creep*4);

2D overlay discipline — frame the 3D, don't bury it

Once cls3D() reveals the 3D, less is more on the 2D layer:

const CITY_SIGNS = [{ label:'MAKE', color:COLORS.cyan, x:150, y:84, w:134, h:40 }, ...];
// drawCitySigns() and checkCitySigns() both iterate CITY_SIGNS.

Scene state machine pattern

This cart uses a single scene string + sceneTime accumulator:

Auto-advance scenes on sceneTime thresholds (and/or taps) for an attract-mode/ad feel.


Host & input gotchas (learned the hard way)


Verification workflow


Recipe: an Outrun / vaporwave scene

The Nova64 house style, assembled (see the city scene in the reference cart):

  1. Sunset skybox — nova64.light.createGradientSkybox(0x180a2e, 0xff4d72) (purple zenith → hot-pink horizon). Clear it (clearSkybox) when leaving the scene so it doesn't bleed elsewhere.
  2. Retro sun at the vanishing point — a big emissive sphere (0xff6a3d) on the horizon, with a few thin dark cubes just in front of its lower half to read as the classic scanline gaps.
  3. Neon grid floor — thin emissive cubes (long in Z for lanes, long in X for rungs) in alternating cyan/magenta on a near-black ground. Reserve the center lane for a brighter dashed road line streaking toward the sun.
  4. Stars — a few dozen tiny emissive white/blue cubes scattered high in the sky behind everything.
  5. Skyline as silhouette — dark tower bodies flanking the avenue, each with a rooftop beacon + a grid of emissive windows (random lit/unlit, neon colours).
  6. Life — glowing traffic (dark car bodies + bright head/taillight cubes) streaming both ways, animated each frame; recycle them along the corridor.
  7. Crank bloom with a LOW threshold (enableBloom(1.15, 0.85, 0.18)) so the saturated neon glows — default threshold leaves it flat (see Bloom gotcha). Reset bloom (enableBloom(0.85)) on the next scene.
  8. Keep ambient low so towers stay silhouettes and the neon does the talking.
  9. Add motion: a banded retro sun whose bright lines wave up/down, drifting traffic, and occasional glitch bursts — but keep flashes accessible (see Accessibility).

Accessibility — make the vibe inclusive

The aesthetic is intense by design (neon, bloom, glitch, strobe, auto-advancing sequences). That's exactly why accessibility needs deliberate attention — the house style trends toward the opposite of accessible defaults. Build these in:

Rule of thumb: ship the full vaporwave assault as the default experience, but make sure one toggle (reduced motion) turns it into something a photosensitive player can still enjoy. Inclusive ≠ watered-down.

Roadmap: accessibility is tracked as a phased, measured effort in WCAG_ACCESSIBILITY_PLAN.md (capture → measure → improve). New carts hit the Phase 0 boxes at minimum.

New-cart checklist


Source cart: examples/the-last-save-file/code.js. Host helper added: cls3D() in runtime/api.js (exposed via runtime/namespace.js draw list).