Skip to the summary
Class 86 · Mastery · Free, no signup

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.

4h read Advanced 19 techniques 5 interactive exercises 26 pieces of measured evidence Updated 24 Aug 2026

Play Volthaven 64Start with the mistake

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

  1. Vertex snapping is the wrong console. Removing it took a Level 1 camera pan from 14 backward vertex steps (2.52 px) to zero.
  2. 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.
  3. 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.
  4. Dither before you quantise, and index by texel — not by screen position, or the pattern crawls across the surface as the camera moves.
  5. The whole soundtrack is 22,563 bytes and adds nothing to the bundle. Eleven instruments synthesised in JavaScript, 15 pieces, all 32 beats.
  6. Loops are bit-exact, not crossfaded. Pick the loop length first, then force every partial to an integer multiple of rate / L and the seam lands on a zero crossing by construction.
  7. 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.
  8. 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.
Interactive · Watchvideo

Watch the class videos

The videos that go with this class, newest first. Press play to load one from YouTube's privacy-enhanced player.

Every Ability Has a Price. Above 60 Charge, You Leak.Uploaded 2026-09-08. Nothing is requested from YouTube until you press play.
Transcript
That is a real browser game running right now. No install, no account, nothing to buy. And it has an economy in it. Not a shop. An economy. Every ability in this game has a price, you earn the currency by fighting, and if you sit on a pile of it, it leaks away. That last part is not a bug. It is the whole design. The game's own operation manual says it in five words. Charge is the whole game. You start every sector with fifty of it and it caps at one hundred. Everything except moving, jumping and the basic arc strike is bought with charge. That is an unusual choice. In most action games your abilities are free the moment you unlock them. Here is the income side, straight from the manual. Two charge for every melee hit that actually lands. Twenty five for every enemy you destroy. Forty for a capacitor, the green cube, and it comes back about twenty seconds later, so it is a well rather than a one time pickup. And one per tick while you stand in a conduit field. Every one of those requires you to be doing something. And here is the spend side. Fifteen for a surge dash, twenty for an arc tether, thirty for the overcharge slam, fifty for a nova. Four of the five abilities cost charge. The fifth, the arc strike, is free, and everything else in the game is built on top of it. Look at what that pairing does. The free move is also the move that pays you. The whole teardown of this game is a free class on the site, no signup and no email, and the game itself is free to play in your browser at the link. Nothing on either page asks you for money. Now the part I actually wanted to talk about. Above sixty charge you leak a little every tick. Not a lot. Enough. That single rule turns the whole resource inside out. A meter you can bank is a meter you save for an emergency. A meter that drains while you hold it is a meter you have to spend, and spending it means using abilities, and using abilities means engaging. The manual is also honest about how the movement works, which is rarer than it should be. Volthaven is built on a rail. The player moves along a fixed spline rather than roaming freely. That is a constraint, and the reason for it is stated plainly. A fixed path is what lets a browser game hold a stable frame rate, because the engine always knows what is about to be on screen. The manual gives you the diagnosis too. If a fight is going badly, it says, the answer is usually that you are not hitting anything, because every connected strike pays you back. That is a real design statement. Missing is not just a lost opportunity, it is a funding problem. Your offence is your income, so playing cautiously is the thing that starves you. So there are three pressures on one number. You earn by engaging. You spend on the four abilities that cost anything. And you leak whatever you try to hoard. The player is not managing a bar, they are managing a rate. And all three of those pressures push the same direction, which is toward staying in the fight instead of backing out of it. The structure around it is five sectors and five bosses. Each sector hands you one new ability about forty percent of the way in, and that ability is the key to the boss at the end of that same sector. The overcharge slam is the clearest example. It costs thirty charge, and it is the only thing in the game that breaks the Cantor's shield. Failure is priced the same careful way. There is no life counter. Every sector has checkpoints. And a boss keeps the damage you have already dealt it, so a fight you lose is still progress. Dying costs you time and nothing else. That is a deliberate decision about what the game is allowed to take from you, and it is the same instinct as the charge leak. Punish standing still, not failing. For the size of that design, here is what actually ships. First load is about a third of a megabyte on a phone or a standard desktop, and just under half a megabyte on a high density desktop, which pulls a larger title image. Every figure on the class page was measured against the live build rather than estimated, and that distinction is the reason I trust it. The soundtrack is roughly seven kilobytes. Ten pieces plus five boss variants. It is that small because it is sequenced rather than streamed. What ships is a list of notes and a way to make a sound, and the instruments are generated by code when the page loads. You are downloading the description of the music, not a recording of it. If you take one thing from this, take that. Price every verb in your game, then fund the ones you want the player to use and tax the ones you do not. Volthaven wants you aggressive, so aggression pays and caution leaks. You can go argue with that design yourself in about fifteen minutes, in a browser tab, for nothing. Links are below.
Your Retro 3D Is a PlayStation. The N64 Never Wobbled.Uploaded 2026-09-01. Nothing is requested from YouTube until you press play.
Transcript
Fourteen times in a single camera pan, a vertex stepped backwards against the direction the camera was moving. Two point five two pixels of reverse motion. That is the wobble everybody reaches for when they want a retro three D look, and it is the wrong machine. The PlayStation had no floating point rasteriser, so it quantised vertex positions to integer screen coordinates and the geometry visibly crawled. The Nintendo sixty four had a floating point signal processor and did not do that. If your retro game jitters, you built a PlayStation. This is class eighty six from the academy, a complete teardown of Volthaven sixty four, a playable N sixty four style game running in a browser. Nineteen techniques, five interactive exercises, and twenty six separate pieces of measured evidence. Not opinions about what looks retro. Screenshots and numbers, before and after, for every claim. I am going to give you the six that actually matter. Six things make the look, and jitter is not one of them. A low resolution framebuffer upscaled with hard pixels. Sixteen 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. Volthaven implemented snapping first, like everyone does, then measured it, proved it belonged to the other console, and deleted it from both shaders. The full class is on the site now. Nineteen techniques, five exercises you can run in the page, and the game itself is free to play in your browser. No signup, no email, no paywall. Link is in the description and pinned in the comments. Here is how that gets settled. Same pan, same geometry, snapping on and snapping off, and you count the vertices that move backwards. Fourteen against zero. Then the same discipline on textures. Everyone sharpens texels because pixels feel retro, but the console filtered its textures, and trilinear filtering with mipmaps measured eleven point seven times less shimmer in the far field. The pixels you remember come from the framebuffer, not from the texture. The sixteen bit framebuffer mode is R G B A five five five one. Five bits each of red, green and blue, and one bit of alpha. It is not five six five. That is different hardware. This codebase says five six five in eighteen separate comments across twelve files, and the reason that matters is not tidiness. Those comments got used as the calibration reference for three art tickets. A wrong comment is not a small thing when somebody downstream treats it as the spec. With five bits a channel, any smooth gradient collapses into visible bands. Ordered dithering adds a small position dependent offset before quantisation, so the surface resolves into a fine checkerboard your eye averages back into the shade that was missing. Two things decide whether it works. Dither before you quantise, not after. And index the pattern by texel rather than by screen position, because if you index by screen the pattern crawls across the surface every time the camera moves. Fog is not atmosphere. It is the draw distance. The console could not afford distant geometry, so the far plane came in close and fog faded things out before they popped. It reads as mood and functions as a performance budget. It is also the cheapest way to make five places feel like five places, which is why it was worth measuring. Before grading, three of the five sector fog colours arrived at the framebuffer as three different blacks. Nominally distinct. Visually identical. The strongest identity tool in the game was doing nothing on three of the five levels. Fifteen pieces of music in twenty two thousand five hundred and sixty three bytes. Eleven instruments generated in JavaScript at twenty two thousand and fifty hertz, four bit quantised, adding zero bytes to the shipped bundle. And this is the part I did not know before this project. The N sixty four had no dedicated sound chip at all. Audio ran on the signal processor as a microcode task, competing with graphics for the same silicon. Every voice you played was frame time you did not spend rendering. The design is answering a real constraint, not imitating a vibe. The whole thing is thirty five thousand seven hundred ninety one lines across eighty one files, covered by three hundred fifty nine test cases and ten thousand three hundred sixty five lines of test code. That is one point two lines of test for every line of game. Five sectors, five bosses, a fixed rail, rendering at four hundred twenty six by two hundred forty. And the evidence programme produced one thousand one hundred sixteen usable before and after pairs, of which eight were thrown out because their own README declared them manufactured. Throwing those out is the part that makes the other eleven hundred worth anything. The strongest thing in this class is not a rendering technique. It is a set of measurement failures that all rhyme. A probe that hardcoded a number derived at desktop width, then printed a verdict against it on every device, and labelled the whole readout measured. The dimensions were real. The verdict was arithmetic against a constant that does not transfer. Every one of those failures got resolved the same way, by comparing against something already known to work, rather than by reasoning harder about the failure. So if you take three things. Render small and upscale hard. Filter your textures instead of sharpening them. And delete the vertex snapping, because it is the other console. The game is free to play in your browser with nothing to install, and the full class walks all nineteen techniques with the evidence attached. No signup, no email, no paywall. Take what is useful.

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.

