Building Bomberman with Claude!
🧑‍💻

Building Bomberman with Claude!

Published
November 30, 2024
Tags
Software
Projects
Author
Stephen Wu
💣
I built a bomberman clone in ~half a day, with help from LLMs!

Intro

Bomberman is a classic video game from 1983 where you compete against other players by eliminating them with bombs. Players lay bombs that create a blast radius after a short delay.
Video preview
I grew up playing a Korean bomberman variant called Crazy Arcade.
notion image
This game gets quite intense at the higher levels…
Video preview
Here's my process of making a clone of this game and what I learned!

technologies

Tools
  • Cursor IDE
    • What models is Cursor using?
      • My Cursor is set up to currently use a mix of gpt-4o, claude-3.5-sonnet, and o1-mini.
        • These were just the recommended settings.
        • Generally I’ve heard claude-3.5-sonnet is very good at writing code, and Cursor uses different models based on availability and the type of task.
        • notion image
  • Claude chat
    • Why not ChatGPT?
      • At the time of this article, Claude offers the Projects feature — a convenient container for chats belonging to the same theme — but ChatGPT does not.
      • Anecdotally, I’ve found that Claude produces better outputs for creative pursuits, and ChatGPT is better for more practical endeavors.
During this project, I wanted to explore using LLMs for coding, so I used them for many problems that I would’ve otherwise done manually.
Languages & Frameworks
Why did I pick these?
  • I wanted to build a webapp because that’s the most accessible medium for a game — and that’s where the bulk of my experience is in.
  • I wanted a mix of both “tech I’ve used before” and “tech I’ve barely or never used”.
    • old: JS, TS, React
    • new: Tailwind, Shadcn, Next.js
    • I think it’s a good principle for side projects to balance familiar & unfamiliar technologies!
  • TypeScript
    • Safer, easier-to-read JS that saves you hours of debugging.
  • Next.js
    • Next.js adds some nice benefits to the base React webpack setup and has a great tooling ecosystem overall.
    • I wanted this project to be easily deployable to Vercel without me paying for any hosting.
  • Tailwind
    • Tailwind is a new CSS paradigm that comes highly recommended by folks.
    • I’ve wanted to try this out for a while and this seemed like a nice opportunity.
    • LLMs seem to be good at generating Tailwind code due to the framework's structured syntax.
  • Shadcn
    • During this project, I wanted to focus mainly on coding gameplay mechanics and not be bogged down by building web components. Shadcn provides well-designed UI components so I don’t have to build them myself.

i. ideation

One night, I wanted to make a game! I didn’t actually know what I wanted to build at the time, so I started with a Claude project with vague instructions.
(We didn’t actually end up using sockets and chat…)
(We didn’t actually end up using sockets and chat…)
I asked Claude…
S: help me come up with game ideas
After some back-and-forth, I decided to make a Bomberman clone!
notion image
 
I also wanted to re-theme the game to add some flair.
S: “what’s a wholesome version of bomberman?”
C: Here are garden-themed variants that mirror classic Bomberman power-ups:
  • Standard Growth Powder (Basic "Bomb")
✨ -> 🌱🌱🌱 -> 🌿 -> 🥀 🌱●🌱 (cross pattern) 🌱🌱🌱
  • Vine Spreader ("Pierce Bomb" that goes through walls)
✨ -> 🌿🌿🌿 -> 🍃 -> 🥀 🌿●🌿 (keeps going until wall) 🌿🌿🌿
  • Flower Cluster ("Large Bomb" with bigger radius)
✨ -> 🌸🌸🌸 -> 🌺 -> 🥀 🌸🌸🌸🌸🌸 🌸🌸●🌸🌸 (5x5 area) 🌸🌸🌸🌸🌸 🌸🌸🌸
Power-ups could be:
  • 🌱 Extra spread range
  • ⚡ Faster growth speed
  • 🎒 Carry more growth powder
  • 👟 Move faster through garden
  • 🛡️ Temporary immunity to other's plants
