Skip to content
nkh.do
Go back

Boom clone devlogs #1: The Prototype

Boom clone current state — colored rectangles on a tile grid, bots and breakable blocks scattered around

Behold. Programmer art in its natural habitat. Yes those are squares. Yes that’s the game. Sprites coming “soon” (citation needed).

Remember the Boom clone my brother and I started rebuilding instead of going to therapy? It now has actual game logic. The kind where things move, things explode, and things occasionally trap each other in a bubble of regret. Let me walk you through it before I forget what any of it does and have to reverse-engineer my own code at 2am like a stranger on Stack Overflow.

The map

15×13 tiles. Not arbitrary — that’s the original CrazyArcade grid size. Yes, we measured. Yes, we are full-grown adults. No, I will not be taking questions. We are not above counting pixels in a 20-year-old screenshot. Respect the source material or get out.

Each tile is a { flags, assetId } pair packed into a flat array, because every game programmer eventually arrives at the same conclusion: 2D arrays are for cowards and people who have never forEached an undefined. Flags are a bitmask — SOLID, BREAKABLE, PUSHABLE, PASSABLE, HAS_POWERUP — and you compose tile types by OR-ing them together, which is the closest a programmer ever gets to feeling like a chef:

export const TILE_SOLID       = 1 << 0
export const TILE_BREAKABLE   = 1 << 1
export const TILE_PUSHABLE    = 1 << 2
export const TILE_PASSABLE    = 1 << 3
export const TILE_HAS_POWERUP = 1 << 4

export const TILE_WALL  = { flags: TILE_SOLID, assetId: 0 }
export const TILE_BLOCK = { flags: TILE_SOLID | TILE_BREAKABLE, assetId: 0 }
export const TILE_BUSH  = { flags: TILE_BREAKABLE | TILE_PASSABLE, assetId: 0 }

A bush is BREAKABLE | PASSABLE. A breakable block hiding a powerup is SOLID | BREAKABLE | HAS_POWERUP. A wall is just SOLID, like its personality. An empty tile is 0, the flag equivalent of “leave me alone.”

There are no border walls. The map edge is enforced by a function called isInBounds() because I refuse to waste 56 tiles on perimeter when a single function call can humiliate you for free. RAM saved: negligible. Smug satisfaction: substantial.

Sub-cells, or: how I learned to stop trusting tiles

Each tile is split into 3×3 sub-cells. That makes a 45×39 sub-grid that movement and collision actually operate on. Why? Because if you only move tile-by-tile, your game feels like Pokémon, and not in the good way. You want your character to glide. You want vibes. You want the illusion of grace from a person mashing arrow keys with one thumb.

export const SUB_CELLS = 3
export const SUB_GRID_WIDTH  = MAP_WIDTH  * SUB_CELLS // 45
export const SUB_GRID_HEIGHT = MAP_HEIGHT * SUB_CELLS // 39

export function subToTile(sub: number): number {
  return Math.floor(sub / SUB_CELLS)
}

export function tileToSub(tile: number): number {
  return tile * SUB_CELLS + 1 // center sub-cell
}

That + 1 in tileToSub is doing a lot of work — it puts you on the exact center sub-cell of a tile, which only exists because we picked an odd SUB_CELLS. An even number would give you no true center and you’d be off by half a pixel forever, slowly going insane wondering why everything looks slightly drunk. Ask me how I know.

The whole point of sub-cells, honestly, is the sub-tile dodge: explosions check which tile your center sub-cell falls in. So if you’re standing right at a tile boundary, you can shuffle one sub-cell over and the explosion thinks you were never there. This is a real CrazyArcade mechanic — a frame-perfect twitch dodge that separated the kids who memorized timings from the kids who screamed when the bomb appeared. We had to have it. A bomberman game without sub-tile dodges is just chess with worse manners.

It also falls out of the math basically for free, which is the developer equivalent of finding twenty bucks in a coat pocket. Pick odd SUB_CELLS, do the floor-division correctly, and the mechanic just exists. Design and implementation, accidentally married.

We also have wall sliding. If your 3×3 hitbox is only barely clipping a wall (≤1/3 overlap), the game nudges you perpendicularly so you slide past it instead of getting body-checked by a pillar like a confused intern at a revolving door. This is the single most flattering thing software can do for a player and they will never know it’s there. Which is fine. Real love is silent.

Bombs that bomb

Bombs have a 3-second fuse and explode in 4 cardinal directions up to your blast range. Standard stuff. Diagonal is a war crime. The interesting bits:

The trapped/rescued/dead lifecycle

When you get caught in an explosion, you don’t die. You get trapped — frozen in a bubble for 5 seconds. Your teammates can walk onto your tile to rescue you. Your enemies can walk onto your tile to kill you. The bubble timer is a slow countdown to a humiliating, public death where everyone watches you struggle and nobody comes. Like LinkedIn. Like a group chat where you sent a long message and the only reply is a thumbs-up emoji.

Bots from the same team will incidentally rescue allied bots if they happen to walk through them. They don’t actively pathfind toward you. They have their own problems. They’re not heroes. They’re coworkers who’ll grab your package from the front desk only if it’s already on their way to the bathroom.

Bots that almost think

The bot AI is a priority chain: Evade → Bomb → Collect → Chase → Wander. Maslow’s hierarchy, but for the criminally insane. Every tick, the scene precomputes a danger set — every tile that’s either currently on fire or about to be on fire, with chain reactions propagated, mapped to the earliest fuse time. The bots BFS through this danger set to find escape routes and refuse to walk through active fire, because we are not making Lemmings and frankly the world has enough of those.