Module 00 · The wrong console

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:

Side-by-side comparison of vertex snapping on the left and off on the right Snap ON — PS1 Snap OFF — N64
The still half of the evidence. Left column snaps vertex positions; right column does not. The geometry is identical — only the rasterisation rule differs. Frame 00 of the shipped Level 1 yaw pan.
3.3 seconds, and the whole argument. The same pan with snapping on (left) and off (right). Watch the edges of the track. On the left, vertices step backwards 14 times across the pan, totalling 2.52 px of reverse motion. On the right: zero. Video has no audio.

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.

Module 01 · What we built

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.

Volthaven 64 gameplay: a curved gantry over blue water with the player mech, HUD panels and a wireframe checkpoint ring
Cistern Daylight, Sector 3. Curved gantry over water, the player mech, HUD chassis panels, and a green wireframe checkpoint ring. Every technique in this class is visible in this one frame.
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
The five sectors with rail length, gaps, granted ability and boss
#SectorRailGapsGrantsBossHP
1Industrial Rail179.475 @ 1.70—Warden300
2Factory155.984 — two dash-only, 6.70 & 8.70DashCoilmaster450
3Flood591.31zeroSlamCantor500
4Void225.333 @ 7.70TetherAeros550
5Spiral412.966 @ 8.85NovaAscendant900

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