The walls could be:
  • 🌳 Trees (indestructible)
  • 🪴 Potted plants (breakable)
  • 🌿 Hedges (standard)
Full transcript with Claude
notion image

ii. one-shot with Claude

Next, I wanted to see how far we could “one-shot” the program with Claude, so we started out with —
S: ok using the cluster bombs
✨ -> 🌸🌸🌸 -> 🌺 -> 🥀 🌸🌸🌸🌸🌸 🌸🌸●🌸🌸 (5x5 area) 🌸🌸🌸🌸🌸 🌸🌸🌸
I want to make a bomberman clone in JS
react, typescript, text-only, two players can use WASD or arrows, and left shift or right shift to move. emoji
notion image
After setting the environment up properly (npm, nextjs, etc), we got this game!
Video preview
This is a nice start, and we got here in only 10 mins, but clearly the gameplay wasn’t quite there yet.
Issues included:
  • Movement: Pressing keys would override the other player’s movement. Players could not hold down an arrow key to move continuously in one direction.
  • Explosions: Walking into explosions was not hurting players. Bombs would not trigger other bombs.
  • Powerups: Even though the code supported powerups, they weren’t spawning!
  • Features: There were no pets or other fun features.
Initial code
# src/app/page.tsx import Game from "@/components/game/game" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" export default function Home() { return ( <main className="container mx-auto p-4 min-h-screen flex justify-center"> <Card className="w-full max-w-3xl"> <CardHeader> <CardTitle className="text-center">Bomberman</CardTitle> </CardHeader> <CardContent> <Game /> </CardContent> </Card> </main> )
# src/components/game/game.tsx "use client" import React, { useState, useEffect } from "react" import { Alert, AlertDescription } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" const GRID_SIZE = 15 const INITIAL_BOMBS_MAX = 1 const INITIAL_BOMB_RANGE = 1 const INITIAL_SPEED = 1 const CELL_EMPTY = "⬜" const CELL_WALL = "🟫" const CELL_EXPLOSION = "🌸" const CELL_BOMB = "💣" const CELL_POWERUP_SPEED = "⚡️" const CELL_POWERUP_RANGE = "💪" const CELL_POWERUP_BOMB = "➕" const POWERUPS = [CELL_POWERUP_SPEED, CELL_POWERUP_RANGE, CELL_POWERUP_BOMB] const POWERUP_SPAWN_CHANCE = 0.1 const POWERUP_SPAWN_INTERVAL = 5000 const PLAYER_1 = "😀" const PLAYER_2 = "😎" const PLAYER_DEAD = "💀" type Player = { x: number y: number bombs: Bomb[] maxBombs: number bombRange: number speed: number alive: boolean lastMove: number } type Bomb = { x: number y: number timer: number range: number } type PowerUp = "⚡️" | "💪" export default function Game() { const [grid, setGrid] = useState<(string | PowerUp)[][]>( Array(GRID_SIZE) .fill(null) .map(() => Array(GRID_SIZE).fill(CELL_EMPTY)) ) const [players, setPlayers] = useState<{ p1: Player p2: Player }>({ p1: { x: 1, y: 1, bombs: [], maxBombs: INITIAL_BOMBS_MAX, bombRange: INITIAL_BOMB_RANGE, speed: INITIAL_SPEED, alive: true, lastMove: 0, }, p2: { x: GRID_SIZE - 2, y: GRID_SIZE - 2, bombs: [], maxBombs: INITIAL_BOMBS_MAX, bombRange: INITIAL_BOMB_RANGE, speed: INITIAL_SPEED, alive: true, lastMove: 0, }, }) const [gameOver, setGameOver] = useState(false) const isPowerUp = (cell: string | PowerUp): cell is PowerUp => { return POWERUPS.includes(cell) } useEffect(() => { resetGame() }, []) const resetGame = () => { const newGrid = Array(GRID_SIZE) .fill(null) .map(() => Array(GRID_SIZE).fill(CELL_EMPTY)) for (let i = 0; i < GRID_SIZE; i++) { for (let j = 0; j < GRID_SIZE; j++) { if (i % 2 === 0 && j % 2 === 0) { newGrid[i][j] = CELL_WALL } } } newGrid[1][1] = CELL_EMPTY newGrid[GRID_SIZE - 2][GRID_SIZE - 2] = CELL_EMPTY setGrid(newGrid) setPlayers({ p1: { x: 1, y: 1, bombs: [], maxBombs: INITIAL_BOMBS_MAX, bombRange: INITIAL_BOMB_RANGE, speed: INITIAL_SPEED, alive: true, lastMove: 0, }, p2: { x: GRID_SIZE - 2, y: GRID_SIZE - 2, bombs: [], maxBombs: INITIAL_BOMBS_MAX, bombRange: INITIAL_BOMB_RANGE, speed: INITIAL_SPEED, alive: true, lastMove: 0, }, }) setGameOver(false) } useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (gameOver) return e.preventDefault() const key = e.key.toLowerCase() if (["w", "a", "s", "d"].includes(key)) { movePlayer("p1", key) } else if (e.key === "q") { placeBomb("p1") } if (["arrowup", "arrowleft", "arrowdown", "arrowright"].includes(key)) { const dirMap: Record<string, string> = { arrowup: "w", arrowleft: "a", arrowdown: "s", arrowright: "d", } movePlayer("p2", dirMap[key]) } else if (e.key === "Shift" && e.location === 2) { placeBomb("p2") } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [players, grid, gameOver]) const movePlayer = (player: "p1" | "p2", direction: string) => { const currentTime = Date.now() if (!players[player].alive) return const moveDelay = 200 / players[player].speed if (currentTime - players[player].lastMove < moveDelay) return const { x, y } = players[player] let newX = x let newY = y switch (direction) { case "w": newY = Math.max(0, y - 1) break case "s": newY = Math.min(GRID_SIZE - 1, y + 1) break case "a": newX = Math.max(0, x - 1) break case "d": newX = Math.min(GRID_SIZE - 1, x + 1) break } const targetCell = grid[newY][newX] if (targetCell === CELL_WALL || targetCell === CELL_BOMB) return if (isPowerUp(targetCell)) { setPlayers((prev) => ({ ...prev, [player]: { ...prev[player], ...(targetCell === CELL_POWERUP_SPEED ? { speed: prev[player].speed + 0.5 } : targetCell === CELL_POWERUP_RANGE ? { bombRange: prev[player].bombRange + 1 } : {}), }, })) } setGrid((prev) => { const newGrid = [...prev] if (isPowerUp(targetCell)) { newGrid[newY][newX] = CELL_EMPTY } return newGrid }) setPlayers((prev) => ({ ...prev, [player]: { ...prev[player], x: newX, y: newY, lastMove: currentTime, }, })) } const placeBomb = (player: "p1" | "p2") => { const { x, y, bombs, maxBombs, bombRange, alive } = players[player] if (bombs.length >= maxBombs || !alive) return if (grid[y][x] === CELL_BOMB) return const newBomb = { x, y, timer: 3, range: bombRange } setPlayers((prev) => ({ ...prev, [player]: { ...prev[player], bombs: [...prev[player].bombs, newBomb] }, })) setGrid((prev) => { const newGrid = [...prev] newGrid[y][x] = CELL_BOMB return newGrid }) setTimeout(() => { explodeBomb(player, newBomb) }, 2000) } const trySpawnPowerUp = (x: number, y: number) => { setGrid((prev) => { const newGrid = [...prev] if (newGrid[y][x] === CELL_EMPTY) { newGrid[y][x] = POWERUPS[Math.floor(Math.random() * POWERUPS.length)] } return newGrid }) } const checkPlayerDeath = (explosionCoords: Array<[number, number]>) => { const newPlayers = { ...players } let someoneKilled = false explosionCoords.forEach(([x, y]) => { if (players.p1.x === x && players.p1.y === y && players.p1.alive) { newPlayers.p1.alive = false someoneKilled = true } if (players.p2.x === x && players.p2.y === y && players.p2.alive) { newPlayers.p2.alive = false someoneKilled = true } }) if (someoneKilled) { setPlayers(newPlayers) if (!newPlayers.p1.alive || !newPlayers.p2.alive) { setGameOver(true) } } } const explodeBomb = (player: "p1" | "p2", bomb: Bomb) => { const { x, y, range } = bomb const explosionCoords: Array<[number, number]> = [] setPlayers((prev) => ({ ...prev, [player]: { ...prev[player], bombs: prev[player].bombs.filter((b) => b !== bomb), }, })) const directions = [ [0, 1], [0, -1], [1, 0], [-1, 0], ] setGrid((prev) => { const newGrid = [...prev] if (newGrid[y][x] !== CELL_WALL) { newGrid[y][x] = CELL_EXPLOSION explosionCoords.push([x, y]) } directions.forEach(([dx, dy]) => { for (let i = 1; i <= range; i++) { const newX = x + dx * i const newY = y + dy * i if (newX < 0 || newX >= GRID_SIZE || newY < 0 || newY >= GRID_SIZE) break if (newGrid[newY][newX] === CELL_WALL) break newGrid[newY][newX] = CELL_EXPLOSION explosionCoords.push([newX, newY]) } }) return newGrid }) checkPlayerDeath(explosionCoords) setTimeout(() => { setGrid((prev) => { const clearedGrid = prev.map((row, y) => row.map((cell, x) => { if (cell === CELL_EXPLOSION) { return CELL_EMPTY } return cell }) ) return clearedGrid }) }, 300) } useEffect(() => { setTimeout(() => { if (Math.random() < POWERUP_SPAWN_CHANCE) { trySpawnPowerUp( Math.floor(Math.random() * GRID_SIZE), Math.floor(Math.random() * GRID_SIZE) ) } }, POWERUP_SPAWN_INTERVAL) }, []) const getWinnerText = () => { if (!players.p1.alive && !players.p2.alive) return "It's a draw!" if (!players.p1.alive) return "Player 2 wins! 🎉" if (!players.p2.alive) return "Player 1 wins! 🎉" return "" } const getPlayerStats = (player: "p1" | "p2") => { return `Speed: ${players[player].speed.toFixed(1)}x | Bomb Range: ${ players[player].bombRange }x1` } return ( <div className="space-y-4"> <div className="flex justify-between mb-4"> <div className="space-y-2"> <Badge variant={players.p1.alive ? "default" : "destructive"}> Player 1: WASD + Q (bomb) </Badge> <div className="text-sm">{getPlayerStats("p1")}</div> </div> <div className="space-y-2"> <Badge variant={players.p2.alive ? "default" : "destructive"}> Player 2: Arrows + Right Shift (bomb) </Badge> <div className="text-sm text-right">{getPlayerStats("p2")}</div> </div> </div> <div className="flex justify-center"> <div className="grid grid-cols-1 gap-0 bg-secondary p-4 rounded-lg overflow-auto max-h-[80vh]"> {grid.map((row, y) => ( <div key={y} className="flex"> {row.map((cell, x) => ( <div key={`${x}-${y}`} className="w-8 h-8 flex items-center justify-center relative" > {cell === CELL_BOMB && ( <div className="absolute text-xl z-0">💣</div> )} <div className="z-10"> {players.p1.x === x && players.p1.y === y ? players.p1.alive ? PLAYER_1 : PLAYER_DEAD : players.p2.x === x && players.p2.y === y ? players.p2.alive ? PLAYER_2 : PLAYER_DEAD : cell !== CELL_BOMB ? cell : null} </div> </div> ))} </div> ))} </div> </div> {gameOver && ( <Alert> <AlertDescription className="flex items-center justify-between"> {getWinnerText()} <Button onClick={resetGame}>Play Again</Button> </AlertDescription> </Alert> )} </div> ) }
See rest of codebase:
bomberman with help from claude :-)

