In Part 1 we built the game, and in Part 2 we added score tracking. But our ghosts still wander randomly — not very scary. In this tutorial, we will give each ghost a unique chase personality, just like the original Pac-Man.
In the classic Pac-Man, each ghost has a different targeting strategy:
We will implement simplified versions of these behaviors that work well in our tile-based game.
Expand the ghost objects to include a name, color, and AI type. Each ghost gets a target property that the AI updates every frame.
let ghosts = [
{ x: 9, y: 7, dir: 0, color: '#FF0000', name: 'blinky',
ai: 'chase', target: {x:0, y:0} },
{ x: 10, y: 7, dir: 2, color: '#FFB8FF', name: 'pinky',
ai: 'ambush', target: {x:0, y:0} },
{ x: 9, y: 8, dir: 0, color: '#00FFFF', name: 'inky',
ai: 'flank', target: {x:0, y:0} },
{ x: 10, y: 8, dir: 1, color: '#FFB852', name: 'clyde',
ai: 'random', target: {x:0, y:0} },
];
Each ghost calculates its target tile differently. This is the core of the AI — the ghost then moves toward its target using a simple distance check.
function updateGhostTarget(ghost) {
const dirs = [{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}];
if (ghost.ai === 'chase') {
// Blinky: go straight for Pac-Man
ghost.target.x = pacman.x;
ghost.target.y = pacman.y;
} else if (ghost.ai === 'ambush') {
// Pinky: target 4 tiles ahead of Pac-Man
const d = dirs[pacman.dir];
ghost.target.x = pacman.x + d.x * 4;
ghost.target.y = pacman.y + d.y * 4;
} else if (ghost.ai === 'flank') {
// Inky: mirror Blinky's position through a point
// 2 tiles ahead of Pac-Man
const d = dirs[pacman.dir];
const ax = pacman.x + d.x * 2;
const ay = pacman.y + d.y * 2;
const blinky = ghosts[0];
ghost.target.x = ax + (ax - blinky.x);
ghost.target.y = ay + (ay - blinky.y);
} else if (ghost.ai === 'random') {
// Clyde: chase when far, scatter when close
const dist = Math.abs(ghost.x - pacman.x)
+ Math.abs(ghost.y - pacman.y);
if (dist > 8) {
ghost.target.x = pacman.x;
ghost.target.y = pacman.y;
} else {
// Run to bottom-left corner
ghost.target.x = 0;
ghost.target.y = map.length - 1;
}
}
}
Instead of picking a random direction, each ghost now picks the direction that brings it closest to its target tile. The ghost cannot reverse direction (just like the original game).
function moveGhost(ghost) {
updateGhostTarget(ghost);
const dirs = [{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}];
const reverse = (ghost.dir + 2) % 4;
let bestDir = ghost.dir;
let bestDist = Infinity;
for (let d = 0; d < 4; d++) {
if (d === reverse) continue; // no reversing
const nx = ghost.x + dirs[d].x;
const ny = ghost.y + dirs[d].y;
// Check if tile is walkable
if (!map[ny] || map[ny][nx] === 1) continue;
// Manhattan distance to target
const dist = Math.abs(nx - ghost.target.x)
+ Math.abs(ny - ghost.target.y);
if (dist < bestDist) {
bestDist = dist;
bestDir = d;
}
}
const nx = ghost.x + dirs[bestDir].x;
const ny = ghost.y + dirs[bestDir].y;
if (map[ny] && map[ny][nx] !== 1) {
ghost.x = nx;
ghost.y = ny;
ghost.dir = bestDir;
}
}
In the original Pac-Man, ghosts alternate between chase and scatter mode. During scatter, each ghost retreats to its assigned corner. This gives the player breathing room and makes the game feel fair.
let ghostMode = 'chase';
let modeTimer = 0;
const CHASE_DURATION = 20000; // 20 seconds
const SCATTER_DURATION = 7000; // 7 seconds
// Corner targets for scatter mode
const scatterTargets = {
blinky: { x: 19, y: 0 }, // top-right
pinky: { x: 0, y: 0 }, // top-left
inky: { x: 19, y: 19 }, // bottom-right
clyde: { x: 0, y: 19 }, // bottom-left
};
function updateGhostMode() {
const now = Date.now();
if (modeTimer === 0) modeTimer = now;
const elapsed = now - modeTimer;
if (ghostMode === 'chase' && elapsed > CHASE_DURATION) {
ghostMode = 'scatter';
modeTimer = now;
} else if (ghostMode === 'scatter' && elapsed > SCATTER_DURATION) {
ghostMode = 'chase';
modeTimer = now;
}
}
// Modify updateGhostTarget to respect scatter mode:
function updateGhostTarget(ghost) {
if (ghostMode === 'scatter') {
const corner = scatterTargets[ghost.name];
ghost.target.x = corner.x;
ghost.target.y = corner.y;
return;
}
// ... chase logic from Step 2
}
Check if any ghost occupies the same tile as Pac-Man. If so, the player loses a life or the game ends.
function checkGhostCollision() {
for (const ghost of ghosts) {
if (ghost.x === pacman.x && ghost.y === pacman.y) {
onPacmanCaught();
return;
}
}
}
function onPacmanCaught() {
lives--;
if (lives <= 0) {
gameOver = true;
} else {
// Reset positions, keep score
pacman.x = 1;
pacman.y = 1;
pacman.dir = 0;
ghosts[0].x = 9; ghosts[0].y = 7;
ghosts[1].x = 10; ghosts[1].y = 7;
ghosts[2].x = 9; ghosts[2].y = 8;
ghosts[3].x = 10; ghosts[3].y = 8;
}
}
// Call checkGhostCollision() in gameLoop after movement
Make the ghosts look proper with a body and eyes that point in their movement direction. This gives visual feedback about where each ghost is heading.
function drawGhost(ghost) {
const cx = ghost.x * TILE_SIZE + TILE_SIZE / 2;
const cy = ghost.y * TILE_SIZE + TILE_SIZE / 2;
const r = TILE_SIZE / 2 - 2;
// Body
ctx.fillStyle = ghost.color;
ctx.beginPath();
ctx.arc(cx, cy - 2, r, Math.PI, 0);
ctx.lineTo(cx + r, cy + r);
// Wavy bottom
for (let i = 0; i < 3; i++) {
const w = (r * 2) / 3;
const bx = cx + r - w * i;
ctx.quadraticCurveTo(bx - w/4, cy + r - 4,
bx - w/2, cy + r);
}
ctx.fill();
// Eyes
const dirs = [{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}];
const d = dirs[ghost.dir];
const eyeOff = 3;
for (const side of [-1, 1]) {
const ex = cx + side * 3;
const ey = cy - 3;
// White
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(ex, ey, 3, 0, Math.PI * 2);
ctx.fill();
// Pupil (shifts toward direction)
ctx.fillStyle = '#00f';
ctx.beginPath();
ctx.arc(ex + d.x * 1.5, ey + d.y * 1.5,
1.5, 0, Math.PI * 2);
ctx.fill();
}
}
Put it all together by adding the mode update and collision check to the game loop.
function gameLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawMap();
if (!gameOver) {
updateGhostMode();
movePacman();
ghosts.forEach(g => moveGhost(g));
checkGhostCollision();
checkWin();
}
drawPacman();
ghosts.forEach(g => drawGhost(g));
drawScore();
drawGameOver();
}
setInterval(gameLoop, 150);
Your ghosts now have personality and the game feels like a real chase. In the next parts we can explore:
Continue to Part 4: Power Pellet Mode where ghosts turn blue and become edible for bonus points.