Monday, 7 April 2025

A tutorial on building a Pacman game, by Google's Gemini 2.5 Pro (no human edits)

Deconstructing Pac-Man: A JavaScript Game Dev Tutorial

Welcome, aspiring game developers! Ever wondered how classic arcade games like Pac-Man work under the hood? In this tutorial, we'll break down the creation of a simple, tile-based Pac-Man clone using just HTML, CSS, and vanilla JavaScript with the HTML Canvas API.

We went through quite an iterative process to get here, fixing bugs related to movement, collision, ghost behavior, and level design. That's a normal part of development! This tutorial focuses on the final working baseline version.

We'll explore core concepts like the game loop, maze representation, entity movement, collision detection, state management, and basic rendering.

Source code here.



1. Project Setup: HTML & CSS

The foundation is a simple HTML file.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Tile-Based Pac-Man</title>
    <style> /* CSS rules go here */ </style>
</head>
<body>
    <div id="scoreBoard">...</div>
    <div id="gameContainer">
        <canvas id="gameCanvas"></canvas>
         <div id="message">...</div>
    </div>
    <script> /* JavaScript code goes here */ </script>
</body>
</html>
  • HTML: Sets up the basic page structure, including div elements for the scoreboard (scoreBoard), the main game area (gameContainer), and overlay messages (message). The crucial element is <canvas id="gameCanvas">.
  • CSS: Handles the visual presentation. We use CSS Flexbox to center the game, import the "Press Start 2P" font for an arcade feel, style the scoreboard/messages, and make the #gameContainer responsive using percentages, max-width, and aspect-ratio. The canvas element itself is styled to fill its container (width: 100%; height: 100%;).

2. The Canvas Element

The <canvas> element is our digital easel. JavaScript provides access to its 2D rendering context, which is an object with methods for drawing shapes, text, and images.

const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d'); // Get the 2D drawing context

All visual elements of the game (maze, characters, dots, etc.) are drawn onto this canvas using methods of the ctx object (like fillRectarcfillText).

3. Core Game Loop

Most action games rely on a game loop – a function that runs repeatedly, updating the game state and redrawing the screen each time. This creates the illusion of motion and interactivity.

In this tile-based version, we use setInterval:

// In loadLevel()
if (gameIntervalId) clearInterval(gameIntervalId); // Clear previous loop
gameIntervalId = setInterval(gameStep, GAME_SPEED_MS); // Start new loop

// The main loop function
function gameStep() {
    // Ignore if paused or game over
    if (gamePaused || gameOver || !gameStarted) return;

    gameSteps++; // Increment game timer

    // Update timers (ghost release, power pellet, fruit)
    // ...

    // Update entity positions
    movePacman();
    moveGhosts();

    // Check for interactions
    checkCollisions();
    checkWinCondition(); // Check if level/game is won

    // Redraw the entire game state
    drawGame();
}
  • setInterval(gameStep, GAME_SPEED_MS) calls the gameStep function every GAME_SPEED_MS milliseconds (150ms in our case).
  • Each gameStep represents one discrete turn or tick where things can move one tile.
  • Inside gameStep, we update timers, move characters, check for collisions/wins, and finally redraw the screen via drawGame().

4. Representing the World: The Maze

How do we store the maze layout? A 2D array is a natural fit!