iii. refining gameplay with Cursor

Next, we swapped to chatting in Cursor, using its Chat and Autocomplete features.
Fixing keyboard handling
S: “ok now I need to fix keyboard handling. it seems like they're obstructing each other while playing”
Cursor responded with a rewritten keyboard handler.
notion image
Improving bomb animation
This one took a few tries…
S: now I want the bomb to turn more red as it gets closer to exploding C: [code] S: that's not actually turning red C: [code] S: ok I want it to be even more drastic and even turn red C: [code] S: this isn't turning red. am I missing soemthing? it's emoji text C: [code] S: naw that doesn't work. css filters should work, right? C: [code]

Add an owl pet
S: I want to add another powerup, which is an owl, which serves as two purposes. 1. it makes you faster, 2. it gives you another life.
it should only spawn with a 10% chance compared with the others. it is a Pet
notion image

Adding grass
I also wanted to add grass that would break to spawn powerups!
I did this with a mix of manual coding and Cursor’s autocomplete.
Writing descriptive comments helped Cursor give stronger auto-complete suggestions.
Writing descriptive comments helped Cursor give stronger auto-complete suggestions.

This process continued for fixing various bugs and gameplay improvements..
S: i don't think we should disallow colliding players
S: now let's make all the font sizes bigger so it's easier to play
S: ok I also want the EXPLOSION to transition in and out too
S: ok now I want to add a kill count for each player too … S: ok we dont want to count suicides as kills

