Thomas Verhave

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:

What building it taught me

A build-story · see the live project → · more writing →