
Puzzle
You solve problems under fixed rules, such as arranging pieces, clearing a path or putting objects in place. The challenge lies mostly in working out the solution rather than in reflexes.
Also known as: puzzle game, puzzler, logic game
Defining mechanics
- A few clear, deterministic rules that the player can learn completely.
- Separate levels or problems, each with one solution (or a few) to discover.
- Dead-end states reached through a bad move, and tools to undo or restart.
- A progression that introduces the rules one at a time and then combines them.
Gameplay loop
Look at the problem, form a hypothesis, test it, see why it fails, undo and try again until the idea clicks.
Our demo
Controls. Arrows or WASD to move, Z to undo and R to restart the level. With a mouse or touch, tap where you want to go relative to the player and use the Undo and Restart buttons.
Open full screenThree short box-pushing levels in the style of Sokoban, rising in difficulty. The rules fit in one sentence and there is no chance or reflexes involved, so the whole challenge is thinking before you move.
Made with Kaplay, in about 163 lines of JavaScript.
How it's made
Setup and controls
Virtual buttons are defined for moving, undoing and restarting. The rest of the code asks about those buttons, so the same game works with a keyboard or a touch screen.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [34, 28, 48], // Virtual buttons: the game asks about "undo", not about a specific key. buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] }, undo: { keyboard: ["z", "backspace"] }, restart: { keyboard: ["r"] }, start: { keyboard: ["space", "enter"] }, }, }); touchControls(k, [ { button: "left", label: "◀" }, { button: "up", label: "▲" }, { button: "down", label: "▼" }, { button: "right", label: "▶" }, { button: "undo", label: "↶", side: "right" }, { button: "restart", label: "⟲" }, ]); const TILE = 36; const DIRECTIONS = { left: vec2(-1, 0), right: vec2(1, 0), up: vec2(0, -1), down: vec2(0, 1) };Levels as text
Each level is a list of strings in standard Sokoban notation, the one the community uses to share thousands of levels. readLevel() turns it into a state with walls, goals, boxes and the player. Each level teaches one idea before the next combines it with another.
// Standard Sokoban notation: # wall · @ player · $ box · . goal // * box on goal · + player on goal. Each level teaches something: // 1) you can only push, so you must walk around the box to change its direction; // 2) a box in a corner can never move again (a dead-end state); // 3) several boxes: the order in which you move them matters. const LEVELS = [ ["#######", "#@ #", "# $ #", "# . #", "#######"], [" ####", "### #", "# $ #", "# #. ##", "# .$ @#", "# ##", "######"], ["#######", "#. .#", "# $#$ #", "# @ #", "# $# #", "#. #", "#######"], ]; // Turns the text into the game state: positions of walls, goals, boxes and the player. function readLevel(rows) { const board = { walls: new Set(), floor: new Set(), goals: new Set(), boxes: [], player: vec2(0), width: 0, height: rows.length }; rows.forEach((row, y) => { board.width = Math.max(board.width, row.length); [...row].forEach((c, x) => { const key = `${x},${y}`; if (c === "#") board.walls.add(key); else if (x > row.indexOf("#") && x < row.lastIndexOf("#")) board.floor.add(key); if (".*+".includes(c)) board.goals.add(key); if ("$*".includes(c)) board.boxes.push(vec2(x, y)); if ("@+".includes(c)) board.player = vec2(x, y); }); }); return board; } const keyOf = (p) => `${p.x},${p.y}`;Simple, deterministic rules
You move one cell; if there is a box ahead you push it, unless a wall or another box is behind it, and you can never pull it. The same move always gives the same result, so the player can reason through the whole puzzle without relying on luck.
// The rules are few and deterministic: the same move always gives the same result. // You move one cell; if there is a box ahead, you push it, unless there is a // wall or another box behind it. You can never pull a box. function tryMove(d) { if (solved) return; const target = b.player.add(d); if (isWall(target)) return; const i = boxAt(target); if (i >= 0) { const behind = target.add(d); if (isWall(behind) || boxAt(behind) >= 0) return; } // Before changing anything, save a snapshot of the state so it can be undone. history.push({ player: b.player, boxes: [...b.boxes] }); if (i >= 0) b.boxes[i] = target.add(d); b.player = target; checkWin(); } for (const [button, d] of Object.entries(DIRECTIONS)) onButtonPress(button, () => tryMove(d));Undo
Before each move a snapshot of the state is saved, and undoing means going back to the previous one. It makes mistakes cheap, so the player tries out ideas instead of fearing every step. The move counter is the size of that history.
// Undoing means going back to the previous snapshot. In a puzzle it makes // mistakes cheap: the player tries out ideas instead of fearing every move. function undo() { const previous = history.pop(); if (!previous || solved) return; b.player = previous.player; b.boxes = previous.boxes; } onButtonPress("undo", undo); onButtonPress("restart", () => go("game", n, total));Dead-end states
A box that is off its goal and pushed into a corner can no longer move, and the level becomes unsolvable even though the game goes on. The demo paints it red and warns you, because learning to spot those states before reaching them is a big part of the challenge.
// A box off its goal with a wall above or below AND another to one side is // in a corner: it can never be pushed out. The level has become unsolvable, // and pointing it out teaches you to spot these states before reaching them. function isStuck(c) { if (b.goals.has(keyOf(c))) return false; const v = isWall(c.add(0, -1)) || isWall(c.add(0, 1)); const h = isWall(c.add(-1, 0)) || isWall(c.add(1, 0)); return v && h; }Checking the solution
The level is solved when every box sits on a goal. Then the game moves on to the next one and adds up the moves, which invites looking for shorter solutions.
function checkWin() { if (!b.boxes.every((c) => b.goals.has(keyOf(c)))) return; solved = true; const sum = total + history.length; wait(1.2, () => (n + 1 < LEVELS.length ? go("game", n + 1, sum) : go("end", sum))); }Drawing from the state
The board is drawn every frame from the data with onDraw. Since there are no objects to keep in sync, undoing and restarting only have to change the data.
// The board is drawn every frame from the state: there are no objects to // keep in sync, so undoing only has to change the data. const cellPos = (p) => origin.add(p.scale(TILE)); onDraw(() => { for (let y = 0; y < b.height; y++) for (let x = 0; x < b.width; x++) { const p = vec2(x, y); if (isWall(p)) drawRect({ pos: cellPos(p), width: TILE, height: TILE, color: rgb(92, 78, 120) }); if (b.floor.has(keyOf(p))) drawRect({ pos: cellPos(p), width: TILE, height: TILE, color: rgb(52, 44, 70) }); if (b.goals.has(keyOf(p))) drawCircle({ pos: cellPos(p).add(TILE / 2), radius: 7, color: rgb(255, 205, 80) }); } for (const c of b.boxes) { const onGoal = b.goals.has(keyOf(c)); const tint = onGoal ? rgb(110, 210, 120) : isStuck(c) ? rgb(230, 80, 70) : rgb(200, 140, 80); drawRect({ pos: cellPos(c).add(3), width: TILE - 6, height: TILE - 6, radius: 4, color: tint }); } drawCircle({ pos: cellPos(b.player).add(TILE / 2), radius: TILE / 2 - 5, color: rgb(120, 200, 255) }); }); const hud = add([text("", { size: 16, font: FONT, width: 200 }), pos(440, 40)]); onUpdate(() => { const warning = b.boxes.some(isStuck) ? t({ es: "\n\nUna caja quedó atascada. Pulsa Z para deshacer.", en: "\n\nA box is stuck. Press Z to undo." }) : ""; const done = solved ? t({ es: "\n\n¡Resuelto!", en: "\n\nSolved!" }) : ""; hud.text = t({ es: `Nivel ${n + 1} de ${LEVELS.length}\nMovimientos: `, en: `Level ${n + 1} of ${LEVELS.length}\nMoves: ` }) + history.length + warning + done; });Playing with a mouse or touch
A tap moves the player in the direction of the tap, and two on-screen buttons repeat Z and R. Since the game is turn-based, playing this way needs neither precision nor speed.
// With a mouse or touch: tap where you want to go relative to the player. // Two on-screen buttons repeat Z and R. function screenButton(label, y, action) { const btn = add([rect(170, 32, { radius: 6 }), pos(440, y), color(70, 60, 100), area()]); btn.add([text(label, { size: 15, font: FONT }), pos(12, 8)]); btn.onClick(action); } screenButton(t({ es: "Deshacer (Z)", en: "Undo (Z)" }), 250, undo); screenButton(t({ es: "Reiniciar (R)", en: "Restart (R)" }), 292, () => go("game", n, total)); onMousePress(() => { if (mousePos().x > 430) return; // clicks on the buttons do not move the player const d = mousePos().sub(cellPos(b.player).add(TILE / 2)); if (Math.max(Math.abs(d.x), Math.abs(d.y)) < TILE / 2) return; tryMove(Math.abs(d.x) > Math.abs(d.y) ? vec2(Math.sign(d.x), 0) : vec2(0, Math.sign(d.y))); });Scenes
The game has three scenes, title, game and end. The game scene receives the level number and the accumulated moves, and go() calls it again to move to the next level or restart.
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(220, 200, 255), ]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Empuja las cajas", en: "Push the boxes" }), t({ es: "Lleva cada caja a una meta amarilla. Solo puedes empujar, nunca tirar.\nFlechas para moverte, Z deshace, R reinicia.\nPulsa espacio o toca para empezar.", en: "Move every box onto a yellow goal. You can only push, never pull.\nArrows to move, Z undoes, R restarts.\nPress space or tap to start.", }), () => go("game", 0, 0), ), ); scene("end", (total) => screen( t({ es: "¡Resolviste los tres niveles!", en: "You solved all three levels!" }), t({ es: `Movimientos en total: ${total}. ¿Puedes hacerlo con menos?\nPulsa espacio para repetir.`, en: `Total moves: ${total}. Can you do it in fewer?\nPress space to play again.`, }), () => go("game", 0, 0), ), ); go("title");
What we simplified
- There are only three small levels; games in the genre have dozens or hundreds, with a much longer difficulty curve.
- The stuck warning only detects boxes in corners; there are many other dead-end states it does not recognise.
- The boxes and the player are simple shapes, with no animation or sound.
To modify it
- Design a fourth level in the text map and test it with someone who does not know the demo. What do they have to understand to solve it?
- Extend the stuck warning to detect two boxes side by side against a wall, another typical dead-end state.
- Add an ice tile on which a box keeps sliding until it hits something, and think about what new levels that rule makes possible.
Full code
// Puzzle 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: [34, 28, 48],
// Virtual buttons: the game asks about "undo", not about a specific key.
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
up: { keyboard: ["up", "w"] },
down: { keyboard: ["down", "s"] },
undo: { keyboard: ["z", "backspace"] },
restart: { keyboard: ["r"] },
start: { keyboard: ["space", "enter"] },
},
});
touchControls(k, [
{ button: "left", label: "◀" },
{ button: "up", label: "▲" },
{ button: "down", label: "▼" },
{ button: "right", label: "▶" },
{ button: "undo", label: "↶", side: "right" },
{ button: "restart", label: "⟲" },
]);
const TILE = 36;
const DIRECTIONS = { left: vec2(-1, 0), right: vec2(1, 0), up: vec2(0, -1), down: vec2(0, 1) };
// #endregion
// #region levels
// Standard Sokoban notation: # wall · @ player · $ box · . goal
// * box on goal · + player on goal. Each level teaches something:
// 1) you can only push, so you must walk around the box to change its direction;
// 2) a box in a corner can never move again (a dead-end state);
// 3) several boxes: the order in which you move them matters.
const LEVELS = [
["#######", "#@ #", "# $ #", "# . #", "#######"],
[" ####", "### #", "# $ #", "# #. ##", "# .$ @#", "# ##", "######"],
["#######", "#. .#", "# $#$ #", "# @ #", "# $# #", "#. #", "#######"],
];
// Turns the text into the game state: positions of walls, goals, boxes and the player.
function readLevel(rows) {
const board = { walls: new Set(), floor: new Set(), goals: new Set(), boxes: [], player: vec2(0), width: 0, height: rows.length };
rows.forEach((row, y) => {
board.width = Math.max(board.width, row.length);
[...row].forEach((c, x) => {
const key = `${x},${y}`;
if (c === "#") board.walls.add(key);
else if (x > row.indexOf("#") && x < row.lastIndexOf("#")) board.floor.add(key);
if (".*+".includes(c)) board.goals.add(key);
if ("$*".includes(c)) board.boxes.push(vec2(x, y));
if ("@+".includes(c)) board.player = vec2(x, y);
});
});
return board;
}
const keyOf = (p) => `${p.x},${p.y}`;
// #endregion
scene("game", (n, total) => {
const b = readLevel(LEVELS[n]);
const origin = vec2(220 - (b.width * TILE) / 2, 180 - (b.height * TILE) / 2);
const isWall = (p) => b.walls.has(keyOf(p));
const boxAt = (p) => b.boxes.findIndex((c) => c.eq(p));
let history = [];
let solved = false;
// #region rules
// The rules are few and deterministic: the same move always gives the same result.
// You move one cell; if there is a box ahead, you push it, unless there is a
// wall or another box behind it. You can never pull a box.
function tryMove(d) {
if (solved) return;
const target = b.player.add(d);
if (isWall(target)) return;
const i = boxAt(target);
if (i >= 0) {
const behind = target.add(d);
if (isWall(behind) || boxAt(behind) >= 0) return;
}
// Before changing anything, save a snapshot of the state so it can be undone.
history.push({ player: b.player, boxes: [...b.boxes] });
if (i >= 0) b.boxes[i] = target.add(d);
b.player = target;
checkWin();
}
for (const [button, d] of Object.entries(DIRECTIONS)) onButtonPress(button, () => tryMove(d));
// #endregion
// #region undo
// Undoing means going back to the previous snapshot. In a puzzle it makes
// mistakes cheap: the player tries out ideas instead of fearing every move.
function undo() {
const previous = history.pop();
if (!previous || solved) return;
b.player = previous.player;
b.boxes = previous.boxes;
}
onButtonPress("undo", undo);
onButtonPress("restart", () => go("game", n, total));
// #endregion
// #region dead-ends
// A box off its goal with a wall above or below AND another to one side is
// in a corner: it can never be pushed out. The level has become unsolvable,
// and pointing it out teaches you to spot these states before reaching them.
function isStuck(c) {
if (b.goals.has(keyOf(c))) return false;
const v = isWall(c.add(0, -1)) || isWall(c.add(0, 1));
const h = isWall(c.add(-1, 0)) || isWall(c.add(1, 0));
return v && h;
}
// #endregion
// #region win
function checkWin() {
if (!b.boxes.every((c) => b.goals.has(keyOf(c)))) return;
solved = true;
const sum = total + history.length;
wait(1.2, () => (n + 1 < LEVELS.length ? go("game", n + 1, sum) : go("end", sum)));
}
// #endregion
// #region drawing
// The board is drawn every frame from the state: there are no objects to
// keep in sync, so undoing only has to change the data.
const cellPos = (p) => origin.add(p.scale(TILE));
onDraw(() => {
for (let y = 0; y < b.height; y++)
for (let x = 0; x < b.width; x++) {
const p = vec2(x, y);
if (isWall(p)) drawRect({ pos: cellPos(p), width: TILE, height: TILE, color: rgb(92, 78, 120) });
if (b.floor.has(keyOf(p))) drawRect({ pos: cellPos(p), width: TILE, height: TILE, color: rgb(52, 44, 70) });
if (b.goals.has(keyOf(p)))
drawCircle({ pos: cellPos(p).add(TILE / 2), radius: 7, color: rgb(255, 205, 80) });
}
for (const c of b.boxes) {
const onGoal = b.goals.has(keyOf(c));
const tint = onGoal ? rgb(110, 210, 120) : isStuck(c) ? rgb(230, 80, 70) : rgb(200, 140, 80);
drawRect({ pos: cellPos(c).add(3), width: TILE - 6, height: TILE - 6, radius: 4, color: tint });
}
drawCircle({ pos: cellPos(b.player).add(TILE / 2), radius: TILE / 2 - 5, color: rgb(120, 200, 255) });
});
const hud = add([text("", { size: 16, font: FONT, width: 200 }), pos(440, 40)]);
onUpdate(() => {
const warning = b.boxes.some(isStuck)
? t({ es: "\n\nUna caja quedó atascada. Pulsa Z para deshacer.", en: "\n\nA box is stuck. Press Z to undo." })
: "";
const done = solved ? t({ es: "\n\n¡Resuelto!", en: "\n\nSolved!" }) : "";
hud.text =
t({ es: `Nivel ${n + 1} de ${LEVELS.length}\nMovimientos: `, en: `Level ${n + 1} of ${LEVELS.length}\nMoves: ` }) +
history.length + warning + done;
});
// #endregion
// #region mouse
// With a mouse or touch: tap where you want to go relative to the player.
// Two on-screen buttons repeat Z and R.
function screenButton(label, y, action) {
const btn = add([rect(170, 32, { radius: 6 }), pos(440, y), color(70, 60, 100), area()]);
btn.add([text(label, { size: 15, font: FONT }), pos(12, 8)]);
btn.onClick(action);
}
screenButton(t({ es: "Deshacer (Z)", en: "Undo (Z)" }), 250, undo);
screenButton(t({ es: "Reiniciar (R)", en: "Restart (R)" }), 292, () => go("game", n, total));
onMousePress(() => {
if (mousePos().x > 430) return; // clicks on the buttons do not move the player
const d = mousePos().sub(cellPos(b.player).add(TILE / 2));
if (Math.max(Math.abs(d.x), Math.abs(d.y)) < TILE / 2) return;
tryMove(Math.abs(d.x) > Math.abs(d.y) ? vec2(Math.sign(d.x), 0) : vec2(0, Math.sign(d.y)));
});
// #endregion
});
// #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(220, 200, 255),
]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Empuja las cajas", en: "Push the boxes" }),
t({
es: "Lleva cada caja a una meta amarilla. Solo puedes empujar, nunca tirar.\nFlechas para moverte, Z deshace, R reinicia.\nPulsa espacio o toca para empezar.",
en: "Move every box onto a yellow goal. You can only push, never pull.\nArrows to move, Z undoes, R restarts.\nPress space or tap to start.",
}),
() => go("game", 0, 0),
),
);
scene("end", (total) =>
screen(
t({ es: "¡Resolviste los tres niveles!", en: "You solved all three levels!" }),
t({
es: `Movimientos en total: ${total}. ¿Puedes hacerlo con menos?\nPulsa espacio para repetir.`,
en: `Total moves: ${total}. Can you do it in fewer?\nPress space to play again.`,
}),
() => go("game", 0, 0),
),
);
go("title");
// #endregionDesign challenges
- Designing levels that teach without text, so that each one introduces an idea and the next tests it in another situation.
- Checking that every level is solvable and that no shortcuts spoil it, often with a program that solves it by brute force.
- Making mistakes cheap, with instant undo and restart, so the player experiments without fear.
- Tuning the difficulty so the moment of understanding the solution arrives after a fair effort, neither given away nor impossible.
History
- 1982OriginSokoban
Computers were already adapting traditional logic games, such as Nim or hangman. Sokoban, from 1982, gave its name to a whole type of box-pushing puzzle.
Puzzle video game (Wikipedia) - 1985Tetris
Tetris revolutionises and popularises the genre, bringing puzzles to a mass audience.
Puzzle video game (Wikipedia) - 1991Lemmings
Lemmings offers indirect-control puzzles, in which you guide creatures that walk on their own by handing out limited skills.
Lemmings (video game) (Wikipedia) - 2007Portal
Portal is cited as an example of instructional scaffolding, because each chamber teaches an idea before combining it with others.
Portal (video game) (Wikipedia) - 2013
Stephen Lavelle releases PuzzleScript, an open-source language for making turn-based puzzles in the browser, which brings puzzle design within anyone's reach.
Increpare (Wikipedia) - 2019Baba Is You
Baba Is You, born at the 2017 Nordic Game Jam, turns the rules themselves into blocks the player pushes and rewrites.
Baba Is You (Wikipedia)
Reference games
1982
Sokoban
You push boxes around a warehouse seen from above until each one sits on its spot. You can only push, never pull, so one bad move can leave the level unsolvable.
1985
Tetris
You rotate and place falling four-block pieces to complete rows, which disappear. The fall speeds up and you lose when the pieces reach the top.
1991
Lemmings
Little creatures walk mindlessly into danger and you do not control them directly; you hand out a limited number of skills (digging, blocking, building) so that enough of them reach the exit.
2007
Portal
With a gun that opens two linked portals, you get through test chambers by moving yourself and objects in impossible ways. Each chamber introduces a new idea and then combines it with the earlier ones.
2015
Bokosan
A reverse Sokoban, in which you pull the boxes instead of pushing them to put them back in place. It was made for the 2015 js13kGames competition, which limits the game to 13 kilobytes.
2019
Baba Is You
The level's rules are word blocks («BABA IS YOU», «WALL IS STOP») that you can push; by breaking or forming sentences you change what you are, what stops you and what makes you win.
2022
Dying Dreams
Thirteen side-view puzzles, with platforms and ladders, in which you move every figure on screen at once with the same arrow keys, and can undo and restart. It was made for js13kGames 2022 and ranked among that year's best.
Exercise
Solve the demo's three levels and note what idea each one teaches. Then design a new level in the text map that teaches a single idea and test it with a classmate without giving any hints.
Subgenres
- Falling-block puzzlePieces fall from the top and you shift and rotate them to fit them together below; completing a line or a group clears it. The challenge is sorting quickly without leaving gaps as the pieces fall faster.
- Hidden objectYou search a detailed, cluttered scene for a list of hidden items and point them out with the cursor or your finger. The challenge is observation: picking out what you are after amid the clutter, sometimes against the clock.
- Physics puzzleYou solve each level by exploiting consistent physics: throwing, stacking, balancing or building contraptions so that gravity, momentum and collisions do the work. The challenge is predicting how objects will react.
- Puzzle-platformerYou use a platformer's moves (running, jumping, climbing) to solve each level's puzzles: triggering mechanisms, pushing blocks or combining abilities. The challenge lies more in working out the route than in pulling off the jump.
- SokobanYou push boxes or other blocks around a grid until each one is on its target; since you can only push, never pull, you have to plan several moves ahead so you don't leave a piece stuck against a wall.
- Tile-matchingYou move, swap or pick tiles on a grid to bring matching pieces together, which then vanish and let others drop in. The challenge is reading the board and setting up chain reactions.
- Word puzzleYou form, guess or complete words from loose letters or clues, as in anagrams, crosswords or word searches. The challenge is your vocabulary and spelling.