Here’s the decision loop, in pseudo-code:

on each tick:
  if I'm standing in danger or already fleeing:
    BFS to nearest safe tile, walk one step toward it
    return

  if a player is near AND I have bombs AND I roll lucky (15%):
    if I can find an escape route from a bomb placed here:
      place bomb, set fleeing = true
      return

  if there's a revealed powerup on an adjacent tile:
    walk onto it
    return

  if any player is reachable:
    BFS toward nearest, walk one step
    return

  wander in a safe-ish direction

The bot will only place a bomb if it can find an escape route from its own bomb. This took longer than I’d like to admit. Earlier versions of the bot would place a bomb, take a thoughtful pause to consider its life choices, and then explode. It was funny exactly once. The second time I started to suspect the bot was making a statement.

The danger set itself is the bit I’m most pleased with. Active explosions get fuse 0 (immediate danger — “run, idiot”). Pending bombs get their remaining fuse (“you’ve got like 1.4 seconds, idiot”). Chain reactions propagate using the triggering bomb’s fuse, not the chained one’s, because a chained bomb effectively detonates the moment its trigger does — physics doesn’t wait for the second bomb to find itself. The bot then BFS’s through tiles, treating any cell with low fuse as “do not stand here” and any cell with 0 as “actively on fire, are you insane.”

They collect powerups opportunistically — only if it’s an adjacent tile. They don’t go on long quests for fire upgrades. They’re not protagonists. They’re middle management. If a full_fire is two tiles away, that’s two tiles too many. The bot has emails to ignore.

Powerups

Four of them so far. None of them are balanced. We’re calling that “design space”:

There’s a Radar powerup in the design doc that would reveal hidden powerups inside blocks without destroying them. It does not exist yet. It is vaporware. I include it here to manifest it into being, like a wizard, or a bullet point in a Q3 OKR.

Sound, but only barely

I built a SoundManager that wraps the Web Audio API and produces synth-generated placeholder sounds. Each sound is just an oscillator + a gain envelope. That’s it. That’s the whole instrument. A frequency that swoops and a volume that dies. Roughly the sound design budget of Asteroids (1979), and they got into the Smithsonian, so we’re fine:

play(key: string): void {
  const recipe = this.recipes[key]
  if (!recipe) return

  const osc  = this.ctx.createOscillator()
  const gain = this.ctx.createGain()

  osc.type = recipe.type
  osc.frequency.setValueAtTime(recipe.freqStart, this.ctx.currentTime)
  osc.frequency.linearRampToValueAtTime(recipe.freqEnd, this.ctx.currentTime + recipe.duration)

  gain.gain.setValueAtTime(recipe.volume, this.ctx.currentTime)
  gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + recipe.duration)

  osc.connect(gain)
  gain.connect(this.ctx.destination)

  osc.start(this.ctx.currentTime)
  osc.stop(this.ctx.currentTime + recipe.duration)
}

That’s the entire audio engine. Twenty lines. The bomb place sound is a short square wave down-sweep. The death sound is also a square wave down-sweep but slightly meaner — same wave, longer hold, lower bottom note, like the same actor playing the same villain twice. Player rescue is a sine up-sweep — the only happy sound in the entire game, and it gets played roughly 1.4% of the time.

These will all be replaced with real audio files later. Probably. The call sites won’t have to change. That’s the part I’m proud of. Everything else just sounds like a 1983 calculator having a feeling.

There’s no UI for volume or mute yet. If you don’t like the sounds, mute your tab like a normal person. Or unplug your speakers, which is also a valid feature request.

Game loop

20Hz fixed tick. 50ms per frame. If your monitor runs at 144Hz, your monitor is showing the same frame seven times in a row and pretending not to notice.

The fixed tick isn’t a render budget — it’s a setup for multiplayer. Colyseus is going in later, and authoritative servers love deterministic, fixed-rate simulations the way cats love boxes. By writing all the game logic against a 20Hz tick now, the eventual server port becomes “run the same loop on the server, send state diffs, swap input source from keyboard to network.” In theory. The other theory is that everything will break the moment a second human shows up, but that’s a Future Hoang problem and Future Hoang has it coming.

Game phases: Waiting → Countdown → Playing → Finished.

Win condition in single-player is “kill all the bots before they kill you.” Configurable 1–3 bots. Multiplayer is last-player-standing, which we haven’t really tested at scale yet because we are two people and one of us is doing the dishes. Always. He has been doing the dishes for six months. I’m starting to think it’s strategic.

What’s next

Real maps instead of procedurally-generated ones. Real sprites instead of colored rectangles judging your taste. Real sound effects instead of oscillator regret. A name for the game that isn’t “Boom” or “the game project” or — and this is a real one we considered — “untitled-bomberman-thing.” And eventually, multiplayer rooms that actually work, where 8 strangers can scream at each other in real time, just like 2008. Before the bad timeline. Before the cookie banners.

Until then, the bots are out here BFS-ing through danger sets and politely refusing to suicide. That’s the win for this devlog. See you in #2, assuming we don’t get distracted and start writing a particle system. We will. We’re going to write a particle system. I can feel it.


Share this post on:

Previous Post
Boom clone devlogs #2: The Glow-Up
Next Post
Projects Showcase