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.
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.
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.
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.
nova64.light.createPointLight(color, intensity, distance, x, y, z). Place it
where the in-world light source is (a TV, a sign, a pedestal) so the room reads
as lit by that thing. Toggle visibility per scene with setLightVisible.0x101010).enableBloom(...) makes emissive surfaces glow — lean on it for screens/neon.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.
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.)
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:
getCachedMaterialkeys on{color, emissive, ...}, so mutating a material affects every mesh that shares that key. A uniquecolor+emissivecombo (like a white screen with a one-off emissive tint) is safe to mutate; common combos are not.
update()/scene logic with
setCameraPosition + setCameraTarget + setCameraFOV. (gpu.setCameraTarget
does a lookAt, so whatever you target lands at screen center.)sin-based sway/bob sells "handheld / watching from the carpet."easeInOut the push; clamp progress to [0,1].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);
Once cls3D() reveals the 3D, less is more on the 2D layer:
drawGradient over the top ~80px and
bottom ~70px) for contrast behind text.const CITY_SIGNS = [{ label:'MAKE', color:COLORS.cyan, x:150, y:84, w:134, h:40 }, ...];
// drawCitySigns() and checkCitySigns() both iterate CITY_SIGNS.
This cart uses a single scene string + sceneTime accumulator:
update(dt) advances sceneTime, runs per-scene logic, and calls
enterScene(next) on transitions (which resets sceneTime, toggles mesh-group
visibility via setMeshVisible, swaps lighting/fog, and toggles scene lights).draw() dispatches to a per-scene overlay function.transition value (rectfill full-screen black at
alpha = transition*255).roomMeshes, cityMeshes, ...) and
showGroup(group, visible) on enter.Auto-advance scenes on sceneTime thresholds (and/or taps) for an
attract-mode/ad feel.
Enter = "Restart cart" in the studio console. Don't use it as an in-cart
confirm key; it resets the cart. Use Space / pointer taps. (Shift+X = dev
console, F9 = debug.)dispatchEvent(new KeyboardEvent(...)) from devtools does not reach it —
use real CDP key/click input (e.g. chrome-devtools press_key / click).mousePressed(); design touch-first (multi-touch via
nova64.input.touches()), since this is mobile-first.pnpm dev (vite, port 3000) typically runs in
WSL. WSL2 inotify does not see Windows-side file edits on /mnt/c, so
vite won't hot-reload edits made from Windows tools — restart vite (or edit
from inside WSL) to pick changes up. A stale vite serves an old transformed
module even across page reloads.nvm use 20 && node_modules/.bin/vite --host (run persistent/in
background; a detached nohup inside a one-shot shell gets reaped).press_key,
screenshot, and evaluate_script. Useful probes via window.nova64:
scene.getScene(), camera.getCamera(), scene.getMesh(id), project a mesh's
world pos to NDC to verify framing.camera.getCamera().position/fov
to detect the current scene, then screenshot promptly — a long settle delay
lets the scene auto-advance past you.The Nova64 house style, assembled (see the city scene in the reference cart):
nova64.light.createGradientSkybox(0x180a2e, 0xff4d72)
(purple zenith → hot-pink horizon). Clear it (clearSkybox) when leaving the
scene so it doesn't bleed elsewhere.0xff6a3d) on
the horizon, with a few thin dark cubes just in front of its lower half to
read as the classic scanline gaps.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.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:
prefers-reduced-motion
(window.matchMedia('(prefers-reduced-motion: reduce)')) as the default.Enter the only confirm (it's the
console's Restart). Text entry via a real DOM input (native keyboard/IME).prefers-reduced-motion, prefers-contrast, and system
font-scaling should visibly change behaviour where feasible.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.
draw() uses cls3D() (or cls(rgba8(0,0,0,0))) — not cls(0x000000).Space/pointer for input (never Enter as confirm); touch-first; tap targets ≥ 44px.prefers-reduced-motion); flashes < 3/sec; not colour-only; no reflex-gating; captions for audio.Source cart: examples/the-last-save-file/code.js.
Host helper added: cls3D() in runtime/api.js (exposed via runtime/namespace.js draw list).