At the end of the last post, the game looked like Atomic Bomberman. Real sprites, real animations, directional explosions. But there were two problems you could spot in the first ten seconds of playing: movement felt stiff (walk diagonally into a wall and you'd freeze in place) and bombs didn't do anything. You could walk straight through them. Your own, your opponent's, it didn't matter.
That's not Bomberman. Half the strategy of the game is trapping people with bomb placement. If bombs don't block movement, you're just dropping fireworks on a walking simulator.
This week I made movement feel right, implemented the three bomb rules that every Bomberman game gets right, extracted the collision system into its own module, and debugged a ghost that turned out to be a feature.
wall sliding: the feature that isn't a feature
Before tackling bombs, I had a movement problem. Walking diagonally into a wall just...stopped you dead. Hold up-right, hit a wall on the right side, and your character freezes. No sliding along the wall face, no partial movement on the unblocked axis. It felt stiff.
The fix lives in calc_player_new_position, and it's shorter than you'd expect:
if can_player_move(player.x, y, ...) {
resolved_y = y;
}
if can_player_move(x, player.y, ...) {
resolved_x = x;
}
if can_player_move(x, y, ...) {
resolved_x = x;
resolved_y = y;
}
Three checks, each axis tested independently:
- Try moving on Y only (keep old X). If clear, accept Y.
- Try moving on X only (keep old Y). If clear, accept X.
- Try moving on both. If clear, accept both — full diagonal.
If you're moving diagonally into a wall that blocks X but not Y, step 1 accepts the Y movement, step 2 rejects X, and step 3 also fails. Result: you slide along the wall on the Y axis.
The thing I didn't expect: wall sliding and diagonal movement aren't two features. They're one algorithm. Axis-independent resolution handles both cases identically. Diagonal input where one axis is blocked is wall sliding. No special-casing, no branching on direction combinations. The behavior emerges from the structure rather than being hand-coded for each case.
One trade-off I left alone: I noticed that moving diagonally felt noticeably faster than moving in a straight line. The reason is simple. Cardinal movement updates one axis per tick, so you travel 1 unit. Diagonal movement updates both axes, so the actual distance is √(1² + 1²) = √2 ≈ 1.41 units per tick. That's 41% faster. Classic Bomberman normalizes the diagonal vector to unit length, but on a small grid the speed boost isn't game-breaking. If it becomes a problem during playtesting, normalization is a one-line fix. For now, simpler wins.
collision outgrows the map
Before bombs could block anything, I needed to answer a question: where does collision logic live?
The existing is_blocked function was in map.rs. It checked whether a tile was a wall or a destructible block — pure map concerns. But bomb collision isn't a map concern. Bombs are dynamic game objects, not part of the grid topology. Stuffing bomb checks into the map module would be mixing responsibilities.
I considered util.rs, but that's a dumping ground waiting to happen. If you can't name what a module does in one sentence, it's probably not a real module.
The answer was collision.rs — a module that owns the question "can this entity move here?" It combines map solidity checks with bomb position checks. is_blocked and is_solid moved out of map.rs. Grid constants like TILE_SIZE and PLAYER_SIZE stayed where they were — those describe the map, not collision behavior.
I also extracted get_player_edges, a helper that converts a player's pixel coordinates into the four tile indices their bounding box touches. Both is_blocked and the new is_bomb_blocking need this math, and duplicating it would be asking for a subtle off-by-one bug later.
rule 1: no stacking
The simplest rule. You can't place a bomb on a tile that already has one.
if bombs.iter().any(|b| b.row == row && b.col == col) {
return; // tile already has a bomb
}
One line in try_place_bomb. I'd actually implemented this one before the session started. It was the most obvious missing behavior. Without it, mashing the bomb key stacks infinite bombs on the same tile, and the explosion chain reaction becomes absurd.
rule 2: bombs are walls
Bombs block movement for all players. This is what makes Bomberman a strategy game: you can trap an opponent (or yourself) with a well-placed bomb.
The implementation: is_bomb_blocking iterates the bomb list, converts the player's position to edges using get_player_edges, and checks if any bomb occupies a tile the player touches. This combines with the existing is_blocked into a new can_player_move function:
pub fn can_player_move(
x: f64,
y: f64,
map: &[TileType],
bombs: &[Bomb],
player_id: u8,
) -> bool {
!is_blocked(x, y, map) && !is_bomb_blocking(x, y, bombs, player_id)
}
One function, one question: can this player move here? The caller doesn't need to know whether it was a wall, a destructible block, or a bomb that stopped them.
rule 3: walk off your own bomb
Here's where it gets interesting. When you place a bomb, you're standing on it. If rule 2 applied immediately, you'd be trapped by your own bomb every single time. That's not how Bomberman works. You need to be able to walk away first.
The solution: an walkthrough_player field on the Bomb struct.
pub struct Bomb {
pub row: usize,
pub col: usize,
pub timer: f64,
pub fire_range: u32,
pub walkthrough_player: Option<u8>,
// ...
}
When a bomb is placed, walkthrough_player is set to Some(player_id). The bomb doesn't block its placer. Each tick, update_bombs_owner_blocking checks whether the immune player has moved off the bomb's tile. Once they have, it clears to None and the bomb becomes solid for everyone — including the player who placed it.
The naming took a few rounds. Started with blocking_owner, which reads like the owner is doing the blocking. Then owner_exempt, which is accurate but clinical. Landed on walkthrough_player. It says what it means: this player can still walk through this bomb.
This rule is invisible when it works. You place a bomb, walk away, and never think about it. But without it, bombs would be unusable. It's the kind of game design that exists purely to prevent a terrible experience, and you only notice it when it's missing.
an idiomatic rust moment
The is_bomb_blocking function started as an imperative habit I brought from JavaScript, even though both languages have a better option:
let mut blocked = false;
for bomb in bombs {
// check if bomb blocks this position
if /* ... */ {
blocked = true;
break;
}
}
blocked
Mutable flag, loop, early break, return the flag. It works, but Rust has a better way:
bombs.iter().any(|bomb| {
// check if bomb blocks this position
// ...
})
The question was: how do you continue inside .any()? You don't — you return false from the closure. continue skips to the next iteration in a loop; return false in .any() does the same thing, because .any() stops at the first true. Same semantics, less ceremony.
This is the kind of refactor that doesn't change behavior but changes how the code reads. The any() version says what it means: "is any bomb blocking this position?" The loop version makes you read the whole body to understand the intent.
the ghost in the machine
With all three rules implemented, I started testing chain reactions — the satisfying Bomberman moment where one bomb's explosion triggers another. And that's when I saw the ghosts.
A chain of three bombs would produce "extra" explosions — bomb center sprites appearing on tiles where no bombs were placed. My first instinct: stale state. Maybe the bomb-to-explosion transition is leaking old positions. Maybe the client isn't cleaning up properly.
This is the kind of bug that looks terrifying in a networked game. State corruption in a 60Hz game loop? That could be anything.
following the wire
My AI mentor (Claude) suggested the senior engineer's approach to networked bugs: start at the wire. Before you blame the logic, figure out if the server is sending bad data or the client is misinterpreting good data.
Three investigation threads:
- Wire truth — add protocol logging to capture the raw JSON. Is the server sending explosion tiles that shouldn't be there?
- Rust collection integrity — check
bomb.rsandmap.rsfor the bomb-to-explosion transition. Are we indexing into aVecthat's being mutated during iteration? Stale IDs from a removed bomb? - PixiJS lifecycle — with
AnimatedSprite, the visual lifecycle is more complex than rawGraphics. Are "fire and forget" explosion sprites actually being forgotten by the renderer?
the discovery
The ghosts weren't stale data. They weren't leaked sprites. They were visual intersections.
Remember the explosion direction inference from part 5? For each explosion tile, the client checks its neighbors to decide which sprite to use. Two horizontal neighbors → horizontal arm. Two vertical → vertical arm. Multiple directions → center.
When two explosion arms from different bombs cross at a 90-degree angle, the tile at the intersection has neighbors in all four directions. The inference logic sees "neighbors everywhere" and defaults to the center sprite — the one that looks like a bomb origin.
The "ghost bombs" were cross-shaped intersections being rendered as centers. The server was correct. The networking was correct. The state management was correct. What looked like a data corruption bug was emergent visual behavior from a rendering heuristic.
the lesson
Deterministic reproduction was the key. Once I placed bombs in specific patterns and watched the "ghosts" appear at exactly the coordinates where blast paths crossed, the pattern was obvious. The bug wasn't in the data — it was in the interpretation.
The fix (if I want one) is either having the server flag which tiles are true bomb centers, or adding a "cross-intersection" sprite to the client's inference logic. For now, I left it. The networking and state management are working perfectly, and that's what I was actually worried about.
what this session taught me
The wall sliding algorithm and the three bomb rules were each a handful of lines. But they surfaced real design questions:
- Let behavior emerge from structure. Wall sliding isn't a feature — it's a side effect of testing each axis independently. The less you special-case, the fewer bugs you write.
- Where does collision live? Not in the map module. In its own module, because the question "can I move here?" combines multiple concerns.
- How do you handle temporary immunity? With an explicit state field that gets cleared by a condition, not by a timer.
- How do you debug a visual bug in a networked game? Start at the wire. Isolate server vs client. Reproduce deterministically.
And the ghost explosions reminded me of something I keep relearning: in a system with emergent behavior, not every unexpected result is a bug. Sometimes the code is doing exactly what you told it to, and the problem is that you didn't anticipate the combination.
next up
Movement feels right. Bombs block, trap, and punish. Chain reactions cascade. The core gameplay loop is there — place bombs, dodge explosions, collect power-ups, kill your friends.
But right now, two players connect and immediately start playing. There's no lobby, no ready check, no round structure. The game just... begins. And when someone dies, nothing happens. There's no "you win," no rematch, no waiting for the next round.
Next time: building a lobby and ready system, and what happens when your game needs to care about state that isn't gameplay.