Everyone’s Retro 3D Is a PlayStation
The wobbling vertices everybody copies are a PS1 artifact. The Nintendo 64 had a floating-point rasteriser and did not do that. This is the complete teardown of Volthaven 64 — a playable N64-style game in a browser — nineteen techniques, every one measured, with the before and after.
In short
The N64 look is not jitter. It is a low-resolution framebuffer upscaled with hard pixels, 16-bit colour with ordered dithering, tiny textures that are blurred rather than sharpened, lighting baked into vertex colours, and fog standing in for a draw distance. Vertex snapping — the technique everyone reaches for first — belongs to the PlayStation, and this project measured it, proved it, and deleted it from both shaders.
Key takeaways
- Vertex snapping is the wrong console. Removing it took a Level 1 camera pan from 14 backward vertex steps (2.52 px) to zero.
- Sharp texels are also the wrong console. The N64 filtered its textures. Trilinear + mipmaps measured 11.7× less far-field shimmer; the pixels come from the framebuffer, not the texture.
- The framebuffer is RGBA5551, not 5-6-5. This codebase writes “5-6-5” in 18 comment sites across 12 files and it became the calibration reference for three art tickets.
- Dither before you quantise, and index by texel — not by screen position, or the pattern crawls across the surface as the camera moves.
- The whole soundtrack is 22,563 bytes and adds nothing to the bundle. Eleven instruments synthesised in JavaScript, 15 pieces, all 32 beats.
- Loops are bit-exact, not crossfaded. Pick the loop length first, then force every partial to an integer multiple of
rate / Land the seam lands on a zero crossing by construction. - An art ticket that cannot show a pixel is not done. The programme produced 1,116 usable before/after pairs — and excluded 8 whose own README declares them manufactured.
- One number made Level 3 unwinnable for the project’s entire history: a flood rate of 0.25 against a player who climbs at 0.0135.
Watch the class videos
The videos that go with this class, newest first. Press play to load one from YouTube's privacy-enhanced player.
Transcript
Transcript
This video block requests nothing from YouTube until you press play. Pressing play loads YouTube's privacy-enhanced player, youtube-nocookie.com, in this page, and the player then contacts other Google and YouTube hosts.
The most famous retro-3D technique is from a different machine
The PlayStation 1 had no floating-point rasteriser. Vertex positions were quantised to integer screen coordinates, so geometry visibly wobbled as the camera moved. The Nintendo 64 had a floating-point Reality Signal Processor and did not do this. If your retro game jitters, you have built a PS1.
This is the first thing to get right, because it is the thing almost every “retro 3D” shader gets wrong, and because getting it wrong makes everything downstream look like the wrong decade.
Volthaven implemented snapping first — like everyone does. Then it measured a Level 1 yaw pan and counted how many times a vertex moved backwards against the direction of the camera:
Snap ON — PS1
Snap OFF — N64
Where the N64 look actually comes from
Six things, none of them jitter: a low-resolution framebuffer upscaled without interpolation, 16-bit colour, ordered dithering to hide the banding that causes, tiny textures with trilinear filtering, lighting baked into vertex colours, and fog doing the job of a draw distance. Every one of them is a section below.
The removal had to happen in two shaders. The ticket named one file. The identical block also sat in the water material, and patching only the one named would have fixed 1 material and skipped 335. That is a lesson about tickets as much as about rasterisers.
The audit that started the whole art programme
An art-director review scored the build 1 out of 5 on four separate criteria — vertex colour richness, animation weight, environmental storytelling, and moment-to-moment polish — and wrote that the engine was “inadvertently emulating a PlayStation 1 with a CRT filter.” Those four scores ended at 4, 4, 3 and 4. Everything in this class is what happened in between.
Five sectors, five bosses, and a rail
Volthaven 64 is a rail-based action platformer in TypeScript on Three.js. The player runs a fixed track through five graded sectors, each granting one ability and ending in a boss. It runs in a browser with no install, and it is covered by 359 test cases across 10,365 lines of test code.
- Source
- 35,791lines across 81 files
- Tests
- 359cases · 10,365 lines
- Test ratio
- 1.20lines of test per line of game
- Sectors
- 5each with one boss
- Soundtrack
- 22,563 B15 pieces, 0 B of bundle
- Render target
- 426×240short axis pinned
| # | Sector | Rail | Gaps | Grants | Boss | HP |
|---|---|---|---|---|---|---|
| 1 | Industrial Rail | 179.47 | 5 @ 1.70 | — | Warden | 300 |
| 2 | Factory | 155.98 | 4 — two dash-only, 6.70 & 8.70 | Dash | Coilmaster | 450 |
| 3 | Flood | 591.31 | zero | Slam | Cantor | 500 |
| 4 | Void | 225.33 | 3 @ 7.70 | Tether | Aeros | 550 |
| 5 | Spiral | 412.96 | 6 @ 8.85 | Nova | Ascendant | 900 |
The rail is the whole architecture
A level is not a mesh you walk on. It is a curve. A single scalar railT describes how far along the track the player is, and world position is derived from it by sampling the curve. Gaps are ranges of railT with no floor. The camera follows the curve rather than the player. Everything — enemies, checkpoints, the flood, boss arenas — is placed by a position along that one parameter.
This is why the whole game fits in 35,791 lines. It is also why one arithmetic error in a curve length can make an entire level unreachable, which happens in Module 12.
The five abilities, as the code actually enforces them
Arc Strike J cost 0 · 8-frame cooldown · 12 dmg · reach 2.5 h x 2.0 v
Surge Dash K cost 15 · deducted · 12.0 rail units · 15 frames i-frames
Overcharge Slam Down + J cost 30 · deducted once · 40 dmg · radius 4.0
Arc Tether L cost 0 · gated on charge >= 20 and NEVER DEDUCTED
Discharge Nova I cost 50 · deducted · 30 dmg · radius 12
The slam has two deduction sites and charges you once
Worth walking through, because reading the source carelessly makes it look like a double charge and it is not:
1188 if (!isGrounded && wantsSlam) charge -= 30 the direct path
1192 else if (isGrounded && wantsSlam) NO DEDUCTION sets vy, isGrounded=false,
slamBufferTimer=20 — it is a hop
1231 else if (slamBufferTimer > 0 ...) charge -= 30 fires at the apex of that hop
airborne slam -> 1188 only = 30
grounded slam -> 1192 hop (free), then 1231 at the apex = 30
Either route costs 30 once. The hop is free and the buffered branch spends it.
Why the hop exists at all
A grounded SLAM used to fall through to Arc Strike with no feedback, so the mobile SLAM button silently did the wrong thing and Level 3’s Cantor shield gate looked broken — the one boss whose only opening is a slam from above. The hop-and-buffer is the fix: on the ground it launches you, then fires the slam at the apex, which is where the move was always supposed to happen.
Nine places the interface disagrees with the code
Arc Tether is free. The in-game manual bills it five separate times, including under a column headed SPEND, and Level 4 even maintains an arena charge floor so the player can afford it. The complete list of deduction sites in Player.ts is -1 drain, -30 slam airborne, -30 slam buffered, -15 dash, -50 nova — there is no tether entry. Elsewhere the HUD shows SLAM READY from 20 charge against a real floor of 30, and hides it above 50. One invulnerability timer is set to 24 under a comment reading “Exactly 48 frames per spec.”
Render small, upscale hard
Render the scene to an offscreen target a fraction of the window's size, then blit it to the canvas with no interpolation. This is the foundation technique: it produces the chunky pixel grid, and it is also what makes every later effect cheap, because everything downstream operates on a quarter of a megapixel.
Most N64 titles ran at 320×240. A few used a 640×480 high-resolution mode, usually with the expansion pak. The pixels you remember are 320×240 stretched to a television.
Volthaven does not hardcode 320×240, and the reason is worth stealing:
constructor target created at 320 x 240
resize() overwrites it — only the SHORT axis stays 240
16:9 window -> 426 x 240
4:3 window -> 320 x 240
portrait -> 240 x 520
Hardcode 320×240 on a 16:9 display and you must either letterbox or stretch. Stretching gives you non-square pixels, which reads as a bad emulator rather than as a console. Pinning the short axis keeps every pixel square at every aspect ratio, and the cost is that a widescreen player sees more of the level than a 1998 player would.
That is a real divergence, and it is the right one
Being faithful to the pixel grid matters more than being faithful to the pixel count. A 426×240 image on a 16:9 monitor looks like an N64. A stretched 320×240 image looks like a mistake.
The upscale is one CSS property, and it is the property that does the most visible work in the entire codebase:
imageRendering: 'pixelated'
The default browser behaviour when scaling a canvas up is bilinear smoothing. That turns a crisp 426×240 image into a blurry mess and undoes every other technique on this page. pixelated makes each source pixel a hard square.
RGBA5551, and the word we got wrong 18 times
The N64's 16-bit framebuffer mode is RGBA5551 — five bits each of red, green and blue, and one bit of alpha or coverage. It is not 5-6-5. That is a different piece of hardware, and mixing them up is so common that this codebase does it in 18 separate comments across 12 files.
Quantisation happens once, at the end, in the blit shader. Not per material — if you quantise per material you get different banding on overlapping surfaces and the illusion falls apart at every intersection.
c.r = floor(c.r * 31.0 + 0.5) / 31.0; // 5 bits — 32 levels
c.g = floor(c.g * 63.0 + 0.5) / 63.0; // 6 bits — 64 levels
c.b = floor(c.b * 31.0 + 0.5) / 31.0; // 5 bits — 32 levels
Those multipliers are 5-6-5. So the implementation matches the comments, and both are the wrong console’s format. Green gets 64 levels where the hardware gave it 32.
Why this is worth a whole section
The wrong term became repository vocabulary. It appears in 18 sites across 12 files, and it was the stated calibration reference for three separate art tickets — the dither work, the palette grading, and the colour authoring. Nobody wrote a test for a word. A wrong word propagates further than a wrong line of code, because a line of code has somewhere to fail.
Is the visible difference large? No — one extra bit of green. Is it worth fixing? Yes, and mostly so the comments stop teaching the error to the next reader.
Find the other three
Colour is not the only place this codebase says one thing and does another. Three rounds below, each a real inspection from the render layer. Every listed line is real, and in each round exactly one is not doing what it appears to do.
Find the line that does nothing.
Three rounds from the render pipeline. Every setting below is applied, and the API reports every one of them as active. In each round exactly one changes no pixels.
Checked: getContextAttributes() reports the requested flags
renderTarget.samples, shipped at 4 against a maximum of 8. Only a pixel diff could tell you: the project's own file is named diff_aa0_vs_aa1_x32_EXPECTED_BLACK.png.A flag can be granted and still be pointed at nothing.
Checked: the material declares transparency and the level renders
material.opacity into a raw ShaderMaterial by itself. The water renders fully opaque on two levels. There is a purpose-built test guarding this — and it passes, because its own fixture contains opacity: 0.8 and it only ever checks that the property was declared.Declaring a property is not the same as the value arriving at the shader.
Checked: the HUD renders every field it declares
CP: 0 forever. This is the same write-in-some-paths-not-others shape as the boss bug in Module 12, reproduced inside the diagnostic tool built to catch it.A field can be displayed, typed, and initialised, and still never be written.
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
Thirty-two levels of blue is not enough
With five bits per channel, any smooth gradient collapses into visible bands. Ordered dithering adds a small position-dependent offset before quantisation, so a surface that would band instead resolves into a fine checkerboard the eye averages into an intermediate shade. This is the technique that makes 16-bit colour look deliberate rather than broken.
Dither OFF
Dither ON
0 8 2 10
12 4 14 6
3 11 1 9
15 7 13 5 / 16.0 -> offset added before floor()
The detail almost everyone gets wrong
The matrix is indexed by the source texel, not by gl_FragCoord.
Index by screen position and the dither pattern is fixed to the display. Move the camera and every surface appears to swim through a stationary checkerboard — the pattern crawls. Index by the texel and the pattern is attached to the surface, so it moves with the geometry the way a real texture would.
The project verified the result the only way that means anything: it counted 4×4 screen blocks that were not uniform after quantisation, and got 0 of 76,800. Every block on screen resolves to a single quantised value, which is what tells you the grid survived the whole pipeline intact.
The refusal this produced
A ticket asked for bilinear filtering on the render target. The specialist refused with a measurement: bilinear made 35.6% of 4×4 screen blocks non-uniform, dissolving the pixel grid across a third of the screen. The blit must be nearest-neighbour or none of this survives to the display.
A scanline is a display artifact, not a console one
CRT scanlines ship in this codebase as a toggle, defaulted off, on exactly that reasoning. The N64 did not produce scanlines; the television did. Bundling the two is how retro projects end up simulating a specific living room instead of a specific machine.
Light you pay for once
The N64 had no pixel shaders and very little fill-rate to spare, so per-vertex Gouraud shading was the practical ceiling. Baking goes one step further: evaluate the lighting model at build time, store the result in the mesh's vertex colour attribute, and at runtime the GPU only interpolates numbers it already has.
Before
After
(1,1,1) on screen — authored in one colour space and read in another, so pure white arrived as black. After: plated, panel-lined, and lit by values baked into its own vertices.The baker evaluates a five-term model per vertex — not a single dot product. The terms give you something that reads as lit rather than merely shaded, and because it costs nothing at runtime you can afford to be generous with geometry.
That last point is the real payoff. Volthaven's levels carry a great deal of decor — pillars, gantries, kerbs, lamps — and none of it costs anything to light. On hardware with a real-time lighting budget you would have to choose. Here the choice is made once, at build time, and the budget at runtime is zero.
Unlit basic material
N64 material + bake + fog
The trade you are making
Baked light does not move. No dynamic shadows, no lights that follow the player, no time of day. In exchange the entire lighting cost of the game is paid before it ships. For a fixed-camera rail game that is close to free; for an open world with a day cycle it is the wrong tool.
Tiny, procedural, and blurry on purpose
The Reality Display Processor had four kilobytes of texture memory. That is the single constraint most responsible for how N64 surfaces look: large flat planes wearing small, soft, repeating textures. And soft is the operative word — the N64 filtered. Sharp texels are the other console again.
Four kilobytes means a 16-bit texture of roughly 32×64 in one load, and four-bit 64×64 textures were common. Volthaven generates its textures procedurally at 64×64 in JavaScript rather than shipping image files, which keeps the constraint explicit and the bundle empty.
The procedural-generation trap
Drawing from a shared global random source makes every texture depend on the order the textures were generated in. Add one texture at the top of the list and everything below it changes. Each generator needs its own seeded stream, or your art is a function of your import order.
Filtering is the second wrong-console mistake
This one is nearly as common as vertex snapping, and it comes from the same instinct: retro means pixels, so make the texels sharp.
The N64 applied three-point filtering and mipmapping. Its textures looked blurry. The pixelation you remember is the framebuffer being upscaled — it is not the texture. Sharp nearest-neighbour texels on a low-poly model is a PlayStation and PC look.
far-field shimmer 11.7x lower with trilinear filtering + mipmaps
why: a 64x64 texture on a distant surface samples far apart in UV space;
without mipmaps consecutive frames grab unrelated texels and the
surface boils. Mipmaps pre-average; trilinear blends between levels.
Two techniques in this class pull in opposite directions and both are correct: nearest-neighbour on the final blit, so the framebuffer grid survives to the display, and trilinear on the textures, so surfaces do not boil. Getting these the wrong way round is the most common way to build something that looks retro in a screenshot and wrong in motion.
N64 or PlayStation?
Six techniques below. Some belong to the machine this game is imitating. Some belong to a different one, or to the television it was plugged into. Call each before you reveal it.
Is that an N64 technique?
Six techniques. Three belong to the Nintendo 64. Three do not — and two of those three are in almost every retro shader on the internet.
Blur the textures with trilinear filtering and mipmaps, so distant surfaces stop shimmering.
Quantise vertex positions to a pixel grid so geometry visibly steps as the camera moves.
Render at a low internal resolution and upscale to the display with no interpolation.
image-rendering: pixelated, stands between this and a blurry mess.Overlay dark horizontal scanlines to sell the era.
Add an ordered 4x4 Bayer offset before quantising to 16-bit colour.
Skip perspective correction so textures warp and swim across large surfaces.
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
Fog is not weather. It is the draw distance.
The console could not afford distant geometry, so the far plane was pulled in close and fog was used to fade objects out before they popped out of existence. It reads as atmosphere and functions as a performance budget. Grading it per level is also the cheapest way to make five places feel like five places.
Before grading
After grading
3 layered fog bands near / mid / far, each with its own colour and curve
sky dome so the far plane never shows as an edge
near / far plane 0.1 / 100 — the far plane is LOCKED
arithmetic pop guard geometry fades to fog before it crosses the plane
The far plane stays fixed and the fog adapts to it, rather than the other way round. That ordering matters: a moving far plane changes your depth-buffer precision as you play, and z-fighting that appears only in some rooms is among the least pleasant bugs to chase.
With near at 0.1 and far at 100, the depth buffer has a workable distribution. Push near down to 0.01 to stop clipping something close to the camera and you spend precision you cannot get back — distant coplanar surfaces start flickering. The near plane is a budget, not a convenience.
Put the effects through the same pipeline as the world
The fastest way to break a retro image is a modern effect. An additive glow with a soft alpha falloff does not quantise, does not dither, and does not receive fog, so it floats on top of the scene looking like it came from a different engine — which it did.
Additive bubble
Dithered, fog-graded
That trade is the whole lesson. A soft glow spends screen area to say “something happened.” A hard shape with real contrast says it in less room and reads instantly at 240 lines, which is the resolution the player is actually looking at.
Quantise the fades too
A smooth alpha fade is a modern tell. Volthaven steps its fades — discrete levels rather than a continuous ramp — and uses flat-shaded faceted geometry for effect meshes rather than smooth-normalled shapes. Both are cheap and both push the image back into the right decade.
The art programme, measured
Every art ticket in this project had to prove its effect with a rendered pixel or a timed frame. Seven of those measurements are below. Judge each one: does the number actually support the claim it was filed under?
Seven art tickets, and what they measured.
Each row is a claim and the measurement taken to test it. The bars and percentages are computed from the two values, so a published delta cannot disagree with its own numbers.
Removing vertex snapping eliminates reverse geometry motion during a camera pan
measured delta -14 steps -100.0%
Putting the nova through the render pipeline trades screen area for readability
measured delta +52.8 contrast +379.9%
Redesigning the enemy roster makes silhouettes distinguishable
measured delta +676.2 P2/A +2146.7%
Boss silhouettes should not be interchangeable with each other
measured delta -0.57 IoU -58.6%
A boss telegraph must be legible at the resolution the game ships at
measured delta +20.77 % of frame +22577.2%
Sound effects and music are balanced against each other
measured delta +0.24 peak +114.8%
Consolidating track geometry reduces seam artifacts
measured delta -5.06 px/frame -33.3%
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
Count frames, not milliseconds
Volthaven's gameplay logic runs on frame counts rather than delta time. Cooldowns, invulnerability windows, boss phase durations and telegraph timings are all integers of frames. That is how the console did it, and it buys something modern engines give up: perfect determinism.
Delta time is the modern default and it is usually right. It keeps a game feeling the same on a 60 Hz laptop and a 144 Hz monitor. It also makes your game a floating-point simulation whose outcome depends on the exact frame times of the machine it ran on.
Frame counting gives you the opposite trade:
ARC_STRIKE_COOLDOWN_FRAMES = 8 an integer, not 133 milliseconds
boss phase durations 240f, 600f, 120f — exact, reproducible
telegraph windows counted, so a test can assert them exactly
BUYS a headless test can run 3,000 frames and get the same result every time
COSTS the game is tied to its tick rate; a different rate is a different game
The payoff shows up in Module 12. A test suite that can drive thousands of deterministic frames is what makes it possible to say “the player was within horizontal reach for 44 frames and vertical reach for 1,966, and within both simultaneously for zero” — a sentence that is meaningless if every run produces different numbers.
The number that predates the rule
Before ARC_STRIKE_COOLDOWN_FRAMES existed, the basic attack could deal 720 damage in 60 frames. Every boss HP value in the game was tuned against that. When the cooldown was added, the bosses were not retuned — so the HP numbers you saw in Module 01 are all artifacts of a DPS ceiling that no longer exists.
Fifteen pieces of music in twenty-two kilobytes
Cartridge consoles had no room for streamed audio, so they synthesised instruments and sequenced note events. Volthaven does the same: 11 instruments generated in JavaScript at 22,050 Hz with 4-bit quantisation, packing to 22,563 bytes and adding zero bytes to the shipped bundle.
The N64 is the interesting case here. It had no dedicated sound chip at all. Audio ran on the Reality Signal Processor as a microcode task, competing directly with graphics for the same silicon. Every voice you played was frame time you did not spend rendering. That is the constraint the design is answering.
- Instruments
- 11synthesised in JS
- Sample rate
- 22,050Hz, 4-bit quantised
- Packed
- 22,563 B0 B of bundle
- Voices
- 24quietest-then-oldest stealing
- Pieces
- 15all 32 beats
- Effects
- 23also sequencer tracks
The loop is bit-exact, not crossfaded
This is the best idea in the audio system and it generalises far beyond games.
Most looping audio is faded: play to the end, crossfade into the start, hope nobody notices. Volthaven inverts the problem. It picks the loop length first, then constrains every partial in every instrument to an integer multiple of rate / L. Every sine component therefore completes a whole number of cycles across the loop, so the final sample joins the first at a zero crossing by construction. There is no seam to hide.
Attacks are built the same way — the identical additive summation, run at negative frame indices — so the attack transient is part of one continuous function rather than an envelope spliced onto the front.
The sound effects are music
All 23 effects are sequencer tracks on the same instrument bank, on dedicated channels, authored in milliseconds at a fixed 120 BPM so that changing the music tempo cannot drag them. One synthesis path, one voice pool, one bus. Nothing in the game plays a sample file.
Trace the chain
Below is the actual signal path from a synthesised instrument to your speakers. Click any highlighted line to see what it does and what breaks if it is missing.
The signal path, from oscillator to output.
Volthaven's whole audio system in one chain. The highlighted lines are the ones worth understanding — click each to read what it does.
// ─────────── BUILD TIME ───────────// ─────────── PLAYBACK ───────────per-channel gainmusicBus// Level 3 restages this whole chain as the water rises:// tempo climbs, reverb wet FALLS — water displaces the air that was ringing.
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
Two open defects, honestly scoped
The voice allocator’s steal log grows without a cap and its reset has no shipped call site — but measured steals are zero across all five levels, so it is latent rather than live. The tell that it is an oversight is that the sibling onset log is capped, at 8192.
More audible: the effects level setter is never called, so effects sit at 1.0 against music at 0.27. The nova peaks at 0.449 against the entire score at 0.209 — the loudest effect is 2.1× the loudest music. It was a deliberate default set before the music existed, and the number was never revisited once it did.
A telegraph that does not read at 240 lines is not a telegraph
Every boss in Volthaven is a state machine with a telegraph, a punish window, and a recovery. At 426×240 with heavy fog, the tell has to be enormous and it has to be on the floor — because the floor is the one surface the player is always looking at.
Before
After
Silhouette first, everything else second
The same discipline applies to the ordinary enemies, and the numbers there are more extreme:
The cone is the instructive one. Measured by perimeter squared over area, it scored 31.6 from the front and 31.5 in profile — nearly identical, because a cone of revolution looks the same from every angle. After redesign the same enemy measures 609.0 front and 707.7 in profile. An enemy that reads identically from all sides gives the player no information about which way it is facing, which at this resolution is most of what they have to go on.
Put the tell on the floor
The Warden’s telegraph is its actual 12-unit hitbox, drawn on the ground. Not a suggestion of the attack — the attack’s real extent, rendered. The player learns the fight by learning to read a shape that cannot lie to them, because it is the hitbox. Four of the five bosses use floor tells; only the Ascendant uses a vertical column, and it is the hardest of the five to read.
The five fights, in one table
| Boss | HP | Telegraph | The window |
|---|---|---|---|
| Warden | 300 | 30-frame windup, real hitbox drawn on the floor | After the slam lands |
| Coilmaster | 450 | Fixed rhythm cycle, 330 / 293 / 255 frames | 90 frames, every cycle |
| Cantor | 500 | Three stacked rings before the drop | Immune except a 120-frame stagger, opened only by slamming from above |
| Aeros | 550 | Dive, targeted on the player’s position at frame 44 | 120-frame grounded recovery — the only one |
| Ascendant | 900 | Vertical column — the one non-floor tell | Three HP-gated bodies over the same rhythm |
Three numbers that made the game unplayable
The test suite is the largest single area of this codebase: 359 cases across 10,365 lines, 1.20 lines of test per line of gameplay. It exists because a rail game is a pile of constants, and a single wrong constant does not crash — it quietly makes something impossible.
One · The flood nobody could outrun
water rise 0.25 units per frame (original)
player climb 0.0135 units per frame (maximum, measured)
18.5x faster than the fastest possible ascent
after 0.01681 units per frame
Level 3 was unwinnable at any skill level for the entire history of the project. Not hard — impossible. No amount of play could produce a win, and nothing crashed, logged, or failed. It took a headless bot running the level deterministically and a test that asserted completion to surface it.
Two · The boss nobody could hit
Aeros finished ten consecutive naive campaign runs on 550 of 550 HP. Never scratched.
frames the player was within HORIZONTAL melee reach 44
frames the player was within VERTICAL melee reach 1,966
frames within BOTH at the same time 0
Each axis looked fine on its own. Either measurement alone would have closed the ticket. The bug only exists in the intersection, which is exactly the shape of defect a human playtester reports as “the boss feels weird” and cannot localise.
Three · The enemy that followed you into the wrong fight
deaths caused where it stood 0
damage dealt after following the player into
the Ascendant's arena 555 (5x the boss itself)
deleting that ONE spawn: Level 5 completion 3/10 -> 10/10
Zero deaths where it was placed. It was not a hard enemy. It was an enemy in the wrong place at the wrong time, and it was doing five times the damage of the final boss during the final boss fight.
What all three have in common
None of them is a crash, an exception, or a failing assertion in ordinary code. Each is a constant that is individually reasonable and wrong in combination — and each was found by running the game thousands of times deterministically and measuring the outcome, not by reading the code and not by playing it.
Every face button was wrong, and the code was correct
The game binds gamepad inputs by W3C Standard Gamepad index, which is right. The manual labelled those indices A / B / X / Y, which is right for Xbox and PlayStation and wrong for every Nintendo-layout pad. All four face actions were mislabelled on the controller the owner actually plays with, and no lookup-table test could ever have caught it.
0 jump bottom face
1 slam right face
2 strike left face
3 nova top face
On a Nintendo layout, A is on the right and B is on the bottom. Measured on a real 8BitDo SN30 Pro:
| Player presses | Gets | Manual promised |
|---|---|---|
| A (right face) | SLAM | jump |
| B (bottom face) | JUMP | slam |
| X (top face) | NOVA | arc strike |
| Y (left face) | ARC STRIKE | nova |
The control that proved it was labelling, not code
The same prompts and the same probe, run on a DualSense: every face button landed correctly. PlayStation labels are positional, so they agree with the index order. Only the pad whose labels differ came out rotated — and only on the four buttons whose labels differ. Shoulders, Start and the D-pad matched on both.
And it cannot be auto-detected
In X-input mode the SN30 Pro reports id: "Xbox 360 Controller (XInput STANDARD GAMEPAD)" — the literal identifier of a real Xbox pad. There is no vendor string to sniff and no transport field to read. Auto-correction is impossible. The player has to be able to tell the game.
The guard is the lesson
The fix touched only the words. Input.GAMEPAD_BUTTONS is byte-for-byte unchanged and a test asserts that, because a correct table is exactly what a label-shaped fix is most likely to damage. A per-vendor position-to-glyph table supplies the prompts.
A test that checks the thing you changed will not catch a bug in the thing you did not
A lookup-table test would never have found this. The table was correct. The bug was the literal "A" typed into the JSX, and it stayed true for two vendors and false for the third for as long as the game shipped. The guard now reads GameUI.tsx and fails on any bare face glyph. Its negative control: reintroducing the original literal fails 2 of its 3 assertions.
The remapping design that dissolved a risk instead of testing it
The mapping !== 'standard' branch is untestable without hardware that reports a non-standard mapping — a branch whose first real execution would be on a stranger’s machine. The resolution was not to test it. It was to remove the need for it.
A capture flow asks the player to press the button they want and records whatever index the pad reports. It never consults mapping. There is no layout guess to be wrong, so there is no fallback branch to leave untested.
Two traps came out of building it, and both are the same trap:
- Re-arm is not immediate. The first version re-baselined the axis detector on the very next frame after a hit, so a stick springing back read as a fresh input and one press recorded two bindings. Require every button released and every axis within ~0.25 of its sampled rest pose.
reset()must not re-sample the rest pose. A remap flow calls it immediately after recording — exactly when the stick is mid-spring-back — which takes a displaced axis as the new zero. That is the same bug rebuilt inside its own fix.
A scope claim that had to be withdrawn
The brief asked for “USB / Bluetooth pad detection.” The Gamepad API has no transport field — it exposes id, index, connected, timestamp, mapping, axes, buttons, vibrationActuator and hapticActuators, and nothing else. A DualSense reports "Wireless Controller" because that is the device’s name; plug it in by cable and it still says Wireless. The feature was cut and a test now asserts the settings payload contains no transport claim. The honest deliverable is sometimes a smaller one plus a stated reason.
| SN30 Pro (USB, X-input) | DualSense (Bluetooth) | |
|---|---|---|
| id | Xbox 360 Controller… | Wireless Controller (Vendor: 054c) |
| mapping | standard | standard |
| buttons / axes | 17 / 4 | 18 / 4 |
| vibrationActuator | true, dual-rumble | false |
| axes at rest | [0, 0, 0, 0] | [0.012, −0.004, −0.004, −0.02] |
Two findings fall straight out of that table. Vibration must be guarded per-pad, not once globally — one session can see both. And the 18-button DualSense is two past the 17-index standard, so anything iterating buttons.length against a fixed table reads off the end.
Centred content that overflows cannot be scrolled to
justify-content: center on a flex column that overflows pushes content out of both ends, and neither end can be reached. scrollHeight equals clientHeight, so nothing reports an overflow. It is invisible until the content grows — and it shipped in this project five separate times.
title screen found first, fixed
boss health bar a genuinely discontinuous case — correctly fixed with a breakpoint
pause panel needed 1,082 px in a 639 px viewport. 7 of 18 buttons unreachable
game over clips below a 282 px viewport
victory clips below ~329 px <- an iPhone SE in landscape is 320 px tall
ErrorBoundary clips below 192 px <- renders error.message, an unbounded string
The pause panel is the one worth telling. Measured on the live build with a pad attached: plate top at −221, bottom at 861. Clipped 221 px off the top and 222 px off the bottom — the symmetric signature that tells you it is centring rather than simply being too tall.
Seven unreachable controls, and one of them was RESUME
With a pad connected, you could pause the game and not un-pause it with the mouse. The gamepad settings section only renders when a pad is present, and it roughly tripled the panel’s height — so the defect was latent for the entire project and the previous module’s feature is what exposed it. No headless sweep would have found it, because nothing was connected.
The remedy is not a breakpoint
container justify-start overflow-y-auto
plate my-auto shrink-0
Auto margins absorb positive free space and resolve to zero when it is negative, so the panel centres while it fits and pins to the top the moment it does not. shrink-0 is load-bearing and easy to omit — without it a column flex item shrinks vertically and squashes instead of overflowing.
A magic number is only right where there is a real discontinuity
“Must always fit” is a continuous constraint, so the layout has to be elastic. The boss health bar is the counter-example that proves the rule: that one genuinely is discontinuous, and a breakpoint is the correct fix there.
The acceptance test that was wrong first
Its first version asserted bottom <= viewportHeight for every button. That is the right assertion for a container that cannot scroll and the wrong one for a fix whose entire point is that it scrolls — a 1,102 px panel cannot fit in 240 px and is not supposed to. It reported 33 failures on a build whose clipping was already gone. Rewritten to assert reachability: scroll to the control, then measure.
The structural guard, and what it admits it cannot see
A test walks every absolute inset-0 overlay and fails any that is a flex column centring content with no overflow-y-auto. Three things make it more than decoration:
it parses rather than greps GameUI.tsx documents this defect inside JSX comments using the
very class names under test, so a naive scan reads the postmortem
as markup
vacuity guard fails if it finds zero overlays — a rename must fail loudly
rather than pass by looking at an empty list
a built-in control a ten-overlay fixture asserting exactly three are flagged, so the
detector is proven able to say NO on every run
And it states its own limits: it reads class names off disk, has no DOM, and cannot know that the pause plate needed 1,082 px. It asserts the weaker structural claim, which is the right one to assert.
Tap targets, and why they were unaffordable until the layout was fixed
BEFORE RESUME 38 px · QUIT 38 · MUTE 27 · twelve pad controls 24–27
only 2 of 18 cleared 44 px
AFTER 18 of 18
Twelve controls at 44 px adds roughly 230 px to a panel that already could not fit and could not scroll. The accessibility fix was gated on the layout fix — you could not have done it first.
One detail with teeth
min-h-[44px] is an arbitrary value deliberately. This project’s Tailwind spacing unit is 3.25 px, not the default 4, because font-family: monospace on html makes Chrome apply a 13 px default font size. So min-h-11 would compute to 35.75 px and would silently miss the target it looks like it hits.
All of it was in the alpha channel
The logo ladder was 1,051,766 bytes across four rungs and had already been optimised once. It came down 43.3% with the colour encode bit-identical, because libwebp has a sharp discontinuity at alpha_quality <= 70 where it switches to a much cheaper path.
rung before after saved
800 138,692 82,192 -40.7%
960 190,362 109,450 -42.5%
1200 284,006 156,022 -45.1%
1600 438,706 248,790 -43.3%
RGB planes: byte-identical to what shipped before.
The measurement that nearly threw it away
Composited over the game’s own background, alpha_q70 shows 31.75% of pixels differing by more than 2/255, worst case 86. On its own that reads as unacceptable and the change gets reverted.
The control says otherwise:
| Encode | Comp mean | Max | > 2/255 |
|---|---|---|---|
| alpha_q100 — already shipped | 2.962 | 86 | 30.11% |
| alpha_q70 — the candidate | 3.131 | 86 | 31.75% |
The 30% and the maximum of 86 were already in the shipped file, from the lossy encode and the downscale. alpha_q70 adds +0.17 to the mean and does not move the maximum at all.
An absolute number is not a finding
Without a control you cannot separate damage you caused from damage that was already there — and here that mistake would have cost 43% of the payload. The 31.75% figure is true, was correctly measured, and means nothing on its own.
Two method notes that made the comparison honest. The shipped encoder settings were recovered first — q82 / alpha_q100 / m6 reproduces all four files byte-for-byte — which is what makes every later comparison like-for-like. And the result was inspected in two places: the densest soft-alpha window and the worst-error window, which are not the same crop.
The bill is not bandwidth
A static browser game served from Cloud Run costs 4.38 cents per thousand new players. The interesting part is not that figure — it is that the two numbers everyone reasons about, bandwidth and traffic, turned out to be the wrong ones, and the actual bill is build artefacts nobody deleted.
- Per 1,000 players
- 4.38¢new players, cold
- Per 1,000,000
- $45.25egress + compute
- Free tier covers
- 2,741players / month
- First load
- 0.3735MiB weighted mean
The number that governed the model for four revisions was wrong
“Which logo rung the modal player fetches” was CALCULATED through three revisions, on the reasoning that a phone at 393 CSS px times a device pixel ratio of 3 picks the 1200w rung. Then it was measured from Cloud Run logs, n = 349 over 7 days of launch traffic:
rung fetches share
800w 258 73.9%
1600w 83 23.8%
1200w 5 1.4%
960w 3 0.9%
The distribution is bimodal — 74% at the floor, 24% at the ceiling, almost nothing between. That is desktop at DPR 1 and retina, not a spread of phones. The model had been 8.4% pessimistic, and the intuition that phones are the expensive case did not survive contact with the logs.
Say the n and the window
This is a real measurement over seven days of launch traffic. It is not a stable one — the mix will move as the audience does. A distribution quoted without its sample size and its window is an assertion wearing a table.
The lever that was never there
“Standard Tier gives 200 GiB free against Premium’s 1 GiB” was carried as the largest open opportunity for three revisions. It is impossible, verified four independent ways:
ELIGIBILITY Standard is scoped to resources using regional external IPs.
Zero hits for Cloud Run or serverless in the tiers overview.
PRICING Cloud Run pricing states outbound transfer USES Premium. Declarative.
NO SURFACE no --network-tier flag on any gcloud run command, no networkTier API field
BILLING the Standard egress SKU group is 42 SKUs, every one Compute Engine
And the framing was wrong even conditionally: Standard is reachable only behind an external load balancer at roughly $18.25/month plus $0.008/GiB, which at 200 GiB is a wash against simply paying Premium.
The cost nobody was tracking is larger than the one everybody was
700 files · 182 images · ~36.4 GiB -> ~$3.86 / month
more than the egress cost of serving 83,000 players
four-fifths of it sits in a region the game does not deploy to
The bill for a static browser game is not bandwidth
It is the build artefacts nobody deletes and the idle instance nobody meant to leave running. --min-instances 1 would cost $10–15/month regardless of traffic — more than a quarter of a million players.
And the cleanup held
/volthaven_logo.png, the 1.93 MB master, was requested 145 times in 7 days and returns a 9-byte 404. Worth checking, because had it been serving, that is 280 MB — and it would have dwarfed everything else in the model.
Five failures with the same shape
The strongest recurring material in this project is not a rendering technique. It is a set of measurement failures that all rhyme, and every one of them was resolved the same way — by comparing against something already known to work, rather than by reasoning harder about the failure.
An instrument that encodes an assumption launders that assumption into data
A viewport probe hardcoded a 705 px requirement derived at desktop width, then printed a verdict against it on every device. The dimensions were genuinely measured. The verdict was arithmetic against a constant that does not transfer — and the whole readout was labelled MEASURED.
A metric with a noise floor above the effect size cannot disprove the effect
Checking animation frames for stalls by counting pixels that differed at all found none — because on dithered art the dither perturbs nearly every pixel regardless of motion. By magnitude, the stalls were 17 and 5 changed pixels against a median of 23,088. The first measurement did not refute the defect. It could not see it.
Confirm a failure with a second method before believing it
A build gate reported FAILED on a working fix because it asserted bottom:2rem against a Tailwind v4 build that emits calc(var(--spacing) * 8). Separately, images read as never-loading with the element 200 px inside the viewport — that was scrollIntoView() not driving the compositor. What settled it was that the pre-existing images failed identically, which ruled out the new markup.
A known-good control is cheaper than a debugging session
The DualSense that proved the gamepad bug was labelling. The alpha_q100 baseline that saved 43% of the logo payload. The ten-overlay fixture that proves the structural guard can still say no. Every one of the failures above was resolved by a control, not by analysis.
Say what a green tick measures
The overlay guard ships with a written statement of what it cannot see. The remapping panel’s test asserts the payload contains no transport claim. The cost model labels every figure MEASURED, CALCULATED or NOT VERIFIED, and names what would settle the unverified ones.
What this class has not verified
Stated plainly, because the alternative is the failure in the first paragraph. mapping !== 'standard' has never been observed on real hardware. Chrome OS gamepad behaviour was never captured. iOS Safari was not tested at any point in this project. And Volthaven 64 is N64-style — unlicensed, and it does not run on the hardware.
Judge six measurements
Six from the material above. Three are sound. Three are the kind that read as rigorous and settle nothing. Call each before you reveal it.
Does that measurement settle anything?
Six real measurements from this project's final phase. All six were carefully taken. Three of them cannot support the conclusion drawn from them.
A viewport probe reports each device's dimensions and prints PASS or FAIL against a 705 px requirement. The readout is labelled MEASURED.
The same gamepad prompts and probe are run on a DualSense as a control, and every face button lands correctly.
Animation frames are checked for stalls by counting pixels that changed at all between frames. No stalls are found.
A candidate image encode is rejected because 31.75% of composited pixels differ by more than 2/255, with a worst case of 86.
Standard network tier is ruled out as a cost lever on four independent grounds — eligibility scope, published pricing, absent API surface, and the billing SKU list.
A structural test walks every overlay in the source and passes, confirming none of them clips its content.
Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.
Everything above is in the build
Volthaven 64 runs in a browser with no install, no account and no payment. Five sectors, five bosses, keyboard or touch. Every technique in this class is visible in the first thirty seconds if you know what you are looking at.
The bottom line
Building something that looks like an N64 is not a filter you apply at the end. It is a stack of specific, cheap decisions — a small framebuffer, five bits a channel, a Bayer matrix indexed by texel, light baked into vertices, textures too small to be sharp, and fog standing in for a horizon. The hard part is not implementing any one of them. It is knowing which limitations belonged to the machine, which belonged to the television, and which belonged to a different console entirely — and being willing to measure rather than assume, even when the assumption is the one everybody makes.
Terms this class defines
- Volthaven 64
- Volthaven 64 is a rail-based action platformer in TypeScript on Three.js.
- Baked vertex lighting
- Baked vertex lighting evaluates a lighting model once per vertex at build time and stores the result in the mesh's vertex colour attribute, so at runtime the GPU only interpolates values it already has.
Questions
Did the Nintendo 64 have wobbly vertices like the PlayStation?
No. That wobble is a PlayStation 1 artifact and it is the single most common mistake in retro 3D work. The PS1 had no floating-point rasteriser, so vertex positions were quantised to integer screen coordinates and geometry visibly jittered as the camera moved. The Nintendo 64 had a floating-point Reality Signal Processor and did not do this. Volthaven 64 implemented snapping, measured it, and deleted it from both shaders: a Level 1 yaw pan went from 14 backward vertex steps totalling 2.52 pixels down to zero. The N64 look comes from low resolution, 16-bit colour, ordered dithering, trilinear filtering on tiny textures, and heavy fog, not from jitter.
What resolution did the Nintendo 64 actually render at?
Most N64 titles rendered at 320x240 and some used 640x480 in high-resolution mode. Volthaven 64 renders to an offscreen target whose short axis is pinned to 240 and whose long axis follows the window's aspect ratio, so on a 16:9 display it is 426x240 rather than 320x240. That target is then upscaled to the browser window with CSS image-rendering set to pixelated, so every source pixel becomes a hard square with no interpolation. Pinning only the short axis is a deliberate departure: it keeps the pixel grid square on modern widescreen displays instead of stretching a 4:3 image.
What colour depth did the N64 framebuffer use?
The Nintendo 64's 16-bit framebuffer mode is RGBA5551: five bits each for red, green and blue, and one bit of alpha or coverage. It is not 5-6-5, which is a common misremembering borrowed from other 16-bit hardware. Volthaven 64's own source calls it 5-6-5 in 18 separate comment sites across 12 files, and that wrong term became the calibration reference for three art tickets. The quantisation in the blit shader multiplies by 31, 63 and 31, so the implementation is 5-6-5 in fact as well as in name. A wrong word propagates further than a wrong line of code, because nobody writes a test for vocabulary.
Why does dithering matter for a retro look?
With only 32 levels per colour channel, any smooth gradient collapses into visible bands. Ordered dithering breaks those bands by adding a small position-dependent offset before quantisation, trading a banded surface for a fine checkerboard the eye reads as an intermediate shade. Volthaven 64 uses the canonical 4x4 Bayer matrix, verified cell by cell, and indexes it by the source texel rather than by screen position. Indexing by texel is what keeps the pattern locked to the surface as the camera moves instead of crawling across the screen, and it is the detail most implementations get wrong.
What is vertex-baked lighting and why use it?
Baked vertex lighting evaluates a lighting model once per vertex at build time and stores the result in the mesh's vertex colour attribute, so at runtime the GPU only interpolates values it already has. The N64 had no pixel shaders and very little fill-rate budget, so per-vertex Gouraud shading was the practical ceiling. Volthaven 64's baker evaluates a five-term model per vertex and writes it into vertex colours, which is also why its levels can carry large amounts of decor geometry without a per-frame lighting cost. The visible trade is that light does not move; the win is that it costs nothing to draw.
How big can an N64 texture be?
The Reality Display Processor had 4 KB of texture memory, so a 16-bit texture could be at most about 32x64 pixels in a single load, and 64x64 four-bit textures were common. This is why N64 surfaces are large flat planes wearing small blurry textures rather than detailed maps. Volthaven 64 generates its textures procedurally at 64x64 in JavaScript rather than shipping image files, which keeps the bundle small and makes the constraint explicit. Procedural generation carries its own trap: drawing from a shared global random source makes texture output depend on generation order.
Why is there so much fog in N64 games?
Fog is a draw-distance hider. The console could not render distant geometry cheaply, so the far plane was pulled in close and fog was used to make objects fade out before they popped out of existence. It reads as atmosphere and functions as a performance budget. Volthaven 64 uses three layered fog bands plus a sky dome and an arithmetic guard against popping, with the far plane locked at 100 units and the fog parameters adapting rather than the plane moving. Each of the five levels has its own graded fog palette, which is the main tool making them feel like different places.
Should textures be filtered or left sharp for a retro look?
Filtered, if you are targeting the N64 specifically. This is the second most common mistake after vertex snapping. The N64 applied three-point filtering and mipmapping, so its textures looked soft and blurry rather than sharp and pixelated; sharp nearest-neighbour texels are a PlayStation and PC look. Volthaven 64 uses trilinear filtering with mipmaps and measured the result: 11.7 times less shimmer in the far field than the unfiltered alternative. The pixelation in the final image comes from the low-resolution framebuffer being upscaled, not from the textures themselves.
How do you make a game soundtrack without shipping audio files?
Synthesise the instruments in code and sequence them as note events, which is what cartridge-era consoles did because they had no room for streamed audio. Volthaven 64 synthesises 11 instruments in JavaScript at 22,050 Hz with 4-bit quantisation, packing to 22,563 bytes and adding zero bytes to the bundle. A sequencer schedules beat-domain note events with an adaptive lookahead against the audio clock, and a voice allocator manages 24 simultaneous voices with a quietest-then-oldest stealing policy and a 6 millisecond fade. Fifteen pieces of music exist, all 32 beats long.
How do you make a synthesised loop seamless?
Choose the loop length first, then constrain every partial in every instrument to an integer multiple of the sample rate divided by that length. Every component then completes a whole number of cycles across the loop, so the last sample joins the first at a zero crossing by construction and the seam is bit-exact rather than crossfaded. Volthaven 64 builds attacks by running the same additive summation at negative frame indices, so the attack transient is part of the same continuous function rather than a spliced envelope. This is cheaper and more exact than any fade-based approach.
What is the hardest part of building a retro-style game to feel authentic?
Deciding which limitations to keep. Every constraint you emulate costs you something a modern engine gives away, and some constraints produce the look while others only produce the frustration. Volthaven 64 kept the low framebuffer, the colour depth, the dithering, the tiny textures, the baked light and the fog, because those produce the image. It rejected vertex snapping as belonging to a different console, and shipped CRT scanlines as a toggle defaulted off on the reasoning that a scanline is a display artifact rather than a console one. The discipline is separating the hardware from the television it was plugged into.
Is Volthaven 64 free to play and is this class free?
Both are free. Volthaven 64 runs in a browser with no install, no account and no payment, and this masterclass has no signup, no email capture, no paywall and no certificate. The game is five sectors with five bosses, built in TypeScript on Three.js and covered by 359 test cases across 10,365 lines of test code. Every figure quoted in this class is measured from the game's own repository and cited to a file, including the defects, of which several are still open and named on this page.