
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:
- Bomb pass-through. When you place a bomb, you’re standing on it. Obviously you should be allowed to walk off it. But you should not be allowed to walk back on. The collision algorithm handles this by only checking the leading edge of your hitbox, which sounds simple until you spend 90 minutes wondering why you can phase through your own ordinance like a sad ghost.
- Chain reactions. A bomb caught in an explosion detonates immediately. This is responsible for approximately 100% of the moments my brother yells at me through Discord. The remaining 0% is when his cat steps on the keyboard. We’ve allocated 12% of the design budget specifically to chain reactions and 0% to therapy.
- Powerup survival. If a block reveals a powerup during an explosion, that powerup survives. If the powerup was already on the ground before the blast — gone. Vaporized. Annihilated. This rule exists purely so you can’t accidentally nuke the speed boost you were running toward and then sit there for 0.3 seconds wondering what your life choices have led to. We are merciful gods. The cruel version of this game exists in an alternate timeline and I do not want to live there.
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”:
speed_up— +1 speed, capped at 10, which gets multiplied by 0.1 to convert to sub-cells per tick because of course it does. The game’s fastest player moves at exactly 1 sub-cell per tick, which sounds slow until you remember a tick is 50ms and suddenly your brother is on the other side of the map.bomb_up— +1 simultaneous bomb, cap 8. Eight bombs is too many bombs. Eight bombs is a war crime. We left it in.fire_up— +1 blast range, cap 8. See above re: war crimes.full_fire— sets blast range to max immediately. This is the one you want. This is the one that, when picked up, causes opponents to type “wtf” in chat. This is the dopamine.
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.
- Waiting is the lobby, skipped entirely in single-player because you are alone and we don’t need to make that worse with a loading screen.
- Countdown is 3 seconds. Pure adrenaline. Pure regret-the-snack-you-just-ate.
- Playing is the actual game. The reason any of this exists.
- Finished is a 5-second results screen where you stare at the outcome and reconsider whether you are, in fact, a competitive person. Spoiler: you are. We all are. This is why people get banned from Monopoly.
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.