For four posts, my Bomberman clone looked like a game designed by a spreadsheet. Players were colored squares. Bombs were black squares. Explosions were yellow rectangles. It worked — the game logic was solid — but it looked like a developer art museum.
This week I swapped every Graphics primitive for actual Atomic Bomberman sprites. And in doing so, I learned something I didn't expect: the sprite swap itself was the easy part. The interesting part was discovering how cleanly the rendering layer separated from the game logic — and what broke when it didn't.
Before:
After:
the thesis
Here's the rule I accidentally followed and now consciously endorse: your game server should never know what a sprite is.
The server in this project manages game state: positions, timers, collision, death. It sends a game_state message 60 times a second. Here's what that message contains for explosions:
pub struct ExplosionState {
pub tiles: Vec<(f64, f64)>,
}
That's it. A flat list of tile coordinates. The server doesn't know which tile is the center of a blast, which is an arm, which is a tip. It doesn't know that explosions have direction. It placed bombs, counted down timers, and computed which tiles should be on fire. Its job ends there.
Every rendering decision lives on the client. Which sprite to use, how to animate it, whether to flip a texture. And this separation made the entire sprite overhaul possible without touching a single line of Rust.
finding the assets
I spent time on itch.io and OpenGameArt looking for Bomberman-style sprites. Plenty of generic top-down packs, nothing that felt right. Then I remembered: years ago I'd started a CraftyJS Bomberman clone using the original Atomic Bomberman sprites. I never finished it, but the sprites were still sitting in the old project folder. Sometimes the best asset is the one you already have.
the sprite pipeline
The loader module ended up being small:
export const loadSheet = async (path: string) => {
const sheet: Texture = await Assets.load(path);
sheet.source.scaleMode = "nearest";
return sheet;
};
export const loadTexture = (
sheet: Texture,
x: number,
y: number,
spriteWidth: number = 16,
spriteHeight: number = 16,
) => {
return new Texture({
source: sheet.source,
frame: new Rectangle(x, y, spriteWidth, spriteHeight),
});
};
loadSheet fetches a PNG and sets nearest-neighbor scaling, which is essential for pixel art. Without it, PixiJS interpolates when scaling up and everything looks like vaseline on a camera lens. loadTexture slices a sub-rectangle from the sheet.
All original sprites are 16px. The game renders at 64px per tile. PixiJS handles the scaling through sprite.width and sprite.height, so no image preprocessing needed. 192 grid tiles, 4 textures. Sprites are cheap instances pointing at shared image data.
the async cascade
Before sprites, creating a manager was synchronous. new Graphics(), draw some rectangles, done. But Assets.load() is async. That single change meant every manager factory that loads a spritesheet became async, and the useEffect in App.tsx that wires everything together had to await each one before connecting the WebSocket:
const gridManager = await createGridManager(gridLayer);
const playersManager = await createPlayersManager(playerLayer);
const bombsManager = await createBombsManager(entityLayer);
const explosionsManager = await createExplosionsManager(gridLayer, gridManager);
None of the actual logic changed, just the function signatures and the init order. But touching every manager's factory function to add async/await felt like the kind of refactor where you question whether sprites were worth it. (They were.)
the pixel measurement problem
The power-up spritesheet was 117x33 pixels with inconsistent spacing between icons. I couldn't eyeball where one frame ended and the next began. You could open it in GIMP and zoom in with a pixel grid, but Claude suggested something I wouldn't have thought of. ffmpeg can scale pixel art with nearest-neighbor filtering:
ffmpeg -i powerups.png -vf "scale=iw*8:ih*8:flags=neighbor" powerups_8x.png
Scaled 8x, every original pixel became an 8x8 block. It's still eyeballing, but at 8x the boundaries between sprite frames become obvious. You can clearly see where the background color changes and where the 1px gaps are. Turns out the frames were 16x16 with 1px gaps between them. The bottom row (red backgrounds) are debuffs. Those went into the stretch goals.
explosions: client-side direction inference
This was the most interesting rendering problem, and the clearest example of the server/client split paying off.
The server sends a flat set of explosion tile positions. No metadata about shape. But explosions need different sprites: a center piece, horizontal and vertical arms, tips at the ends. The client has to figure this out.
The solution: for each explosion tile, check which neighbors are also in the explosion set:
const left = tilesSet.has(`${x - TILE_SIZE}-${y}`);
const right = tilesSet.has(`${x + TILE_SIZE}-${y}`);
const top = tilesSet.has(`${x}-${y - TILE_SIZE}`);
const bottom = tilesSet.has(`${x}-${y + TILE_SIZE}`);
let direction: ExplosionDirection = "center";
if (!left && !right && !top && bottom) direction = "topEdge";
else if (!left && right && !top && !bottom) direction = "leftEdge";
else if (left && !right && !top && !bottom) direction = "rightEdge";
else if (!left && !right && top && !bottom) direction = "bottomEdge";
else if (left && right && !top && !bottom) direction = "left";
else if (!left && !right && top && bottom) direction = "top";
Both horizontal neighbors? Horizontal arm. Both vertical? Vertical arm. Only one neighbor? Tip pointing away from it. Multiple directions? Center. Pure function, no graph traversal, O(1) lookups per tile using a Set.
The server didn't need to change. It still sends tiles: Vec<(f64, f64)>, just coordinates. The client derives the visual structure from the spatial relationship between tiles. If I later want fancier explosion sprites or different animation styles, I change client code only.
the flip gotcha
PixiJS lets you mirror sprites with scale.x = -1, which saves you from needing separate left-facing and right-facing assets. But there's a catch: the flip happens around the sprite's anchor point, which defaults to the top-left corner. So flipping a sprite doesn't just mirror it, it also jumps its position.
Fix: set anchor(0.5, 0.5) to flip around the center, then offset x and y by half the tile size to compensate. Small thing, but it took longer to debug than the entire direction inference algorithm.
players: animation from velocity
The server sends this for each player:
pub struct PlayerState {
pub id: u8,
pub x: f64,
pub y: f64,
pub alive: bool,
pub dx: i8,
pub dy: i8,
}
Position, alive/dead, and a direction vector. No animation frame, no facing direction enum, no sprite index. The client's PlayerSprite class maps this to animations:
private play(frameType: FrameType) {
if (this.currentFrameType === frameType) return;
this.currentFrameType = frameType;
this.sprite.textures = this.frames[frameType];
this.sprite.visible = true;
this.sprite.loop = true;
this.sprite.play();
}
public up() { this.play("up"); }
public down() { this.play("down"); }
public left() { this.play("left"); }
public right() { this.play("right"); }
The play() method early-returns if the requested animation is already active. This matters because the game loop calls player.right() on every tick while the player moves right. Without the guard, it would restart the walk animation 60 times a second.
Vertical movement takes priority over horizontal in the update logic, matching how classic Bomberman looks during diagonal movement. That's a rendering opinion the server has no business holding.
The player spritesheet was the most complex. 4 colors, each with walk cycles in 4 directions plus a death sequence. The frame layout came from reverse-engineering my old CraftyJS project: Crafty.sprite(16, 26, ...) told me each frame was 16px wide and 26px tall, with the actual character occupying 16x24 and the rest being padding.
bombs: the easy win
Bombs were the simplest sprite swap. PixiJS AnimatedSprite takes a texture array and cycles through it:
const animatedSprite = new AnimatedSprite({
textures: frames, // [0, 1, 2, 1, 0] for ping-pong
animationSpeed: 0.05,
loop: true,
autoPlay: true,
});
Three frames, reversed for a ping-pong effect (small → big → small). Set loop: true and forget about it. The server sends bomb positions and timers; the client handles the pulsing animation independently.
what the server sends, what the client decides
Here's the full picture of the boundary:
| Server sends | Client decides | |
|---|---|---|
| Players | id, x, y, alive, dx, dy | Which animation to play, sprite color, death sequence |
| Bombs | x, y, timer, owner | Pulse animation, sprite size |
| Explosions | tile coordinates | Center vs arm vs tip, direction, flip |
| Power-ups | x, y, kind | Sprite frame, pickup flash |
| Grid | tile types | Wall/floor/destructible sprites |
The game_state message is ~200 bytes per tick. No texture references, no animation state, no rendering hints. The server is a physics engine that happens to know about game rules. The client is a renderer that happens to know about game state.
This meant the entire visual overhaul was a client-only change. Every Graphics rectangle replaced with a sprite, every animation added, every directional inference implemented. The server kept running its 60Hz loop, unchanged, unaware that the game had gone from looking like a developer prototype to looking like Atomic Bomberman.
next up
The game looks like Atomic Bomberman now. But there's a problem: you can walk straight through your own bomb. And another player's bomb. Bombs are supposed to trap people — that's half the strategy of Bomberman. Right now they're decoration.
Next time: the three rules that make bombs feel fair, and why implementing them in a server-authoritative multiplayer game is harder than it sounds.


Top comments (0)