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 │
└─────────────────────────────────────────────┘
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
}
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
};
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
}
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; }
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' },
};
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
// ...
}
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
homingStrengthright — 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;
}
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
// ...
};
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,
};
}
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'));
}
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();
}
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();
}
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;
}
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);
}
}
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
}
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);
}
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);
// ...
}
}
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;
}
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;
}
}
}
}
}
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
}
The game loop
Standard requestAnimationFrame:
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/ — WASD to move, Space to shoot, Shift to dash. Seven rooms, then Cthulhu. Good luck.

Top comments (0)