DEV Community

Cover image for I Built a Doom Clone in One HTML File
Martin Patino
Martin Patino

Posted on • Originally published at martinpatino.com

I Built a Doom Clone in One HTML File

The first version of Cthulhu just walked into a wall and stayed there. Six tentacles flailing, third eye glowing, zero threat. I'd spent two days on the rendering — bezier curves, phase transitions, attack patterns — and the thing couldn't navigate around a pillar.

That's how most of Hell Crawler went. Build something that looks right, realize it doesn't work right, tear it apart, rebuild. The game you can play here is the version where things finally came together.

What the game actually is

Clear every room. Kill every demon. Save your family.

Yeah, the story is ridiculous. It's a top-down dungeon shooter — 7 procedurally-linked rooms, 9 enemy types, weapons ranging from a pistol to a chainsaw, a vehicle combat level, a bomb defusal level, and a Cthulhu boss fight at the end. The whole thing lives inside a single <script is:inline> tag in an Astro page. About 3,500 lines of vanilla JavaScript hitting the Canvas API directly.

No build step, no game engine, no npm packages. I wanted to see how far raw fillRect and arc calls could take me.

┌─────────────────────────────────────────────┐
│  take-a-break.astro                         │
│                                             │
│  1. CONFIG          Tunable constants       │
│  2. AUDIO           Synthesized SFX         │
│  3. MAP GENERATION  Procedural rooms        │
│  4. GAME STATE      Player, enemies, etc.   │
│  5. INITIALIZATION  New game / room setup   │
│  6. INPUT           Keyboard handling       │
│  7. UPDATE          AI, collisions, physics │
│  8. RENDERING       Tiles, sprites, HUD     │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

How rooms are built

Each room is a 21x15 tile grid. Tiles are integers — 0 for floor, 1-5 for wall variants. The generator starts by walling off the border, then punches holes wherever the room graph says an exit should be:

function generateRoom(roomIndex) {
  var map = [];
  for (r = 0; r < ROWS; r++) {
    map[r] = [];
    for (c = 0; c < COLS; c++) {
      // Border walls, everything else is floor
      map[r][c] = (r === 0 || r === ROWS - 1
                || c === 0 || c === COLS - 1) ? 1 : 0;
    }
  }

  // Punch holes for exits based on the room graph
  var exits = game.roomGraph[roomIndex].exits;
  if (exits.left !== undefined) {
    map[6][0] = 0; map[7][0] = 0; map[8][0] = 0;
    map[6][1] = 0; map[7][1] = 0; map[8][1] = 0;
  }
  // ... right, up, down exits follow the same pattern
}
Enter fullscreen mode Exit fullscreen mode

The 5 wall types each get a base color, top edge highlight, and shadow line. Three values per tile type, drawn as stacked rectangles — that's all it takes to fake depth without sprites:

var WALL_COLORS = {
  1: { base: '#5a2020', top: '#6b2828', line: '#3a1010' }, // Red stone
  2: { base: '#3a3a44', top: '#4a4a55', line: '#2a2a33' }, // Grey stone
  3: { base: '#4a3018', top: '#5a3820', line: '#2a1808' }, // Brown wood
  4: { base: '#8a1010', top: '#aa2020', line: '#550808' }, // Blood red
  5: { base: '#282838', top: '#383848', line: '#181828' }, // Dark metal
};
Enter fullscreen mode Exit fullscreen mode

Four room layouts get shuffled per run and assigned to rooms 0-3, so you might get a pillar maze first or a wall-block grid. Small thing, but it keeps the early game from feeling stale on repeat plays.

Movement and collision

I went through three collision systems before landing on the 8-point check. The first attempt only tested corners — the player could walk halfway through narrow walls. The second added edge midpoints but broke diagonal movement. The final version checks corners plus the center of each edge:

function isBlocked(x, y, w, h) {
  var map = game.maps[game.currentRoom];
  return isTileBlocked(x, y, map)             // top-left
      || isTileBlocked(x + w, y, map)         // top-right
      || isTileBlocked(x, y + h, map)         // bottom-left
      || isTileBlocked(x + w, y + h, map)     // bottom-right
      || isTileBlocked(x + w / 2, y, map)     // top-center
      || isTileBlocked(x + w / 2, y + h, map) // bottom-center
      || isTileBlocked(x, y + h / 2, map)     // left-center
      || isTileBlocked(x + w, y + h / 2, map);// right-center
}
Enter fullscreen mode Exit fullscreen mode

