Writing · Build Notes
I built a full 3D FPS in a single HTML file — no build step, no assets, everything procedural
Murkfall is a complete first-person shooter — maze levels, three enemy types, a boss with an enrage phase, a minimap, persistent high scores — that ships as one index.html file.
Murkfall is a complete first-person shooter — maze levels, three enemy types, a boss with an enrage phase, a minimap, persistent high scores — that ships as one index.html file. No build step, no npm, no bundler, and zero downloaded assets. Every texture is painted on a <canvas> at load, every sound is synthesized with the Web Audio API, and Three.js comes from a CDN via an import map.
You can read the whole game by opening one file. That constraint drove every interesting decision in it.
Why one file, zero assets
I wanted a game I could host anywhere by copying a single file — no asset pipeline, no CORS wrangling, no "download the textures" step. That rules out image and audio files entirely, which sounds like a limitation until you realize it forces everything to be generated, and generated things are tiny, tweakable, and never 404.
Three.js loads with nothing but an import map — no bundler needed:
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from "three";
// ...the entire game...
</script>(One gotcha worth flagging: ES modules don't run over file:// in Chrome, so you have to serve it over http — python3 -m http.server is enough. Firefox will open the file directly.)
Textures painted at runtime
Every surface — walls, floor, enemies, the boss — is a canvas drawn in JavaScript and handed to Three.js as a texture. A wall is a base fill, some noise, and a few grime streaks:
function makeWallTexture() {
const c = document.createElement("canvas");
c.width = c.height = 128;
const g = c.getContext("2d");
g.fillStyle = "#1a1d24"; g.fillRect(0, 0, 128, 128);
for (let i = 0; i < 400; i++) { // noise speckle
g.fillStyle = `rgba(0,0,0,${Math.random() * 0.15})`;
g.fillRect(Math.random() * 128, Math.random() * 128, 2, 2);
}
return new THREE.CanvasTexture(c);
}Change two numbers and the whole game re-skins itself. No round-trip to an art tool.
Sound with zero audio files
Same philosophy for audio: there are no .wavs. A gunshot is a burst of filtered noise with a fast decay envelope, built live:
function shoot() {
const t = ctx.currentTime;
const src = ctx.createBufferSource();
src.buffer = noiseBuffer; // pre-made white noise
const filter = ctx.createBiquadFilter();
filter.type = "bandpass"; filter.frequency.value = 900;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.4, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15); // snappy decay
src.connect(filter).connect(gain).connect(ctx.destination);
src.start(t);
}Reloads, hits, enemy deaths, the boss's projectiles — all a handful of oscillators and envelopes.
The game underneath
Once the engine is procedural, the actual game is just rules:
- Movement — a maze built from a tile map, with wall-slide collision, gravity, and jumping.
- Shooting — hitscan via a Three.js
Raycaster, with a muzzle flash, a one-frame point light, and impact sparks. - Three enemy types that path toward you and deal contact damage: red grunts (baseline), fast fragile acid-green wisps that swarm from corridor 2, and slow tanky amber brutes that hit hard from corridor 4.
- Five escalating corridors + a boss finale. A hand-tuned spawn plan ramps the enemy mix and HP each round; clearing all five charges five ◆ Power Cells that unseal the Heart of the Silo.
- The Architect-Vane boss (wave 6): a giant Murk-drone with its own health bar that lobs projectiles you have to dodge — and enrages below 33% HP, moving faster and firing a three-shot spread.
- A live minimap, and a high score persisted to
localStoragewith a "★ NEW BEST ★" flag.
What building it taught me
- A hard constraint is a creative engine. "No files" sounds restrictive, but procedural textures and synthesized audio made the whole thing tunable in a way an asset pipeline never is — every visual and every sound is two or three numbers you can nudge live.
- Web Audio is a synthesizer hiding in your browser. Once you think in oscillators, noise buffers, filters, and gain envelopes, you stop needing sound files at all.
- "One file" is a feature, not a stunt. It deploys by copying, it can't half-load, and the entire thing is legible top to bottom.
A build-story · see the live project → · more writing →