You move through a maze, usually seen from above, and the challenge is choosing your routes through its corridors wisely to collect items, find the exit or escape whoever is chasing you.
Also known as: maze game, maze chase, dot eater
Defining mechanics
- Movement along corridors on a grid; the important decisions are made at junctions.
- Items spread across the whole maze (dots, treasure) that force you to cover all of it.
- Chasers with simple movement rules that differ from one another, which the player learns to predict.
- Power-ups that turn the chase around for a while and change who hunts whom.
Gameplay loop
Read where the chasers are, pick a route that collects as much as possible at little risk, follow it and adjust it at every junction.
Our demo
Controls. Arrows or WASD to turn; you can ask for the turn before reaching the junction. With a mouse or touch, tap where you want to go relative to the player, or use the on-screen buttons.
Open full screenA grid maze with dots to collect, three chasers with different behaviours and four power-ups that turn the chase around for a few seconds. Just enough to feel what defines the genre, which is choosing routes under pressure.
Made with Kaplay, in about 195 lines of JavaScript.
How it's made
Setup and controls
Virtual buttons are defined for the four directions, so the same code works with a keyboard or a touch screen. The speeds of the player, the chasers and the frightened mode, and the power-up's duration are the numbers that set the difficulty.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [12, 12, 30], // Virtual buttons: the game asks about "up", not about a specific key. buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] }, start: { keyboard: ["space", "enter"] }, }, }); touchControls(k, [ { button: "left", label: "◀" }, { button: "right", label: "▶" }, { button: "up", label: "▲", side: "right" }, { button: "down", label: "▼" }, ]); const TILE = 18; const PLAYER_SPEED = 7; // cells per second const GHOST_SPEED = 6; const FRIGHTENED_SPEED = 3.5; const POWER_TIME = 6; // seconds const DIRECTIONS = { left: vec2(-1, 0), right: vec2(1, 0), up: vec2(0, -1), down: vec2(0, 1) };The maze as text
The maze is a list of strings; each character is a wall, a dot, a power-up or a character's starting cell. isFree() tells whether a cell can be walked on and cellToScreen() turns it into pixels. All the logic works in cells, not pixels.
// The maze is a text drawing: # wall · . dot · o power-up // P player · R, S, N the three chasers (red, pink and orange). const MAP = [ "#####################", "#.........#.........#", "#o###.###.#.###.###o#", "#...................#", "#.###.#.#####.#.###.#", "#.....#...#...#.....#", "#####.###.#.###.#####", "#.........R.........#", "#.###.###.#.###.###.#", "#...#.....S.....#...#", "###.#.#.#####.#.#.###", "#.....#...N...#.....#", "#.#######.#.#######.#", "#.........P.........#", "#.###.###.#.###.###.#", "#o..#...........#..o#", "#.#.#.###.#.###.#.#.#", "#...................#", "#####################", ]; const X0 = (640 - MAP[0].length * TILE) / 2; const Y0 = (360 - MAP.length * TILE) / 2; const isFree = (c) => MAP[c.y]?.[c.x] !== undefined && MAP[c.y][c.x] !== "#"; const cellToScreen = (c) => vec2(X0 + c.x * TILE + TILE / 2, Y0 + c.y * TILE + TILE / 2); const findCell = (char) => { const y = MAP.findIndex((row) => row.includes(char)); return vec2(MAP[y].indexOf(char), y); };Moving on the grid
The player and the chasers move the same way, from cell centre to cell centre, with interpolation so it looks smooth. The next direction is decided only on reaching a cell, so decisions happen at junctions, which is where the genre puts its interest.
// Everyone moves the same way: from cell centre to cell centre. On reaching a // cell, `choose` picks the next direction. So movement reads as a grid and // decisions only happen at junctions. function makeActor(home, comps) { const a = add([...comps, pos(cellToScreen(home)), anchor("center")]); Object.assign(a, { home, cell: home, next: home, dir: vec2(0), progress: 1 }); return a; } function step(a, speed, chooseDir) { a.progress += dt() * speed; if (a.progress >= 1) { a.cell = a.next; a.arrive?.(); a.dir = chooseDir(a); const ahead = a.cell.add(a.dir); if (isFree(ahead)) (a.next = ahead), (a.progress = 0); else (a.dir = vec2(0)), (a.progress = 1); } a.pos = cellToScreen(a.cell).lerp(cellToScreen(a.next), Math.min(a.progress, 1)); }Forgiving controls
The player remembers the last direction asked for and takes it as soon as the corridor allows, so you can turn slightly before reaching the junction. Turning around is immediate. Without these two details, escaping down a corridor feels unfair.
// The player remembers the last direction asked for and takes it as soon as // the corridor allows: turning "before" reaching the junction makes the // controls feel precise. Turning around is instant. const player = makeActor(findCell("P"), [circle(7), color(255, 225, 60)]); let wanted = vec2(0); const request = (d) => { wanted = d; if (d.eq(player.dir.scale(-1))) { [player.cell, player.next] = [player.next, player.cell]; player.dir = d; player.progress = 1 - player.progress; } }; for (const [button, d] of Object.entries(DIRECTIONS)) onButtonPress(button, () => request(d)); // With a mouse or touch: tap where you want to go relative to the player. onMousePress(() => { const d = mousePos().sub(player.pos); request(Math.abs(d.x) > Math.abs(d.y) ? vec2(Math.sign(d.x), 0) : vec2(0, Math.sign(d.y))); }); const choosePlayerDir = (a) => (isFree(a.cell.add(wanted)) ? wanted : a.dir);Dots and power-ups
Each dot is worth little, and emptying the maze wins the game. The power-up swaps the roles for a few seconds and forces the chasers to turn around, a visible sign that the rules changed.
// Each dot is worth little; the power-up changes the rules for a few seconds. player.arrive = () => { const key = `${player.cell.x},${player.cell.y}`; const p = food.get(key); if (!p) return; destroy(p); food.delete(key); score += p.power ? 50 : 10; if (p.power) { power = POWER_TIME; eaten = 0; for (const g of ghosts) g.dir = g.dir.scale(-1); // they turn around: a clear sign something changed } if (food.size === 0) go("end", true, score); };Readable chase AI
At each junction, the chaser takes the exit that brings it closest to its target, without turning back. The only difference between them is the target, since the red one aims at your cell, the pink one at four cells ahead of you and the orange one wanders at random. These simple rules let the player learn to read and exploit them, which is the idea Pac-Man made famous.
// The AI is readable: at each junction the chaser takes, without turning // back, the exit that brings it closest to its target. Only the target // changes: red aims at your cell, pink at four cells ahead of you (an // ambush) and orange wanders at random. When frightened, they all flee at random. const ghosts = [ ["R", rgb(240, 60, 60), () => player.cell], ["S", rgb(255, 150, 200), () => player.cell.add(player.dir.scale(4))], ["N", rgb(255, 170, 60), null], ].map(([char, tint, target]) => makeActor(findCell(char), [rect(14, 14, { radius: [7, 7, 2, 2] }), color(tint), { tint, target }]), ); function chooseGhostDir(g) { let exits = Object.values(DIRECTIONS).filter((d) => isFree(g.cell.add(d)) && !d.eq(g.dir.scale(-1))); if (exits.length === 0) exits = [g.dir.scale(-1)]; // dead end: the only option is to go back if (power > 0 || !g.target) return choose(exits); const goal = g.target(); return exits.reduce((best, d) => (g.cell.add(d).dist(goal) < g.cell.add(best).dist(goal) ? d : best)); }Risk and reward
Touching a chaser costs a life, unless it is frightened. Then it is worth more each time (200, 400, 800), which tempts you to go after them instead of calmly eating dots.
// Risk and reward: touching a chaser costs a life, unless it is // frightened; then it is worth more each time (200, 400, 800). function resetPositions() { for (const a of [player, ...ghosts]) Object.assign(a, { cell: a.home, next: a.home, dir: vec2(0), progress: 1 }); wanted = vec2(0); power = 0; pause = 1; } function checkHits() { for (const g of ghosts) { if (g.pos.dist(player.pos) > TILE * 0.7) continue; if (power > 0) { eaten++; score += 100 * 2 ** eaten; Object.assign(g, { cell: g.home, next: g.home, progress: 1 }); } else if (--lives <= 0) { return go("end", false, score); } else { return resetPositions(); } } }Scenes
The game has three scenes, title, game and end, and go() jumps between them. This restarts the game without cleaning anything up by hand.
function screen(title, subtitle, next) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]); add([ text(subtitle, { size: 16, font: FONT, align: "center", width: 540 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255), ]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Laberinto", en: "Maze" }), t({ es: "Cómete todos los puntos sin que te atrapen. Los puntos grandes asustan\na los perseguidores y puedes comértelos. Flechas o toques para girar.\nPulsa espacio o toca para empezar.", en: "Eat every dot without getting caught. The big dots scare the chasers\nand let you eat them. Arrows or taps to turn.\nPress space or tap to start.", }), () => go("game"), ), ); scene("end", (won, score) => screen( won ? t({ es: "¡Laberinto limpio!", en: "Maze cleared!" }) : t({ es: "Te atraparon", en: "You got caught" }), t({ es: `Puntos: ${score}. Pulsa espacio para repetir.`, en: `Score: ${score}. Press space to play again.` }), () => go("game"), ), ); go("title");
What we simplified
- The chasers do not alternate between chase and scatter phases as in Pac-Man; they always chase, except when frightened.
- There is no ghost house, side tunnels, fruit or levels that speed up.
- Shapes are circles and rectangles, with no animations or sound.
To modify it
- Add a fourth chaser that imitates Pac-Man's Clyde, chasing you when far away and heading for a corner when close.
- Make the chasers alternate every few seconds between chasing and scattering to their corner, and notice how the feeling of pressure changes.
- Design another maze in the text map with a tunnel connecting the left and right sides.
Full code
// Maze demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, touchControls, pixelDensity } from "../common.js";
// #region setup
const k = kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [12, 12, 30],
// Virtual buttons: the game asks about "up", not about a specific key.
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
up: { keyboard: ["up", "w"] },
down: { keyboard: ["down", "s"] },
start: { keyboard: ["space", "enter"] },
},
});
touchControls(k, [
{ button: "left", label: "◀" },
{ button: "right", label: "▶" },
{ button: "up", label: "▲", side: "right" },
{ button: "down", label: "▼" },
]);
const TILE = 18;
const PLAYER_SPEED = 7; // cells per second
const GHOST_SPEED = 6;
const FRIGHTENED_SPEED = 3.5;
const POWER_TIME = 6; // seconds
const DIRECTIONS = { left: vec2(-1, 0), right: vec2(1, 0), up: vec2(0, -1), down: vec2(0, 1) };
// #endregion
// #region maze
// The maze is a text drawing: # wall · . dot · o power-up
// P player · R, S, N the three chasers (red, pink and orange).
const MAP = [
"#####################",
"#.........#.........#",
"#o###.###.#.###.###o#",
"#...................#",
"#.###.#.#####.#.###.#",
"#.....#...#...#.....#",
"#####.###.#.###.#####",
"#.........R.........#",
"#.###.###.#.###.###.#",
"#...#.....S.....#...#",
"###.#.#.#####.#.#.###",
"#.....#...N...#.....#",
"#.#######.#.#######.#",
"#.........P.........#",
"#.###.###.#.###.###.#",
"#o..#...........#..o#",
"#.#.#.###.#.###.#.#.#",
"#...................#",
"#####################",
];
const X0 = (640 - MAP[0].length * TILE) / 2;
const Y0 = (360 - MAP.length * TILE) / 2;
const isFree = (c) => MAP[c.y]?.[c.x] !== undefined && MAP[c.y][c.x] !== "#";
const cellToScreen = (c) => vec2(X0 + c.x * TILE + TILE / 2, Y0 + c.y * TILE + TILE / 2);
const findCell = (char) => {
const y = MAP.findIndex((row) => row.includes(char));
return vec2(MAP[y].indexOf(char), y);
};
// #endregion
// #region movement
// Everyone moves the same way: from cell centre to cell centre. On reaching a
// cell, `choose` picks the next direction. So movement reads as a grid and
// decisions only happen at junctions.
function makeActor(home, comps) {
const a = add([...comps, pos(cellToScreen(home)), anchor("center")]);
Object.assign(a, { home, cell: home, next: home, dir: vec2(0), progress: 1 });
return a;
}
function step(a, speed, chooseDir) {
a.progress += dt() * speed;
if (a.progress >= 1) {
a.cell = a.next;
a.arrive?.();
a.dir = chooseDir(a);
const ahead = a.cell.add(a.dir);
if (isFree(ahead)) (a.next = ahead), (a.progress = 0);
else (a.dir = vec2(0)), (a.progress = 1);
}
a.pos = cellToScreen(a.cell).lerp(cellToScreen(a.next), Math.min(a.progress, 1));
}
// #endregion
scene("game", () => {
let score = 0;
let lives = 3;
let power = 0; // seconds the chasers stay frightened
let eaten = 0; // ghosts eaten with the same power-up
let pause = 1;
const hud = add([text("", { size: 15, font: FONT, width: 110 }), pos(14, 14)]);
// Walls and dots come from the map. Dots are stored by cell.
const food = new Map();
MAP.forEach((row, y) =>
[...row].forEach((c, x) => {
const p = cellToScreen(vec2(x, y));
if (c === "#") add([rect(TILE, TILE), pos(p), anchor("center"), color(40, 60, 170)]);
if (c === "." || c === "o")
food.set(`${x},${y}`, add([circle(c === "o" ? 6 : 2.5), pos(p), anchor("center"), color(255, 220, 170), { power: c === "o" }]));
}),
);
// #region player
// The player remembers the last direction asked for and takes it as soon as
// the corridor allows: turning "before" reaching the junction makes the
// controls feel precise. Turning around is instant.
const player = makeActor(findCell("P"), [circle(7), color(255, 225, 60)]);
let wanted = vec2(0);
const request = (d) => {
wanted = d;
if (d.eq(player.dir.scale(-1))) {
[player.cell, player.next] = [player.next, player.cell];
player.dir = d;
player.progress = 1 - player.progress;
}
};
for (const [button, d] of Object.entries(DIRECTIONS)) onButtonPress(button, () => request(d));
// With a mouse or touch: tap where you want to go relative to the player.
onMousePress(() => {
const d = mousePos().sub(player.pos);
request(Math.abs(d.x) > Math.abs(d.y) ? vec2(Math.sign(d.x), 0) : vec2(0, Math.sign(d.y)));
});
const choosePlayerDir = (a) => (isFree(a.cell.add(wanted)) ? wanted : a.dir);
// #endregion
// #region eating
// Each dot is worth little; the power-up changes the rules for a few seconds.
player.arrive = () => {
const key = `${player.cell.x},${player.cell.y}`;
const p = food.get(key);
if (!p) return;
destroy(p);
food.delete(key);
score += p.power ? 50 : 10;
if (p.power) {
power = POWER_TIME;
eaten = 0;
for (const g of ghosts) g.dir = g.dir.scale(-1); // they turn around: a clear sign something changed
}
if (food.size === 0) go("end", true, score);
};
// #endregion
// #region chasers
// The AI is readable: at each junction the chaser takes, without turning
// back, the exit that brings it closest to its target. Only the target
// changes: red aims at your cell, pink at four cells ahead of you (an
// ambush) and orange wanders at random. When frightened, they all flee at random.
const ghosts = [
["R", rgb(240, 60, 60), () => player.cell],
["S", rgb(255, 150, 200), () => player.cell.add(player.dir.scale(4))],
["N", rgb(255, 170, 60), null],
].map(([char, tint, target]) =>
makeActor(findCell(char), [rect(14, 14, { radius: [7, 7, 2, 2] }), color(tint), { tint, target }]),
);
function chooseGhostDir(g) {
let exits = Object.values(DIRECTIONS).filter((d) => isFree(g.cell.add(d)) && !d.eq(g.dir.scale(-1)));
if (exits.length === 0) exits = [g.dir.scale(-1)]; // dead end: the only option is to go back
if (power > 0 || !g.target) return choose(exits);
const goal = g.target();
return exits.reduce((best, d) => (g.cell.add(d).dist(goal) < g.cell.add(best).dist(goal) ? d : best));
}
// #endregion
// #region risk
// Risk and reward: touching a chaser costs a life, unless it is
// frightened; then it is worth more each time (200, 400, 800).
function resetPositions() {
for (const a of [player, ...ghosts]) Object.assign(a, { cell: a.home, next: a.home, dir: vec2(0), progress: 1 });
wanted = vec2(0);
power = 0;
pause = 1;
}
function checkHits() {
for (const g of ghosts) {
if (g.pos.dist(player.pos) > TILE * 0.7) continue;
if (power > 0) {
eaten++;
score += 100 * 2 ** eaten;
Object.assign(g, { cell: g.home, next: g.home, progress: 1 });
} else if (--lives <= 0) {
return go("end", false, score);
} else {
return resetPositions();
}
}
}
// #endregion
onUpdate(() => {
hud.text = t({ es: "Puntos\n", en: "Score\n" }) + score + t({ es: "\n\nVidas\n", en: "\n\nLives\n" }) + lives;
if (pause > 0) return void (pause -= dt());
power = Math.max(0, power - dt());
step(player, PLAYER_SPEED, choosePlayerDir);
for (const g of ghosts) {
step(g, power > 0 ? FRIGHTENED_SPEED : GHOST_SPEED, chooseGhostDir);
// Blue while the power lasts; they blink when it is about to run out.
const blink = power < 2 && Math.floor(time() * 6) % 2;
g.color = power > 0 && !blink ? rgb(60, 90, 255) : g.tint;
}
checkHits();
});
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
add([
text(subtitle, { size: 16, font: FONT, align: "center", width: 540 }),
pos(center().add(0, 30)),
anchor("center"),
color(200, 210, 255),
]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Laberinto", en: "Maze" }),
t({
es: "Cómete todos los puntos sin que te atrapen. Los puntos grandes asustan\na los perseguidores y puedes comértelos. Flechas o toques para girar.\nPulsa espacio o toca para empezar.",
en: "Eat every dot without getting caught. The big dots scare the chasers\nand let you eat them. Arrows or taps to turn.\nPress space or tap to start.",
}),
() => go("game"),
),
);
scene("end", (won, score) =>
screen(
won ? t({ es: "¡Laberinto limpio!", en: "Maze cleared!" }) : t({ es: "Te atraparon", en: "You got caught" }),
t({ es: `Puntos: ${score}. Pulsa espacio para repetir.`, en: `Score: ${score}. Press space to play again.` }),
() => go("game"),
),
);
go("title");
// #endregionDesign challenges
- A chase AI that feels threatening but readable, so the player can anticipate and outwit it instead of feeling caught at random.
- Designing the maze with enough junctions and escape routes so there are no inescapable traps, and placing power-ups to create risk-and-reward decisions.
- Forgiving turn controls that remember the requested direction and take it as soon as the corridor allows.
- Raising the difficulty (speed, chaser aggressiveness, power-up duration) without changing the rules the player has already learned.
History
- 1973OriginGotcha
Mazes had appeared earlier on research computers, such as Mouse in the Maze in the 1950s or Maze (1973), in 3D; Gotcha was the first arcade maze game.
Maze game (Wikipedia) - 1979Head On
Head On is the first maze game whose goal is collecting every dot while dodging an opponent.
Head On (video game) (Wikipedia) - 1980Pac-Man
Pac-Man gives each ghost its own way of chasing and adds power pellets that reverse the hunt. It becomes the genre's model.
Pac-Man (Wikipedia) - 19813D Monster Maze
3D Monster Maze takes the maze into first person on a home computer, with a monster hunting you.
3D Monster Maze (Wikipedia) - 1982
In Atari v. North American Philips, US courts stop the clone K.C. Munchkin!, but treat the idea of a maze chase as free to use. That is why games of this kind abound.
Maze game (Wikipedia) - 2007Pac-Man Championship Edition
Pac-Man Championship Edition refreshes the formula with a time limit, maze halves that regenerate and rising speed.
Pac-Man Championship Edition (Wikipedia) - 2010Pac-Man
For Pac-Man's 30th anniversary, Google puts a playable version on its home page, which is still available in the browser.
Pac-Man (Wikipedia)
Reference games
1973
Gotcha
Two players move through a maze that keeps changing; one chases and the other flees. It is considered the first arcade maze game.
1979
Head On
You drive a car around concentric lanes collecting dots while another car comes the opposite way; you can only change lanes at certain gaps. It was the first maze game whose goal is to run over dots.
1980
Pac-Man
You eat the dots in a maze while four ghosts chase you, each with its own way of hunting you down. The power pellets in the corners frighten them and let you eat them for a while.
1981
3D Monster Maze
In first person, you look for the exit of a randomly generated maze while a Tyrannosaurus stalks you. You are faster than it, but a dead end can be the end of you.
2007
Pac-Man Championship Edition
A return to Pac-Man with a time limit, in which each half of the maze is renewed once you clear it and the game speeds up as your score rises.
2022
Captain Neat-o in Treasure Maze
You run through a maze collecting treasure and dodging guardian snakes until you reach the exit. It is a short game, about three minutes long, made in PICO-8 for Toy Box Jam 3.
Exercise
Play the demo and describe in words how each chaser moves. Then change the pink one's target in the code (for example, eight cells ahead instead of four) and discuss whether the ambush becomes more or less dangerous.
Subgenres
- Maze chaseYou move through a single-screen maze collecting every dot while enemies chase you down the same corridors; you win by reading their routes and using the items that briefly let you turn the tables.
