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
, andaspect-ratio
. Thecanvas
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 fillRect
, arc
, fillText
).
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 thegameStep
function everyGAME_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 viadrawGame()
.
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 modifydynamicMaze
as dots are eaten (changing2
to5
), 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 (gridX
, gridY
) 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
anddy
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 eachgameStep
.- Determine Direction: Decide the
dx
,dy
for the next step (based on input buffer for Pac-Man, or AI logic for ghosts). - Calculate Target Tile:
intendedNextGridX = currentGridX + dx
,intendedNextGridY = currentGridY + dy
. - Check Collision: Use
isPassable(intendedNextGridX, intendedNextGridY)
to see if the target tile is a wall. - Update Position: If passable, update
gridX = intendedNextGridX
,gridY = intendedNextGridY
. - 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). - Stop if Blocked: If the target tile is not passable, set
dx = 0
anddy = 0
to stop movement in that direction.
- Determine Direction: Decide the
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 thedynamicMaze
but handles the X-coordinate wrapping for the tunnels before checking the array bounds or returning the tile type.isPassable
usesgetTile
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
andmoveGhosts
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
updatespacman.nextDx
andpacman.nextDy
. This acts as an input buffer.movePacman
(running everygameStep
) checks this buffer. If the buffered direction represents a valid turn from the current tile, it updates Pac-Man's actual direction (pacman.dx
,pacman.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 checksisPassable
and avoids immediate 180-degree turns unless necessary). - Calculates the distance (using
Math.hypot
) from the center of each potential next tile to thetargetX
,targetY
. - Chooses the move (
{dx, dy}
) that results in the shortest distance (with a slight penalty for reversing).
- Gets all valid moves from the ghost's current tile (using
- Usage:
- When exiting (
inBase && canLeavePen
), target isghostDoorTile
. - When returning (
eaten
), target isghostDoorTile
first, thenspawnGridX/Y
once inside. - When chasing (normal state), target is
pacman.gridX
,pacman.gridY
. - When frightened, movement is random among valid moves.
- When exiting (
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 indynamicMaze
to an empty path (5
), decrementsdotCount
, and potentially triggers fruit or power-up mode. - Fruit: The
fruit
object stores the current fruit's state.activateFruit
is called byhandleTileContent
whendotsEaten
reaches a threshold (FRUIT_APPEAR_DOT_COUNTS
).gameStep
decrements the fruit's activetimer
.handleTileContent
also checks if Pac-Man lands on the active fruit's tile to award points. - Lives:
lives
variable decremented inhandleLifeLost
. The game ends (endGame
) iflives <= 0
.resetPositionsAfterLifeLost
resets characters for the next life, crucially resettinggameSteps
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 ifdotCount <= 0
; if so, it either callsloadLevel(currentLevelIndex + 1)
orendGame(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 eachgameStep
. It clears the canvas (clearRect
) and calls specific drawing functions (drawMaze
,drawFruit
,drawPacman
,drawGhost
).drawMaze()
: Iterates throughdynamicMaze
. CallsdrawWall
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()
andctx.restore()
withctx.translate()
andctx.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()
orctx.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
oreaten
state. - Pac-Man mouth animation uses a simple timer (
pacmanAnimationTimer
) and boolean (mouthOpen
).
- Calculate pixel coordinates from grid coordinates using
drawFruit()
: Draws the fruit emoji (e.g.,🍒
) at its grid location iffruit.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 (intendedNextGridX
, intendedNextGridY
). 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.gridX
, pacman.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 gameStep
, powerPelletActiveSteps
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