// 1 = wall, 2 = dot, 3 = power pellet, 5 = empty path, 6 = Ghost door, etc.
const mazeLayout1 = [
    [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
    [1,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,1],
    // ... more rows ...
    [5,5,5,5,5,2,5,5,5,5,5,5,5,5,5,2,5,5,5,5,5], // Tunnel row
    [1,1,1,1,1,2,1,5,1,5,6,5,1,5,1,2,1,1,1,1,1], // Ghost Pen row with door (6)
    [1,5,5,5,5,2,5,5,1,4,4,4,1,5,5,2,5,5,5,5,1], // Ghost Pen row with spawns (4)
    // ... more rows ...
];

const ALL_LEVELS = [mazeLayout1, mazeLayout2, mazeLayout3]; // Store multiple levels

let dynamicMaze = []; // Holds the *current* state of the maze (dots get eaten)

// In loadLevel(levelIndex):
dynamicMaze = JSON.parse(JSON.stringify(ALL_LEVELS[currentLevelIndex])); // Deep copy layout
  • Each number represents a different type of tile.
  • We store multiple layouts in ALL_LEVELS.
  • dynamicMaze is created as a copy of the current level's layout at the start of each level (loadLevel). This is crucial because we modify dynamicMaze as dots are eaten (changing 2 to 5), and we need the original layout intact for the next level or restart. JSON.parse(JSON.stringify(...)) is a common way to create a deep copy of a simple array like this.

5. Meet the Cast: Entities (Pac-Man & Ghosts)

We represent Pac-Man and each ghost as JavaScript objects, storing their properties (state).

// Pac-Man State
pacman = {
    gridX: 10, gridY: 16, // Current position (tile coordinates)
    dx: 0, dy: 0,         // Current movement direction (-1, 0, or 1 tile step)
    nextDx: 0, nextDy: 0, // Buffered direction from player input
    radius: 8             // Visual size (calculated from TILE_SIZE)
};

// Ghost State (Example)
ghosts.push({
    id: i, gridX: 9, gridY: 10, // Position
    dx: 1, dy: 0,              // Current movement direction
    spawnGridX: 9, spawnGridY: 10, // Original spawn point
    color: '#ff0000', originalColor: '#ff0000', // Colors
    frightened: false,        // Is it scared?
    eaten: false,             // Has it been eaten?
    inBase: true,             // Is it in the ghost pen?
    canLeavePen: false,       // Is it allowed to leave yet?
    radius: 8
});

Storing state in objects makes it easy to manage and update each character independently. We use grid coordinates (gridXgridY) for logic and convert them to pixel coordinates only for drawing.

6. Movement on a Grid

Since this is tile-based, movement happens in discrete steps from one tile center to the next.

  • Direction: dx and dy store the intended move for the next step (-1, 0, or 1 in each axis). For example, dx: 1, dy: 0 means "move right one tile".
  • movePacman() / moveGhosts(): These functions run each gameStep.
    1. Determine Direction: Decide the dxdy for the next step (based on input buffer for Pac-Man, or AI logic for ghosts).
    2. Calculate Target Tile: intendedNextGridX = currentGridX + dxintendedNextGridY = currentGridY + dy.
    3. Check Collision: Use isPassable(intendedNextGridX, intendedNextGridY) to see if the target tile is a wall.
    4. Update Position: If passable, update gridX = intendedNextGridXgridY = intendedNextGridY.
    5. Handle Wrapping: After updating the position, check if gridX went off-screen (less than 0 or >= width) and wrap it to the other side if necessary (for the tunnel).
    6. Stop if Blocked: If the target tile is not passable, set dx = 0 and dy = 0 to stop movement in that direction.

7. Collision Detection: Walls & Wraps

Collision detection is simplified in a tile-based system. We primarily need to know if the next tile an entity wants to move to is a wall.

// Checks the type of tile at given grid coordinates, handling tunnel wraps
function getTile(gridX, gridY) {
    let checkX = gridX;
    // Wrap X coordinate for checking the array edges (tunnel)
    if (gridX < 0) checkX = MAZE_WIDTH_TILES - 1;
    else if (gridX >= MAZE_WIDTH_TILES) checkX = 0;

    // Check Y bounds first
    if (gridY < 0 || gridY >= MAZE_HEIGHT_TILES) return 1; // Wall
    // Check potentially wrapped X bounds
    if (checkX < 0 || checkX >= MAZE_WIDTH_TILES) return 1; // Wall

    if (!dynamicMaze || dynamicMaze.length === 0 || !dynamicMaze[gridY]) return 1; // Safety check

    // Access the maze using the correct Y and the potentially wrapped X
    return dynamicMaze[gridY][checkX];
}

// Checks if a tile is passable for an entity
function isPassable(gridX, gridY, entityType = 'pacman') {
    const tileType = getTile(gridX, gridY); // Uses the wrapping logic internally!
    if (tileType === 1) return false; // Wall
    // Ghost door (6) blocks Pac-Man but not ghosts
    if (tileType === 6 && entityType === 'pacman') return false;
    // Allow passage on empty paths (5) and tiles with items (2, 3)
    return true;
}
  • getTile is the key: it checks the dynamicMaze but handles the X-coordinate wrapping for the tunnels before checking the array bounds or returning the tile type.
  • isPassable uses getTile and checks for walls (1) or the ghost door (6, only for Pac-Man).
  • The actual wrapping of the entity's position happens after a successful move in the movePacman and moveGhosts functions.

8. Handling Player Input

We need to capture keyboard input and translate it into Pac-Man's intended movement.

// Event Listener (usually set up once on load)
window.addEventListener('keydown', handleKeyDown);

function handleKeyDown(e) {
    // Start game on first valid key press
    // ... (code to call loadLevel(0)) ...

    // Process movement input during gameplay
    if (gameStarted && !gamePaused && !gameOver) {
        let processed = processInput(e.key);
        if (processed) e.preventDefault(); // Prevent page scrolling
    }
}

// Stores the intended direction in the buffer (nextDx/nextDy)
function processInput(key) {
    let processed = true;
    switch (key) {
        case 'ArrowUp': case 'w': case 'W': pacman.nextDx = 0; pacman.nextDy = -1; break;
        // ... other directions ...
        default: processed = false;
    }
    return processed;
}

// In movePacman():
// 1. Check buffer (nextDx/Dy)
if (pacman.nextDx !== 0 || pacman.nextDy !== 0) {
    // Check if turn is valid
    if (isPassable(pacman.gridX + pacman.nextDx, pacman.gridY + pacman.nextDy, 'pacman')) {
        // Apply buffer to current direction (dx/dy)
        pacman.dx = pacman.nextDx;
        pacman.dy = pacman.nextDy;
        // Clear buffer ONLY if applied
        pacman.nextDx = 0;
        pacman.nextDy = 0;
    } // else: Keep buffer if turn invalid
    // ...
}
// 2. Try moving in current direction (dx/dy)
// ...
  • An event listener captures keydown events.
  • processInput updates pacman.nextDx and pacman.nextDy. This acts as an input buffer.
  • movePacman (running every gameStep) checks this buffer. If the buffered direction represents a valid turn from the current tile, it updates Pac-Man's actual direction (pacman.dxpacman.dy) and clears the buffer. If the turn isn't valid yet, the buffer is kept until the turn is possible. This prevents missed inputs and makes turning feel more responsive compared to clearing the buffer every step.

9. Ghost AI & Logic

Making ghosts seem intelligent is complex. This version uses simplified logic.

Ghost States (Base, Eaten, Frightened)

Ghosts have several states tracked by boolean flags:

  • inBase: True if inside the ghost pen. Dictates exit/pacing logic.
  • canLeavePen: True if allowed to leave (controlled by stagger timer or respawn).
  • eaten: True after Pac-Man eats them during a power pellet phase. Dictates return-to-base logic.
  • frightened: True when Pac-Man eats a power pellet. Changes color and AI behavior.

Staggered Exit

To prevent ghosts from clumping at the start, they leave the pen one by one based on a timer (gameSteps compared to GHOST_RELEASE_STEPS).

// In gameStep():
gameSteps++;
for (let i = 1; i < ghosts.length; i++) {
    if (ghosts[i] && ghosts[i].inBase && !ghosts[i].canLeavePen && gameSteps >= GHOST_RELEASE_STEPS[i]) {
        ghosts[i].canLeavePen = true;
    }
}

// In moveGhosts():
if (ghost.inBase && !ghost.canLeavePen) {
    // Pacing logic...
} else if (ghost.inBase && ghost.canLeavePen) {
    // Exit logic (pathfind to door)...
} // etc

Ghosts waiting inside (!canLeavePen) perform simple horizontal pacing.

Basic Pathfinding

  • chooseBestMove(ghost, targetX, targetY): A simple pathfinding helper.
    • Gets all valid moves from the ghost's current tile (using getValidGhostMoves, which checks isPassable and avoids immediate 180-degree turns unless necessary).
    • Calculates the distance (using Math.hypot) from the center of each potential next tile to the targetXtargetY.
    • Chooses the move ({dx, dy}) that results in the shortest distance (with a slight penalty for reversing).
  • Usage:
    • When exiting (inBase && canLeavePen), target is ghostDoorTile.
    • When returning (eaten), target is ghostDoorTile first, then spawnGridX/Y once inside.
    • When chasing (normal state), target is pacman.gridXpacman.gridY.
    • When frightened, movement is random among valid moves.

10. Game Mechanics: Dots, Pellets, Fruit, Lives, Levels

  • Dots/Pellets: handleTileContent checks the tile Pac-Man moves onto. If it's a dot (2) or pellet (3), it updates the score, changes the tile in dynamicMaze to an empty path (5), decrements dotCount, and potentially triggers fruit or power-up mode.
  • Fruit: The fruit object stores the current fruit's state. activateFruit is called by handleTileContent when dotsEaten reaches a threshold (FRUIT_APPEAR_DOT_COUNTS). gameStep decrements the fruit's active timerhandleTileContent also checks if Pac-Man lands on the active fruit's tile to award points.
  • Lives: lives variable decremented in handleLifeLost. The game ends (endGame) if lives <= 0resetPositionsAfterLifeLost resets characters for the next life, crucially resetting gameSteps so ghost timers restart correctly.
  • Levels: ALL_LEVELS holds multiple maze arrays. currentLevelIndex tracks the current level. loadLevel initializes the game state for a specific level index. checkWinCondition checks if dotCount <= 0; if so, it either calls loadLevel(currentLevelIndex + 1) or endGame(true) if it was the last level.

11. Rendering: Bringing it to Life

All drawing happens on the HTML Canvas using its 2D context (ctx).

  • drawGame(): The main drawing orchestrator, called at the end of each gameStep. It clears the canvas (clearRect) and calls specific drawing functions (drawMazedrawFruitdrawPacmandrawGhost).
  • drawMaze(): Iterates through dynamicMaze. Calls drawWall for walls (1). For non-walls, it draws a black background first, then draws dots (2), power pellets (3), or the ghost door (6) on top. Empty paths (5) remain black (including the tunnel paths).
  • drawPacman() / drawGhost():
    • Calculate pixel coordinates from grid coordinates using gridToPixel(coord) = coord * TILE_SIZE + TILE_SIZE / 2.
    • Use ctx.save() and ctx.restore() with ctx.translate() and ctx.rotate() to easily draw characters facing the correct direction.
    • Use ctx.arc() to draw the main body (Pac-Man's circle/mouth, Ghost's head).
    • Use ctx.lineTo() or ctx.quadraticCurveTo() for other shapes (Pac-Man's mouth line, Ghost's wavy bottom).
    • Draw eyes using smaller ctx.arc().
    • Ghost color changes based on frightened or eaten state.
    • Pac-Man mouth animation uses a simple timer (pacmanAnimationTimer) and boolean (mouthOpen).
  • drawFruit(): Draws the fruit emoji (e.g., 🍒) at its grid location if fruit.active is true.

12. Frequently Asked Questions (FAQ)

Q: How does the game loop keep running?
A: We use setInterval(gameStep, GAME_SPEED_MS). This built-in JavaScript function tells the browser to execute our gameStep function repeatedly, approximately every GAME_SPEED_MS milliseconds (e.g., 150ms). Each call to gameStep updates and redraws one step of the game.

Q: How does Pac-Man (or a ghost) know if it will hit a wall?
A: Before moving, the code calculates the target grid coordinate (intendedNextGridXintendedNextGridY). It then calls isPassable(targetX, targetY). This function uses getTile(targetX, targetY) to look up the type of tile in the dynamicMaze array at that coordinate (handling tunnel wraps). If the tile type is a wall (1), isPassable returns false, and the movement is stopped.

Q: How does the tunnel work? How does Pac-Man wrap around?
A: There are two parts. First, the getTile function automatically checks the opposite side of the maze if the requested gridX is less than 0 or >= width. This allows isPassable to correctly see that the move into the tunnel is valid (since the tile on the other side is path 5). Second, after movePacman successfully updates pacman.gridX to an off-screen value (like -1 or 21), separate lines of code immediately check if pacman.gridX is out of bounds and snap it to the coordinate on the opposite side (0 or 20).

Q: Why do my key presses sometimes feel delayed?
A: The game runs in discrete steps every 150ms (GAME_SPEED_MS). Your key press is recorded instantly into a buffer (nextDx/nextDy). However, this buffer is only checked and applied to Pac-Man's actual direction (dx/dy) at the start of the next game step, if the turn is valid from the current tile. We made a fix so the buffer isn't cleared if the turn isn't immediately possible, making it remember your input until the turn can be made. This is much better than losing input but still tied to the 150ms game tick.

Q: How do the ghosts chase Pac-Man?
A: In their normal state, ghosts use the chooseBestMove function. This function looks at all valid moves (excluding immediate 180-degree turns unless necessary). For each valid move, it calculates the distance to Pac-Man's current tile (pacman.gridXpacman.gridY). It then chooses the move ({dx, dy}) that minimizes this distance. It's a simple "greedy" algorithm – always try to get closer. (Note: Real Pac-Man uses more complex targeting!).

Q: How do ghosts leave the pen one by one?
A: Each ghost has a canLeavePen flag. Only the first ghost starts with this as true. A global gameSteps counter increases each game tick. When gameSteps reaches predefined thresholds (GHOST_RELEASE_STEPS), the corresponding ghost's canLeavePen flag is set to true. The moveGhosts function only runs the "exit pen" logic (pathfinding to the door) for ghosts where inBase and canLeavePen are both true. Ghosts waiting just pace back and forth.

Q: How does the Power Pellet work?
A: When Pac-Man eats a pellet (tile 3), activatePowerPellet runs. It sets a counter powerPelletActiveSteps based on the desired duration and game speed. It also loops through ghosts currently outside the pen and sets their frightened flag to true, reversing their direction. Each gameSteppowerPelletActiveSteps decrements. When it hits zero, the ghosts' frightened flag is turned off. While frightened is true, drawGhost uses the blue/flashing color, and moveGhosts uses random movement. checkCollisions handles Pac-Man eating them (eaten = true).

Q: How are the different levels handled?
A: We store multiple maze layouts in the ALL_LEVELS array. A variable currentLevelIndex keeps track of which level we're on. When the game starts or restarts, loadLevel(0) is called. When checkWinCondition finds dotCount is zero, instead of ending the game, it increments currentLevelIndex (if not the last level) and calls loadLevel(currentLevelIndex) to load the next maze, reset positions, reset timers, etc.


13. Conclusion & Next Steps

This tile-based Pac-Man clone demonstrates many fundamental game development concepts using plain JavaScript: state management, a game loop, grid-based movement, collision detection, simple AI, and canvas rendering. By breaking the problem down into smaller pieces (maze data, entity state, movement logic, drawing logic), we can build surprisingly complex behavior.

Potential Improvements:

  • Smoother Animation: Implement pixel-based movement instead of tile-based (more complex collision needed).
  • Ghost AI: Give each ghost a unique personality (Blinky, Pinky, Inky, Clyde behaviors using different targeting rules).
  • Sound Effects: Add sounds for munching, death, power-ups, etc., using the Web Audio API.
  • Difficulty Scaling: Increase game speed or improve ghost AI on later levels.
  • More Levels & Fruits: Add more diverse maze layouts and bonus items.
  • Touch Controls: Implement controls for mobile devices.

Hopefully, this breakdown helps you understand how this Pac-Man game works. Happy coding!

No comments:

Post a Comment