Hell Crawler gameplay showing the player navigating a dungeon room with enemies and projectiles

Diagonal movement gets the 0.707 normalization so you don't zip around at 1.41x speed on diagonals:

if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707; }
Enter fullscreen mode Exit fullscreen mode

On top of that there's dashing (Shift), melee attacks (E/F), and invincibility frames after taking a hit. The i-frames were a late addition — without them, walking into a group of enemies would drain your health to zero in a couple frames.

The weapon table

Six weapons, defined as a flat config so I could tweak balance without hunting through game logic:

var WEAPONS = {
  pistol:     { name: 'PISTOL',   cooldown: 18, ammoCost: 1, damage: 2, color: '#ffcc00' },
  shotgun:    { name: 'SHOTGUN',  cooldown: 28, ammoCost: 2, damage: 2, color: '#ff8844' },
  machinegun: { name: 'M-GUN',    cooldown: 5,  ammoCost: 1, damage: 1, color: '#ffaa44' },
  rocket:     { name: 'ROCKET',   cooldown: 40, ammoCost: 3, damage: 6, color: '#ff2200' },
  laser:      { name: 'LASER',    cooldown: 4,  ammoCost: 1, damage: 2, color: '#00ffff' },
  chainsaw:   { name: 'CHAINSAW', cooldown: 6,  ammoCost: 0, damage: 3, color: '#cccccc' },
};
Enter fullscreen mode Exit fullscreen mode

The chainsaw does 3 damage at zero ammo cost, which sounds broken until you realize you have to be touching the enemy to use it. Against a brute that shoots fireballs, that's a real trade-off.

Enemy AI

Nine enemy types, each with its own update function. I tried a shared behavior tree early on, but every enemy ended up needing so many special cases that individual functions were simpler to reason about:

