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 19 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.
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 TWICE (direct + buffered hop) · 40 dmg
Arc Tether L cost 0 · gated on charge >= 20 and NEVER DEDUCTED
Discharge Nova I cost 50 · deducted · 30 dmg · radius 12
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. 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 19 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 19 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 19 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.
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.
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 19 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.