The previous post covered how the model produced this game in 20 minutes. This one is about the code: a 238.7 KB HTML file, 6,221 lines, with the rendering written entirely in WebGL2 — no framework, no engine, no external assets. It's worth reading because it implements every core piece of voxel rendering end to end, at a size you can actually finish.
| Module | Lines | Responsibility |
|---|
| 01_util.js | 203 | Math, noise, 4×4 matrices, frustum |
| 02_blocks.js | 179 | Registry for 69 blocks and 111 items |
| 03_textures.js | 728 | Procedural textures, 128×128, one array layer each |
| 04_worldgen.js | 624 | Terrain, caves, ore veins, trees, water, villages, chests |
| 05_renderer.js | 756 | WebGL2 renderer, chunk meshing, water |
| 06_render_pass.js | 376 | Shadow map, HDR, post-processing |
| 07_icons.js | 564 | 111 item icons drawn with Canvas2D |
| 08_player.js | 406 | Physics, collision, ray picking |
| 09_entity_render.js | 321 | Villagers, dropped items, magic orbs |
| 10_ui.js | 460 | Inventory, 36 recipes, chest UI |
| 11_audio.js | 190 | WebAudio synthesised effects |
| 12_main.js | 1101 | Main loop, input, quests, day/night, save |
Lines per module (5,908 total)
12_main1101
05_renderer756
03_textures728
04_worldgen624
07_icons564
10_ui460
08_player406
06_render_pass376
09_entity_render321
01_util203
11_audio190
02_blocks179
Chunk meshing: submit only visible faces
The world is 192 × 96 × 192, cut into 16×16 chunks at full height. Each chunk produces two vertex buffers — opaque and water — so transparency can be sorted separately from solids. While meshing, every voxel checks its six neighbours and only emits a face when the neighbour is air, water or a plant. All interior faces are dropped, which is the cheapest and most effective optimisation in voxel rendering.
AO and lighting aren't post-multiplied — they're packed into a vertex attribute. A single aLight carries three channels: sky light, vertex AO and block emission. The fragment shader just reads them:
float ao = clamp(vLight.y, 0.0, 1.0);
float sh = shadowFactor(n, l);
vec3 lighting = diffuse + ambient * (0.55 + 0.45*ao);
Each of the four corners of a face samples its own AO, so corners darken naturally. No screen-space AO pass, no extra cavity shading — the cost is a slightly larger vertex stream.
Shadows: a 2048² map with 3×3 PCF
The sun is the only directional light. A depth pass renders the whole terrain into the shadow map, then the main pass samples a 3×3 kernel with offsets to soften edges. The size is a constant:
const SHADOW_SIZE = 2048;
The day/night cycle drives sun direction, light colour and ambient colour, and shadows follow. At night direct terms go to zero and only moonlight ambient remains, which is why the ground never goes pure black.
HDR, Bloom, ACES
The scene renders into an RGBA16F FBO, falling back to RGBA8 when float textures aren't available. Then a bright pass, a two-pass Gaussian for Bloom, ACES tone mapping, vignette and grain, and finally FXAA before present. The whole chain is a handful of full-screen triangles, so it's cheap.
Water and sky are computed
Water is one shader with two branches: above the surface it mixes reflection by Fresnel, stronger at grazing angles, with alpha following the same curve; below the surface the whole screen switches to a blue-green underwater fog that deepens with distance. The sky is a full-screen pass: horizon-to-zenith gradient, sun halo and disc, stars at night, and noise-driven clouds that drift and shift colour over time.
Textures: not a single image file
Block textures use TEXTURE_2D_ARRAY with one 128×128 layer per block — no atlas seams and no UV offset math. Every layer is drawn with Canvas2D: base gradient, multi-octave noise, stains, highlight specks in ore veins. The result goes straight into texImage3D, with mipmaps and anisotropic filtering enabled.
The same goes for the 111 item icons — 07_icons.js is nothing but paths and gradients. The upside: recolouring is a number change. The downside: when an icon looks wrong, the only fix is nudging Bézier control points; there's no art to swap in.
Audio is synthesised too
Mining, placing, opening chests and pickups are all WebAudio oscillators plus envelopes computed at runtime — no audio files. Saving is a seed and an edit list in localStorage; on reload the world is rebuilt from those.
Three real bugs
- The held item was a WebGL sprite and simply never showed up. The fix was a DOM overlay with an img that sways with the camera — in first person you can't tell.
- Health hearts were inserted as SVG via innerHTML and rendered as the letter 'b'. SVG needs createElementNS + setAttribute; the bug is sneaky because the markup looks perfectly fine.
- Deleting a chunk threw 'm.bufs is not iterable'. A chunk mesh is {opaque, water}; cleanup iterated only one of them, leaving the other unfreed.
When to hand-roll, when not to
Hand-writing made sense here because of three constraints: offline, single file, double-click to open. Under those constraints it's less work than an engine, because there's no bundling and no load failures to handle. If you need PBR materials, skeletal animation, a scene editor or a post-processing stack, don't hand-roll it — use an engine, or you'll spend the time rebuilding what already exists.
NotesLine counts and size are measured locally (5,908 lines of source, 6,221 in the built file, 238.7 KB). Headless SwiftShader runs at 1–2 FPS — normal for software rendering, and not indicative of real frame rates.