Costs, measured — not as documentedsrc/entities/Player.ts
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:

Two sites, mutually exclusive pathssrc/entities/Player.ts
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.”

Module 02 · Resolution

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:

The short axis is pinned; the long axis follows the windowsrc/render/N64Pipeline.ts
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:

Without this, every pixel is smoothed away by the browsersrc/App.tsx:41
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.

Module 03 · Sixteen-bit colour

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.

The quantiser, in the blitsrc/render/N64Pipeline.ts
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.

Interactive · Huntround 1 of 3

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.

Round 1 — the renderer contextAll flags granted
Checked: getContextAttributes() reports the requested flags

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

Module 04 · Dithering

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.

Comparison: flat banded blue on the left, 4x4 Bayer dithered gradient on the right Dither OFF Dither ON
The whole technique in 5.7 kilobytes. Left: hard bands where the quantiser rounded a smooth gradient. Right: the same gradient with a 4×4 Bayer offset applied before quantisation. Same bit depth, same colours available — the difference is entirely where the rounding error is placed.
The canonical 4×4 Bayer matrix, verified cell by cellsrc/render/N64Pipeline.ts
  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.

Module 05 · Baked light

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.

The player mech before and after texturing and vertex baking Before After
The player character, un-surfaced and surfaced. Before: a dark navy silhouette with black holes at every joint. The joint material measured (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.

The Ascendant boss with an unlit material The Ascendant boss with the N64 material, baked light and fog Unlit basic material N64 material + bake + fog
Unlit Lit
Why one unlit material ruins a graded scene. Before: flat red and black with no shading at all, sitting outside the level's tonal range and ignoring its fog. After: shaded, fog-integrated, and still unmistakably red. An unlit material does not just look wrong — it refuses to participate in the scene's colour grade.

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.

Module 06 · Textures

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.

Measured, not assumedtrilinear + mipmaps vs unfiltered
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.

Interactive · Decidercase 1 of 6

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.

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

Module 07 · Fog

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.

All five levels before fog grading, shown at 240p All five levels after fog grading, shown at 240p Before grading After grading
Before After
All five sectors, both arms, at the resolution they ship at. Sectors 2 and 4 change most. Before grading, three of the five fog colours reached the framebuffer as three different blacks — nominally distinct, visually identical. Fog is the strongest per-level identity tool in the game and it was doing nothing on three of them.
The fog stacksrc/render/
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.

Module 08 · Effects

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.

The Discharge Nova effect before and after being put through the N64 pipeline Additive bubble Dithered, fog-graded
Area down, impact up. Before: a soft white additive bubble covering 19.48% of the frame at a contrast of 13.9. After: a hard-edged, dithered, fog-graded shockwave disc covering 7.41% at a contrast of 66.7. It occupies a third of the screen space and hits nearly five times harder.

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.

The Arc Strike effect before and after: a translucent cone becomes an opaque bladed crescent
Discrete cels read as speed. Before: a translucent cyan cone, indistinguishable from the player’s own charge glow. After: an opaque bladed crescent with trailing multiple-exposure poses. Max luma 54.5 → 175.9. Opacity is a hardware argument — on a machine with no alpha budget, things were either drawn or not drawn.

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?

Interactive · Ledgerclaim 1 of 7

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

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

Module 09 · Frame pacing

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:

What frame-domain logic buys and costssrc/core/Engine.ts, src/entities/
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.

Module 10 · The soundtrack

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.

Interactive · Anatomyregion

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.

Module 11 · Reading a boss

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.

The five bosses before redesign, shown at true relative scale The five bosses after redesign, shown at true relative scale Before After
Before After
All five bosses, one camera, true relative scale. Before: a circle, a blob, another blob, three specks and a slab. After: five readable set-piece machines. Cantor’s silhouette overlap against the rest of the roster fell from an IoU of 0.974 — effectively interchangeable — to 0.403.

Silhouette first, everything else second

The same discipline applies to the ordinary enemies, and the numbers there are more extreme:

The five enemy types after redesign, as distinguishable silhouettes
The enemy roster, after. Before this pass the five enemies were — literally — a house shape, a donut, a box, a bowl and a cone. The whole sheet is 7.7 KB.

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.

Cantor's telegraph before and after, captured at native 240p in real fight framing
Cantor’s telegraph, at the resolution it ships at. Left: almost nothing. Right: three stacked shockwave rings. Screen coverage went from 0.092% to 20.863% — a 227× increase. Captured in real fight framing at native 240p, not as a zoomed hero shot, because a tell that only reads when you zoom in does not read.

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

Each boss with its HP, telegraph and punish window
BossHPTelegraphThe window
Warden30030-frame windup, real hitbox drawn on the floorAfter the slam lands
Coilmaster450Fixed rhythm cycle, 330 / 293 / 255 frames90 frames, every cycle
Cantor500Three stacked rings before the dropImmune except a 120-frame stagger, opened only by slamming from above
Aeros550Dive, targeted on the player’s position at frame 44120-frame grounded recovery — the only one
Ascendant900Vertical column — the one non-floor tellThree HP-gated bodies over the same rhythm
Module 12 · What the tests caught

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

Level 3, the flood ratesrc/world/Level3.ts
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.

Why, measured across the runsheadless playtest
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

One Level 5 Coilmaster spawnsrc/world/Level5.ts
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.

Module 13 · The button that said A

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.

The binding — correct, and unchanged by the fixInput.GAMEPAD_BUTTONS
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:

What each face button did versus what the manual promised, on an SN30 Pro
Player pressesGetsManual promised
A (right face)SLAMjump
B (bottom face)JUMPslam
X (top face)NOVAarc strike
Y (left face)ARC STRIKEnova

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.

Measured gamepad capabilities across two real controllers
SN30 Pro (USB, X-input)DualSense (Bluetooth)
idXbox 360 Controller…Wireless Controller (Vendor: 054c)
mappingstandardstandard
buttons / axes17 / 418 / 4
vibrationActuatortrue, dual-rumblefalse
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.

Module 14 · The same layout bug, five times

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.

Five instances, one shapeoverlay audit
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

Elastic, not conditionalthe overlay pattern
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:

Why this guard is trustworthyoverlay structural test
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

Touch target compliance, pause panelmeasured
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.

Module 15 · The 43% nobody was looking for

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.

The ladder, before and afteralpha_q100 → alpha_q70
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:

Composited difference, shipped encode versus the cheaper alpha path
EncodeComp meanMax> 2/255
alpha_q100 — already shipped2.9628630.11%
alpha_q70 — the candidate3.1318631.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.

Module 16 · What it costs to run

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:

Logo rung fetches — MEASURED, n = 349 over 7 daysCloud Run logs
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:

Four lines of evidence, all pointing the same wayverification
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

The actual billArtifact Registry
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.

Module 17 · The method

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.

Interactive · Decidercase 1 of 6

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.

Your answers stay in this browser. Nothing is sent anywhere. Reset clears them.

Play it

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.

Volthaven 64 running in portrait on a Pixel 7, with touch controls and full HUD
Portrait, on a Pixel 7. Full HUD chassis panels, touch controls, and the water sector rendering at a 240-pixel short axis in portrait — which is where pinning the short axis rather than hardcoding 320×240 earns its keep. Real device-profile capture, not a resized desktop shot.

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

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.