After a few hours of this, we ended up here!
Video preview
All of the above was accomplished in just one session of coding!

There was still a lot more tuning and improvements to be made though, including:
  • Animation durations and spawn rates needed some more tuning.
  • Explosions would happen after the game had already ended, leading to a change in the winner.
  • Explosion chaining was buggy and led to too much of a delay on subsequent explosions.
  • There weren’t any invulnerability frames after a bomb hurting a pet, so you’d just lose immediately when your pet died if there were two bombs.

After some more refinement, the gameplay looked like this:
Video preview

learnings

About coding with LLMs
  • LLMs are fantastic ideation and debugging partners!
    • “Give me 5 other ideas” is a simple and easy follow-up prompt when given a response that didn’t quite hit the mark.
  • Just send messages! It doesn’t need to be perfect.
    • You can always reject an LLM’s suggestion or steer it a different direction, so it’s ok to send questions, ideas, thoughts that are unrefined.
  • Always validate!
    • LLMs will confidently say things and its answers may be wrong in subtle ways.
    • Whenever you’re not sure about why Cursor came up with an output, you can always ask “why did you come up with that answer?” and it’ll give some explanations that’ll build your technical greath.
  • LLM code often optimizes for problem-solving over code quality.
    • Often the responses optimize for “did the code address the problem in a very literal way” and not necessarily “did the code follow best practices and solve the problem with long-term scalable abstractions”
    • But one can and should prompt LLMs to solve these problems separately. (e.g. “Please refactor this component into multiple well-abstracted React components.”)
About coding the gameplay
  • Gameplay mechanics require a lot of tuning and building intuition for what feels right!
    • Even after tuning the bomb delays and power up spawn rates, I think the game isn’t quite there yet.
    • Constant-ifying and co-locating all relevant gameplay numbers is helpful to rapidly modify them.
  • Animating is hard…
  • There’s a big difference in enjoyment between gameplay mechanics that are “almost there” vs “just right”.
    • If the bomb explosion duration is off compared to the animations, or popups weren’t spawning often enough, then the gameplay was much worse as it subverted expectations. 😭
  • Play testing and rapid feedback loops are very helpful!
    • You can record game videos (e.g. using CleanShot) and note any subtle bugs and issues. This was especially helpful for tuning animations.
    • You can also ask players for feedback around what felt intuitive and how they felt about different interactions!

Thanks for reading!