function updateNormalEnemy(e, pcx, pcy) {
  if (e.type === 'zombie')   { updateZombieAI(e, pcx, pcy);   return; }
  if (e.type === 'spectre')  { updateSpectreAI(e, pcx, pcy);  return; }
  if (e.type === 'brute')    { updateBruteAI(e, pcx, pcy);    return; }
  if (e.type === 'revenant') { updateRevenantAI(e, pcx, pcy); return; }
  if (e.type === 'spawner')  { updateSpawnerAI(e, pcx, pcy);  return; }
  if (e.type === 'tentacle') { updateTentacleAI(e, pcx, pcy); return; }

  // Default: basic chase/wander
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The standouts:

  • Zombie — Grabs the player and cuts movement speed to 20%. Getting grabbed by one while two imps close in is where most of my deaths happened during testing.
  • Spectre — Phases through walls with fluctuating visibility. If it gets stuck, it teleports. These are annoying in exactly the right way.
  • Revenant — Fires homing missiles. The missile turns toward you each frame, but with a limited turn rate. Sharp direction changes shake them off. I spent a while getting homingStrength right — too high and they're unavoidable, too low and they're trivial.

The homing math:

if (b.homing) {
  var targetAngle = Math.atan2(pcy - b.y, pcx - b.x);
  var currentAngle = Math.atan2(b.vy, b.vx);
  var diff = targetAngle - currentAngle;
  // Normalize angle difference
  if (diff > Math.PI) diff -= Math.PI * 2;
  if (diff < -Math.PI) diff += Math.PI * 2;
  // Apply limited turn
  currentAngle += diff * b.homingStrength;
  b.vx = Math.cos(currentAngle) * b.speed;
  b.vy = Math.sin(currentAngle) * b.speed;
}
Enter fullscreen mode Exit fullscreen mode

Enemies also scale with room index — more HP, faster movement. It's blunt, but it works as a difficulty curve without needing a separate difficulty system:

var enemy = {
  health: 1 + roomIndex,         // More HP in later rooms
  speed: ENEMY_BASE_SPEED + roomIndex * 0.18,  // Faster too
  // ...
};
Enter fullscreen mode Exit fullscreen mode

The Cthulhu fight

Back to that wall-hugging Cthulhu. The fix was embarrassingly simple: the boss pathfinding was using the player's collision box size instead of its own, so it thought it could fit through gaps it couldn't. One variable swap and suddenly Cthulhu was terrifying.

The boss is a generated config — HP varies between 55 and 70 per run:

function generateRandomBoss() {
  var hp = 55 + Math.floor(Math.random() * 16);
  return {
    name: 'CTHULHU',
    bodyColor: '#0a2a2a',
    accentColor: '#1a5a4a',
    size: 62,
    isCthulhu: true,
    tentacleCount: 6,
    attacks: ['fireball', 'tentacle_slam', 'ink_cloud',
              'summon', 'tentacle_sweep'],
    health: hp,
    maxHealth: hp,
  };
}
Enter fullscreen mode Exit fullscreen mode

Five attack patterns: radial fireball bursts (8 projectiles, 12 in phase 2), tentacle slams that spawn projectile lines, ink clouds that scatter slow projectiles everywhere, summoning adds, and a spiraling tentacle sweep. At half health it enters phase 2 — speed jumps, two tentacle monsters and a skull spawn in, and the color palette shifts:

var wasPhase1 = e.phase === 1;
e.phase = e.health <= bc.maxHealth / 2 ? 2 : 1;
if (wasPhase1 && e.phase === 2) {
  e.speed = 1.8;  // Faster
  // Spawn reinforcements
  game.enemies.push(createEnemy(map, BOSS_ROOM, 'tentacle'));
  game.enemies.push(createEnemy(map, BOSS_ROOM, 'tentacle'));
  game.enemies.push(createEnemy(map, BOSS_ROOM, 'skull'));
}
Enter fullscreen mode Exit fullscreen mode

The rendering draws 6 tentacles as quadratic bezier curves, each on independent wave patterns. Getting them to look organic took a lot of fiddling with sin offsets:

for (var ti = 0; ti < tentCount; ti++) {
  var tBaseAngle = (ti / tentCount) * Math.PI * 2;
  var tWave = Math.sin(game.frameCount * 0.04 + ti * 1.2) * 8;
  var tLen = e.w / 2 + 14 + Math.sin(game.frameCount * 0.03 + ti) * 4;

  ctx.strokeStyle = phase2 ? '#2a8a6a' : '#1a5a4a';
  ctx.lineWidth = phase2 ? 5 : 4;
  ctx.beginPath();
  ctx.moveTo(tx1, ty1);
  ctx.quadraticCurveTo(tx2, ty2, tx3, ty3);
  ctx.stroke();
}
Enter fullscreen mode Exit fullscreen mode

The mech suit

Power armor that absorbs hits before your regular armor and health do. Drops randomly from room 2 onward (40% room spawn chance), and always spawns in the boss room — I didn't want anyone reaching Cthulhu without it.

When equipped, the player sprite grows by 3px on each side and gets mechanical details drawn on: hydraulic legs, shoulder rivets, a pulsing power core, helmet antenna. The glow pulses with a sin wave so it reads as "powered" even when standing still:

if (hasRobo) {
  var roboGlow = 0.15 + Math.sin(game.frameCount * 0.08) * 0.08;
  ctx.fillStyle = 'rgba(255, 170, 0, ' + roboGlow + ')';
  ctx.beginPath();
  ctx.arc(rpx + rpw / 2, rpy + rph / 2, rpw / 2 + 6, 0, Math.PI * 2);
  ctx.fill();
}
Enter fullscreen mode Exit fullscreen mode

Damage flows through three layers — mech armor first, then regular armor absorbs 60% of what's left, then health:

function applyDamage(dmg) {
  if (game.activePower === 'INVULNERABLE') return;
  // Robot armor absorbs first
  if (game.robotArmor > 0) {
    var roboAbsorb = Math.min(game.robotArmor, dmg);
    game.robotArmor -= roboAbsorb;
    dmg -= roboAbsorb;
  }
  if (dmg <= 0) return;
  // Regular armor absorbs 60% of remaining
  if (game.armor > 0) {
    var absorbed = Math.min(game.armor, Math.ceil(dmg * 0.6));
    game.armor -= absorbed;
    dmg -= absorbed;
  }
  game.health -= dmg;
}
Enter fullscreen mode Exit fullscreen mode

The levels that aren't dungeon crawls

I added two special rooms because seven rooms of "clear enemies, find exit" got monotonous during playtesting.

The vehicle level (Room 5) puts you in a mech with 200 HP, a cannon, and infinite ammo. Twelve armored enemies — brutes, demons, imps, zombies — swarm from every direction. The mech moves at 4.5 speed (player walks at 3.0), so the pace completely changes. It's the power fantasy break before the final push. I originally had the vehicle as a boss fight reward, but it worked better as a late-game palate cleanser.

The bomb room (Room 6) gives you 60 seconds to collect 5 bomb parts scattered through a maze while 8 enemies hunt you. Below 10 seconds, an alarm kicks in. This one went through the most iteration — the first version had 90 seconds and 3 parts, and nobody ever failed it. Cutting the time and doubling the parts made it genuinely tense. The maze layout matters here more than anywhere else because you need to plan a route, not just shoot your way through.

Particles

Three functions, the whole system. Spawn them on kills, shots, pickups, explosions. The 0.94 friction multiplier on velocity gives them a quick deceleration that looks physical:

function spawnParticles(x, y, count, color) {
  for (var i = 0; i < count; i++) {
    game.particles.push({
      x: x, y: y,
      vx: (Math.random() - 0.5) * 4,
      vy: (Math.random() - 0.5) * 4,
      life: 15 + Math.floor(Math.random() * 15),
      maxLife: 30,
      color: color,
      size: 2 + Math.random() * 3,
    });
  }
}

function updateParticles() {
  for (var i = game.particles.length - 1; i >= 0; i--) {
    var p = game.particles[i];
    p.x += p.vx; p.y += p.vy;
    p.vx *= 0.94; p.vy *= 0.94;
    p.life--;
    if (p.life <= 0) game.particles.splice(i, 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

These are the cheapest source of "game feel" I've found. A kill without a blood splat particle burst feels like nothing happened. A kill with one feels like you did something.

Render order and lighting

The renderer draws in strict layer order — floor, blood pools, exit indicators, pickups, enemies, player, bullets, particles, lighting, HUD. Getting this wrong produces visible z-fighting, and I did get it wrong for a while. Pickups were rendering on top of the player, which looked absurd.

function render() {
  drawTileMap(map);        // 1. Floor and walls
  drawBloodPools();        // 2. Persistent blood stains
  drawExitIndicators();    // 3. Door arrows when room is cleared
  drawPickups();           // 4. Health, ammo, weapons, mech suits
  drawEnemies();           // 5. All enemy sprites
  drawPlayer();            // 6. Doom guy / mech suit
  drawBullets();           // 7. Projectiles
  drawParticles();         // 8. Particle effects
  drawLighting();          // 9. Darkness gradient
  drawCanvasHUD();         // 10. Health/armor/ammo bars
}
Enter fullscreen mode Exit fullscreen mode

The lighting is a single radial gradient centered on the player. Three stops: transparent at the center, 30% dark at 60% radius, 70% dark at the edges. It creates a flashlight cone that adds tension without any per-pixel calculation:

function drawLighting() {
  var cx = game.px + game.pw / 2, cy = game.py + game.ph / 2;
  var radius = game.vehicleMode ? 220 : 180;
  var gradient = ctx.createRadialGradient(cx, cy, radius * 0.3, cx, cy, radius);
  gradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
  gradient.addColorStop(0.6, 'rgba(0, 0, 0, 0.3)');
  gradient.addColorStop(1, 'rgba(0, 0, 0, 0.7)');
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, W, H);
}
Enter fullscreen mode Exit fullscreen mode

Synthesized audio

No audio files in the entire game. Every sound effect is built at runtime with the Web Audio API — oscillators, a pre-generated white noise buffer, biquad filters, and gain envelopes.

function playGunshot(type) {
  var ctx = getAudioCtx();
  var now = ctx.currentTime;
  var noise = ctx.createBufferSource();
  noise.buffer = getNoiseBuffer();  // Pre-generated white noise
  var filter = ctx.createBiquadFilter();
  var gain = ctx.createGain();

  if (type === 'shotgun') {
    filter.type = 'bandpass';
    filter.frequency.value = 600;
    gain.gain.setValueAtTime(0.6, now);
    gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
  } else if (type === 'rocket') {
    filter.frequency.value = 300;
    // Add a low oscillator for the bass thump
    var osc = ctx.createOscillator();
    osc.frequency.setValueAtTime(200, now);
    osc.frequency.exponentialRampToValueAtTime(80, now + 0.15);
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

The noise buffer is one second of random samples, generated once:

function getNoiseBuffer() {
  if (noiseBuffer) return noiseBuffer;
  var ctx = getAudioCtx();
  var size = ctx.sampleRate;
  noiseBuffer = ctx.createBuffer(1, size, ctx.sampleRate);
  var data = noiseBuffer.getChannelData(0);
  for (var i = 0; i < size; i++) data[i] = Math.random() * 2 - 1;
  return noiseBuffer;
}
Enter fullscreen mode Exit fullscreen mode

The shotgun is bandpass-filtered noise. The rocket layers a descending oscillator for bass. The chainsaw is a low-frequency sawtooth. Each weapon sounds distinct, and there's a hard cap at 10 concurrent sounds to prevent the audio context from choking during heavy combat.

Procedural room graph

Rooms connect through a graph that's generated fresh each run. The algorithm places rooms one at a time on a virtual coordinate grid, branching off existing rooms in random directions:

function generateRoomGraph() {
  var DIRS = ['right', 'left', 'up', 'down'];
  var OPPOSITE = { right: 'left', left: 'right', up: 'down', down: 'up' };

  var occupied = {};
  occupied['0,0'] = 0;
  positions.push([0, 0]);

  for (var ri = 1; ri < TOTAL_ROOMS; ri++) {
    var placed = false;
    while (!placed && attempts < 50) {
      // Try to branch from the previous room, but after 10 failures
      // pick any random existing room to branch from
      var fromIdx = ri - 1;
      if (attempts > 10) fromIdx = Math.floor(Math.random() * ri);

      var dirs = shuffleArray(DIRS);
      for (var d = 0; d < dirs.length; d++) {
        var nx = fromPos[0] + OFFSETS[dir][0];
        var ny = fromPos[1] + OFFSETS[dir][1];
        if (!occupied[nx + ',' + ny]) {
          // Place room and create bidirectional exits
          game.roomGraph[fromIdx].exits[dir] = ri;
          game.roomGraph[ri].exits[OPPOSITE[dir]] = fromIdx;
          placed = true;
          break;
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The fallback after 10 failed attempts — picking any random room instead of always the previous one — was a fix for an early bug where the graph would dead-end itself against map borders.

Power-ups and loot

Five power-ups, spawning at 20% chance every 300 frames:

Power-Up Duration What it does
BERSERK 8 sec Double damage, red tint
SPEED DEMON 8 sec 2x move speed and fire rate
INVULNERABLE 5 sec Ignore all damage
QUAD AMMO 10 sec Shots cost no ammo
HELLSTORM Instant 3 damage to every enemy

Enemy drops scale with progression. Weapon drop chance goes from 8% in room 0 to 32% in room 6, so you're well-armed by the time you hit the boss:

function dropLoot(e) {
  var weaponChance = 0.08 + game.currentRoom * 0.04;  // 8% room 0 → 32% room 6
  if (Math.random() < weaponChance) {
    // Drop a random weapon the player doesn't have yet
  }
  // Robot armor: 3% rare drop
  if (Math.random() < 0.03 && game.robotArmor < ROBOT_ARMOR_MAX) {
    // Drop mech suit repair
  }
  // Otherwise: 50% chance of health, ammo, or armor
}
Enter fullscreen mode Exit fullscreen mode

The game loop

Standard requestAnimationFrame:

function gameLoop() {
  update();
  render();
  requestAnimationFrame(gameLoop);
}
Enter fullscreen mode Exit fullscreen mode

update() handles everything in order: power-up timers, health regen, bomb countdown, power-up spawning, player movement, bullet physics, enemy AI, pickup collection, room transitions, win conditions. render() draws it all. Keeping those two apart made debugging significantly easier — when something looked wrong, I knew whether to look at game state or drawing code, not both.

What I'd do differently

If I built this again, I'd split the file. The single-file constraint was fun and it kept me honest about architecture — every system is a clearly-labeled section with flat functions. But at 3,500 lines, finding things gets tedious even with good comments. An ES module per system with a shared game state would've been worth the tooling overhead.

I'd also add a proper pathfinding algorithm. The enemies chase by moving toward the player's coordinates, which means they get stuck on walls constantly. A* on a 21x15 grid would be negligible cost and would make every enemy type feel smarter.

The Web Audio API turned out to be way more capable than I expected. Every sound in the game is synthesized — no audio file downloads at all. That was a gamble that paid off. On the other hand, the particle system could use object pooling instead of creating and splicing array elements every frame. It's fine at the current scale, but it's the first thing that would need fixing if I added more enemies.

Play it

https://martinpatino.com/take-a-break/ — WASD to move, Space to shoot, Shift to dash. Seven rooms, then Cthulhu. Good luck.

Top comments (0)