I Built a Doom Clone in One HTML File — DeepSeek Blog | Neura Market
    Neura MarketNeura Market/DeepSeek
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsBlogVideosGuidesCoursesCommunityTrendingGenerate
    DeepSeekBlogI Built a Doom Clone in One HTML File
    Back to Blog
    I Built a Doom Clone in One HTML File
    javascript

    I Built a Doom Clone in One HTML File

    Martin Patino March 20, 2026
    0 views

    The first version of Cthulhu just walked into a wall and stayed there. Six tentacles flailing, third...

    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](https://martinpatino.com/take-a-break/) 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. ```plaintext ┌─────────────────────────────────────────────┐ │ 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 │ └─────────────────────────────────────────────┘ ``` ## 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: ```js 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 } ``` 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: ```js 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 }; ``` 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: ```js 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 } ``` ![Hell Crawler gameplay showing the player navigating a dungeon room with enemies and projectiles](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0om2njmkoeu96b87zdfx.png) Diagonal movement gets the `0.707` normalization so you don't zip around at 1.41x speed on diagonals: ```js if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707; } ``` 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: ```js 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' }, }; ``` 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: ```js 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 // ... } ``` 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: ```js 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; } ``` 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: ```js var enemy = { health: 1 + roomIndex, // More HP in later rooms speed: ENEMY_BASE_SPEED + roomIndex * 0.18, // Faster too // ... }; ``` ## 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: ```js 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, }; } ``` 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: ```js 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')); } ``` 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: ```js 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(); } ``` ## 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: ```js 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(); } ``` Damage flows through three layers — mech armor first, then regular armor absorbs 60% of what's left, then health: ```js 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; } ``` ## 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: ```js 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); } } ``` 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. ```js 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 } ``` 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: ```js 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); } ``` ## 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. ```js 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); // ... } } ``` The noise buffer is one second of random samples, generated once: ```js 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; } ``` 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: ```js 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; } } } } } ``` 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: ```js 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 } ``` ## The game loop Standard `requestAnimationFrame`: ```js function gameLoop() { update(); render(); requestAnimationFrame(gameLoop); } ``` `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/](https://martinpatino.com/take-a-break/) — WASD to move, Space to shoot, Shift to dash. Seven rooms, then Cthulhu. Good luck.

    Tags

    javascriptgamedevsideprojectshtml

    Comments

    More Blog

    View all
    How I'm using ASTs and Gemini to solve the "Codebase Onboarding" problem 🧠ai

    How I'm using ASTs and Gemini to solve the "Codebase Onboarding" problem 🧠

    Hi everyone! 👋 I’m Tara, a Senior Software Engineer and Consultant. Over the years, I've jumped...

    T
    tworrell
    Local AI Will Save Us All (The Math Says So, Trust Me)ai

    Local AI Will Save Us All (The Math Says So, Trust Me)

    Every few weeks a take goes viral in tech circles making the case for ditching cloud AI and running...

    S
    Sebastian Schürmann
    Lost in the AI Hype, I Started Smallai

    Lost in the AI Hype, I Started Small

    And it helped me get back into tech without drowning TL;DR at the end Coming back to...

    R
    Rohini Gaonkar
    Building a Replay-Tested Interactive Brokers Client in Gogo

    Building a Replay-Tested Interactive Brokers Client in Go

    I wanted an IBKR library that felt like Go and had testing I could trust. So I wrote one.

    T
    Thomas Marcelis
    Playwright in Pictures: Fully Parallel Modeplaywright

    Playwright in Pictures: Fully Parallel Mode

    Playwright’s fullyParallel mode is often treated as a simple performance switch. In practice, it...

    V
    Vitaliy Potapov
    Designing a CLI for Both Humans and Agentscli

    Designing a CLI for Both Humans and Agents

    Learn how Alpic designed its CLI for both human developers and AI agents — covering tradeoffs like polling, context windows, interactivity, and statelessness.

    J
    Julien Vallini

    Stay up to date

    Get the latest DeepSeek prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for DeepSeek and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.