
Puzzle-platformer
You 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.
Also known as: puzzle platformer, platform puzzler
Defining mechanics
- Platforming moves with fixed limits that are easy to learn. If the jump reaches one block and never two, that height decides which parts of the room you can reach.
- Objects you manipulate to change the room, such as boxes to push or carry, switches, levers and doors.
- Often, a rule of its own that bends the world, such as rewinding time, opening portals, rotating the perspective, switching layers of the level or taking turns between several characters.
- Short, self-contained rooms or levels, each one a problem, with an instant restart because making mistakes is part of solving.
Gameplay loop
Look at the whole room, work out why it seems impossible, try an idea with the moves you already know, see where it fails, restart and carry out the solution.
Our demo
Controls. Arrows or A/D to move, space, up arrow or W to jump, R to restart the room. To push a box, walk against it. On mobile, the on-screen buttons.
Open full screenThree small platforming rooms in which jumping is easy and thinking is the hard part. You push boxes, let them fall and put them on switches that open doors. Unlike the platformer demo, there are no pits, enemies or hurry, and unlike the puzzle demo, you move and jump in real time under gravity, so the height of your jump is one more rule of the puzzle.
Made with Kaplay, in about 165 lines of JavaScript.
How it's made
Setup and the jump rule
The game is created and the virtual buttons are defined. The most important constant is JUMP_FORCE, because it makes the jump reach one block and never two. In a platformer that number is a matter of feel, but here it is a level-design rule, because it determines which parts of the room are reachable and what you need a box for.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [24, 26, 40], buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, jump: { keyboard: ["space", "up", "w"] }, restart: { keyboard: ["r"] }, start: { keyboard: ["enter"] }, // space is already "jump", which also starts }, }); touchControls(k, [ { button: "left", label: "◀" }, { button: "right", label: "▶" }, { button: "restart", label: "⟲", side: "right" }, { button: "jump", label: "⤒", side: "right" }, ]); const TILE = 32; const SPEED = 170; // Jumping reaches about 1.7 tiles: enough to climb one box, never two. // That single number is a level-design rule: it decides what is reachable. const JUMP_FORCE = 420; const PUSH_DELAY = 0.15; // seconds leaning on a box before it moves one cell setGravity(1600);Three rooms, three ideas
Each room is a text map and teaches a single idea. In the first, a switch only opens the door while something stands on it, and if you stand on it you cannot get through. In the second you can only push, so you have to jump over the box to push it the right way, and if it falls into the pit it is lost. In the third, a box with no floor under it falls, and you need one box as a step and another as a weight.
// # wall · B box · _ switch · D door · E exit · @ player. // Each room teaches one idea, and the jump is never the hard part. const ROOMS = [ { hint: { es: "Sala 1 · El interruptor abre la puerta mientras algo lo pisa.", en: "Room 1 · The switch opens the door while something stands on it." }, map: [ "####################", "#..............#...#", "#..............#...#", "#..............#...#", "#..............#...#", "#..............#...#", "#..............D...#", "#..............D...#", "#.@...B...._...D.E.#", "####################", "####################", ], }, { hint: { es: "Sala 2 · Solo puedes empujar, nunca tirar. Piensa antes de mover.", en: "Room 2 · You can only push, never pull. Think before you move." }, map: [ "####################", "#..................#", "#..................#", "#..................#", "#..................#", "#..................#", "#E.................#", "###................#", "###....@..B........#", "#############.######", "####################", ], }, { hint: { es: "Sala 3 · Una caja cae si no tiene nada debajo.", en: "Room 3 · A box falls when there is nothing under it." }, map: [ "####################", "#...............#..#", "#...............#..#", "#...............#..#", "#...............#..#", "#...............#..#", "#......B........D..#", "#.....####......D..#", "#...._####..B.@.D.E#", "####################", "####################", ], }, ];Building the room
Each map character becomes an object. Walls, doors and boxes are static solids, which is why the character can stand on a box without it wobbling. Boxes also store their cell (box.cell), because the puzzle logic thinks in cells while the character moves in pixels.
// Walls, doors and boxes are solid. Boxes also live in a grid (box.cell): // the puzzle logic thinks in cells, while the player moves freely in pixels. room.map.forEach((row, y) => [...row].forEach((ch, x) => { const p = vec2(x * TILE, y * TILE); if (ch === "#") { walls.add(`${x},${y}`); add([rect(TILE, TILE), pos(p), color(60, 66, 96), area(), body({ isStatic: true })]); } else if (ch === "D") doors.push(add([rect(TILE, TILE), pos(p), color(200, 80, 80), area(), body({ isStatic: true }), "door", { cell: vec2(x, y) }])); else if (ch === "_") plates.push(add([rect(TILE, 6), pos(p.add(0, TILE - 6)), color(230, 190, 60), area(), "plate", { cell: vec2(x, y) }])); else if (ch === "B") boxes.push(add([rect(TILE, TILE), pos(p), color(176, 120, 72), outline(3, rgb(110, 70, 40)), area(), body({ isStatic: true }), "box", { cell: vec2(x, y) }])); else if (ch === "E") add([rect(TILE - 8, TILE), pos(p.add(4, 0)), color(120, 230, 150), area(), "exit"]); else if (ch === "@") player = add([rect(22, 28), pos(p.add(TILE / 2, TILE)), anchor("bot"), color(120, 200, 255), area(), body(), "player"]); }), ); add([text(t(room.hint), { size: 15, font: FONT }), pos(10, 8), z(10)]); add([text(t({ es: "R reinicia la sala", en: "R restarts the room" }), { size: 13, font: FONT }), pos(630, 344), anchor("right"), color(170, 180, 220)]);The character and the restart
Horizontal movement is computed in the fixed physics step (onFixedUpdate), 50 times per second, like gravity. That way a long frame on a slow computer does not carry you through a box. Restarting with R is instant and costs nothing, because in a puzzle a mistake should be cheap.
// Walking runs in the fixed physics step (50 times per second), like gravity: // on a slow computer a long frame could otherwise carry you through a box. onFixedUpdate(() => { const dir = (isButtonDown("right") ? 1 : 0) - (isButtonDown("left") ? 1 : 0); player.moveBy(dir * SPEED * fixedDt(), 0); }); onButtonPress("jump", () => { if (player.isGrounded()) player.jump(JUMP_FORCE); }); // Restarting is instant and costs nothing: in a puzzle, a mistake should be cheap. onButtonPress("restart", () => go("game", roomIndex)); player.onCollide("exit", () => (roomIndex + 1 < ROOMS.length ? go("game", roomIndex + 1) : go("end")));Pushing and dropping
If you walk against a box for a moment (PUSH_DELAY), it moves one cell, but only if that cell is free, the box is resting on something and there is no other box on top. Box gravity is grid logic too, because a still box with a free cell below drops one cell. The sprite glides towards the new cell so you can see it move.
const boxAt = (x, y) => boxes.find((b) => b.cell.x === x && b.cell.y === y); const isDoor = (x, y) => doors.some((d) => d.cell.x === x && d.cell.y === y); // A box cannot enter a cell the player is standing in. const playerIn = (x, y) => player.pos.x + 11 > x * TILE && player.pos.x - 11 < (x + 1) * TILE && player.pos.y > y * TILE + 2 && player.pos.y - 28 < (y + 1) * TILE; const blocked = (x, y) => walls.has(`${x},${y}`) || !!boxAt(x, y) || (isDoor(x, y) && !doorsOpen) || playerIn(x, y); const settled = (b) => b.pos.dist(b.cell.scale(TILE)) < 1; // Pushing: lean against a box for a moment and it slides one cell, if that // cell is free and nothing is sitting on top of the box (too heavy). let pushTime = 0; player.onUpdate(() => { const dir = (isButtonDown("right") ? 1 : 0) - (isButtonDown("left") ? 1 : 0); const row = Math.floor((player.pos.y - 4) / TILE); const box = dir && player.isGrounded() ? boxAt(Math.floor((player.pos.x + dir * 14) / TILE), row) : null; // Only a box that is resting on something can be pushed. pushTime = box && settled(box) && blocked(box.cell.x, box.cell.y + 1) ? pushTime + dt() : 0; if (pushTime < PUSH_DELAY) return; pushTime = 0; const [x, y] = [box.cell.x + dir, box.cell.y]; if (!boxAt(box.cell.x, y - 1) && !blocked(x, y)) box.cell = vec2(x, y); }); // Gravity for boxes is grid logic: a settled box with a free cell below drops one cell. // The sprite then glides towards its cell, so the player sees it move. onUpdate("box", (b) => { if (settled(b) && !blocked(b.cell.x, b.cell.y + 1)) b.cell = b.cell.add(0, 1); b.pos = b.pos.lerp(b.cell.scale(TILE), Math.min(1, dt() * 14)); });Switches and doors
The door is open while every switch is pressed by a box or by the character. Once open, it stops colliding (preventResolution) and turns almost transparent. It also stays open while something is inside it, so it never closes on you. The switch changes colour when pressed, so you see the rule without reading anything.
// A door is open while EVERY switch is pressed by a box or by the player. // It also stays open while something is inside it, so it never crushes you. player.onBeforePhysicsResolve((col) => { if (doorsOpen && col.target.is("door")) col.preventResolution(); }); onUpdate(() => { const pressed = (pl) => !!boxAt(pl.cell.x, pl.cell.y) || player.isOverlapping(pl); const occupied = doors.some((d) => player.isOverlapping(d) || boxAt(d.cell.x, d.cell.y)); doorsOpen = plates.every(pressed) || (doorsOpen && occupied); for (const pl of plates) pl.color = pressed(pl) ? rgb(110, 230, 120) : rgb(230, 190, 60); for (const d of doors) d.opacity = doorsOpen ? 0.15 : 1; });Scenes
Three scenes (title, game and end). The game scene receives the room number, and touching the exit calls go("game", room + 1), or end after the last one.
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: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]); onButtonPress("start", next); onButtonPress("jump", next); onMousePress(next); } scene("title", () => screen( t({ es: "Plataformas y puzles", en: "Puzzle-platformer" }), t({ es: "Flechas para moverte y saltar. Empuja cajas, pisa interruptores y llega a la salida verde.\nAquí no importa la rapidez: piensa antes de empujar. R reinicia la sala.\nPulsa espacio o toca para empezar.", en: "Arrows to move and jump. Push boxes, press switches and reach the green exit.\nSpeed does not matter here: think before you push. R restarts the room.\nPress space or tap to start.", }), () => go("game", 0), ), ); scene("end", () => screen( t({ es: "¡Resolviste las tres salas!", en: "You solved all three rooms!" }), t({ es: "Pulsa espacio o toca para jugar otra vez.", en: "Press space or tap to play again." }), () => go("game", 0), ), ); go("title");
What we simplified
- There are only three rooms and one mechanic (pushing boxes onto switches); games in the genre usually add a rule of their own, such as rewinding time or opening portals.
- Boxes live on a grid and move cell by cell, even though the character moves freely. This keeps the puzzle exact and independent of physics.
- There is no undo, only restarting the room; undoing in real time requires saving the state at every moment, as Braid does.
To modify it
- Design a fourth room with two switches and a single door. How many boxes are needed if the character can also stand on one?
- Allow pushing a box with another one on top, moving both together, and find which room becomes easier.
- Add an ice box that, when pushed, slides until it hits something. What new puzzles does it allow?
Full code
// Puzzle-platformer 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: [24, 26, 40],
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
jump: { keyboard: ["space", "up", "w"] },
restart: { keyboard: ["r"] },
start: { keyboard: ["enter"] }, // space is already "jump", which also starts
},
});
touchControls(k, [
{ button: "left", label: "◀" },
{ button: "right", label: "▶" },
{ button: "restart", label: "⟲", side: "right" },
{ button: "jump", label: "⤒", side: "right" },
]);
const TILE = 32;
const SPEED = 170;
// Jumping reaches about 1.7 tiles: enough to climb one box, never two.
// That single number is a level-design rule: it decides what is reachable.
const JUMP_FORCE = 420;
const PUSH_DELAY = 0.15; // seconds leaning on a box before it moves one cell
setGravity(1600);
// #endregion
// #region rooms
// # wall · B box · _ switch · D door · E exit · @ player.
// Each room teaches one idea, and the jump is never the hard part.
const ROOMS = [
{
hint: { es: "Sala 1 · El interruptor abre la puerta mientras algo lo pisa.", en: "Room 1 · The switch opens the door while something stands on it." },
map: [
"####################",
"#..............#...#",
"#..............#...#",
"#..............#...#",
"#..............#...#",
"#..............#...#",
"#..............D...#",
"#..............D...#",
"#.@...B...._...D.E.#",
"####################",
"####################",
],
},
{
hint: { es: "Sala 2 · Solo puedes empujar, nunca tirar. Piensa antes de mover.", en: "Room 2 · You can only push, never pull. Think before you move." },
map: [
"####################",
"#..................#",
"#..................#",
"#..................#",
"#..................#",
"#..................#",
"#E.................#",
"###................#",
"###....@..B........#",
"#############.######",
"####################",
],
},
{
hint: { es: "Sala 3 · Una caja cae si no tiene nada debajo.", en: "Room 3 · A box falls when there is nothing under it." },
map: [
"####################",
"#...............#..#",
"#...............#..#",
"#...............#..#",
"#...............#..#",
"#...............#..#",
"#......B........D..#",
"#.....####......D..#",
"#...._####..B.@.D.E#",
"####################",
"####################",
],
},
];
// #endregion
scene("game", (roomIndex) => {
const room = ROOMS[roomIndex];
const walls = new Set();
const boxes = [];
const doors = [];
const plates = [];
let player;
let doorsOpen = false;
// #region build
// Walls, doors and boxes are solid. Boxes also live in a grid (box.cell):
// the puzzle logic thinks in cells, while the player moves freely in pixels.
room.map.forEach((row, y) =>
[...row].forEach((ch, x) => {
const p = vec2(x * TILE, y * TILE);
if (ch === "#") {
walls.add(`${x},${y}`);
add([rect(TILE, TILE), pos(p), color(60, 66, 96), area(), body({ isStatic: true })]);
} else if (ch === "D") doors.push(add([rect(TILE, TILE), pos(p), color(200, 80, 80), area(), body({ isStatic: true }), "door", { cell: vec2(x, y) }]));
else if (ch === "_") plates.push(add([rect(TILE, 6), pos(p.add(0, TILE - 6)), color(230, 190, 60), area(), "plate", { cell: vec2(x, y) }]));
else if (ch === "B")
boxes.push(add([rect(TILE, TILE), pos(p), color(176, 120, 72), outline(3, rgb(110, 70, 40)), area(), body({ isStatic: true }), "box", { cell: vec2(x, y) }]));
else if (ch === "E") add([rect(TILE - 8, TILE), pos(p.add(4, 0)), color(120, 230, 150), area(), "exit"]);
else if (ch === "@") player = add([rect(22, 28), pos(p.add(TILE / 2, TILE)), anchor("bot"), color(120, 200, 255), area(), body(), "player"]);
}),
);
add([text(t(room.hint), { size: 15, font: FONT }), pos(10, 8), z(10)]);
add([text(t({ es: "R reinicia la sala", en: "R restarts the room" }), { size: 13, font: FONT }), pos(630, 344), anchor("right"), color(170, 180, 220)]);
// #endregion
// #region player
// Walking runs in the fixed physics step (50 times per second), like gravity:
// on a slow computer a long frame could otherwise carry you through a box.
onFixedUpdate(() => {
const dir = (isButtonDown("right") ? 1 : 0) - (isButtonDown("left") ? 1 : 0);
player.moveBy(dir * SPEED * fixedDt(), 0);
});
onButtonPress("jump", () => {
if (player.isGrounded()) player.jump(JUMP_FORCE);
});
// Restarting is instant and costs nothing: in a puzzle, a mistake should be cheap.
onButtonPress("restart", () => go("game", roomIndex));
player.onCollide("exit", () => (roomIndex + 1 < ROOMS.length ? go("game", roomIndex + 1) : go("end")));
// #endregion
// #region boxes
const boxAt = (x, y) => boxes.find((b) => b.cell.x === x && b.cell.y === y);
const isDoor = (x, y) => doors.some((d) => d.cell.x === x && d.cell.y === y);
// A box cannot enter a cell the player is standing in.
const playerIn = (x, y) => player.pos.x + 11 > x * TILE && player.pos.x - 11 < (x + 1) * TILE && player.pos.y > y * TILE + 2 && player.pos.y - 28 < (y + 1) * TILE;
const blocked = (x, y) => walls.has(`${x},${y}`) || !!boxAt(x, y) || (isDoor(x, y) && !doorsOpen) || playerIn(x, y);
const settled = (b) => b.pos.dist(b.cell.scale(TILE)) < 1;
// Pushing: lean against a box for a moment and it slides one cell, if that
// cell is free and nothing is sitting on top of the box (too heavy).
let pushTime = 0;
player.onUpdate(() => {
const dir = (isButtonDown("right") ? 1 : 0) - (isButtonDown("left") ? 1 : 0);
const row = Math.floor((player.pos.y - 4) / TILE);
const box = dir && player.isGrounded() ? boxAt(Math.floor((player.pos.x + dir * 14) / TILE), row) : null;
// Only a box that is resting on something can be pushed.
pushTime = box && settled(box) && blocked(box.cell.x, box.cell.y + 1) ? pushTime + dt() : 0;
if (pushTime < PUSH_DELAY) return;
pushTime = 0;
const [x, y] = [box.cell.x + dir, box.cell.y];
if (!boxAt(box.cell.x, y - 1) && !blocked(x, y)) box.cell = vec2(x, y);
});
// Gravity for boxes is grid logic: a settled box with a free cell below drops one cell.
// The sprite then glides towards its cell, so the player sees it move.
onUpdate("box", (b) => {
if (settled(b) && !blocked(b.cell.x, b.cell.y + 1)) b.cell = b.cell.add(0, 1);
b.pos = b.pos.lerp(b.cell.scale(TILE), Math.min(1, dt() * 14));
});
// #endregion
// #region switches
// A door is open while EVERY switch is pressed by a box or by the player.
// It also stays open while something is inside it, so it never crushes you.
player.onBeforePhysicsResolve((col) => {
if (doorsOpen && col.target.is("door")) col.preventResolution();
});
onUpdate(() => {
const pressed = (pl) => !!boxAt(pl.cell.x, pl.cell.y) || player.isOverlapping(pl);
const occupied = doors.some((d) => player.isOverlapping(d) || boxAt(d.cell.x, d.cell.y));
doorsOpen = plates.every(pressed) || (doorsOpen && occupied);
for (const pl of plates) pl.color = pressed(pl) ? rgb(110, 230, 120) : rgb(230, 190, 60);
for (const d of doors) d.opacity = doorsOpen ? 0.15 : 1;
});
// #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: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
onButtonPress("start", next);
onButtonPress("jump", next);
onMousePress(next);
}
scene("title", () =>
screen(
t({ es: "Plataformas y puzles", en: "Puzzle-platformer" }),
t({
es: "Flechas para moverte y saltar. Empuja cajas, pisa interruptores y llega a la salida verde.\nAquí no importa la rapidez: piensa antes de empujar. R reinicia la sala.\nPulsa espacio o toca para empezar.",
en: "Arrows to move and jump. Push boxes, press switches and reach the green exit.\nSpeed does not matter here: think before you push. R restarts the room.\nPress space or tap to start.",
}),
() => go("game", 0),
),
);
scene("end", () =>
screen(
t({ es: "¡Resolviste las tres salas!", en: "You solved all three rooms!" }),
t({ es: "Pulsa espacio o toca para jugar otra vez.", en: "Press space or tap to play again." }),
() => go("game", 0),
),
);
go("title");
// #endregionDesign challenges
- Keeping execution difficulty apart from reasoning difficulty. If the solution demands a pixel-perfect jump, the player cannot tell whether the idea or the hands failed.
- Avoiding unintended solutions. Continuous movement (jumps, pushes, physics) opens shortcuts a grid would not have, so every room must be tested by trying to break it.
- Recognising dead-end states (a box pushed against a wall or lost in a pit) and keeping restarts cheap.
- Introducing each new rule in a safe room and then combining it with the earlier ones, with little or no text.
History
- 1980Space Panic
Space Panic, in which you dig holes in platforms to trap creatures, foreshadows the genre.
Puzzle video game (Wikipedia) - 1983Origin
Enix's Door Door (1983) and Sega's Doki Doki Penguin Land (1985) are perhaps the first examples; in Door Door you trap aliens behind sliding doors.
Platformer (Wikipedia) - 1983Lode Runner
Lode Runner, one of the first games with a level editor, has you collect gold while being chased.
Lode Runner (Wikipedia) - 1985
In Sega's Doki Doki Penguin Land, you guide an egg to the bottom of the level by shifting and breaking blocks, without letting it fall too far.
Doki Doki Penguin Land (Wikipedia) - 1993The Lost Vikings
The Lost Vikings makes you combine three characters with different abilities.
The Lost Vikings (Wikipedia) - 2007Portal
Portal brings the genre into first person with a gun that opens two linked portals through which your body, objects and their momentum pass.
Portal (video game) (Wikipedia) - 2008Braid
Braid, an independent game, builds its puzzles on time manipulation.
Braid (video game) (Wikipedia) - 2010Limbo
Limbo, from the independent studio Playdead, mixes puzzles and horror and expects you to die many times while you work out how to get past each trap.
Limbo (video game) (Wikipedia) - 2012Fez
In Fez you rotate a 3D world to view it from one of its four flat sides, and rotating connects platforms that were out of reach before.
Fez (video game) (Wikipedia) - 2018ONOFF
ONOFF, made for js13kGames, has you switch between two layers of the level with one button, and is played in the browser.
ONOFF — a js13kGames 2018 game (GitHub)
Reference games
1980
Space Panic
Ladder-and-platform arcade game where you dig holes to trap enemies. Often cited as the first platform game.
1983
Lode Runner
You collect all the gold on each screen of ladders and platforms while guards chase you, and you can dig holes in the floor to trap them or open a way through. It was one of the first games with a level editor.
1993
The Lost Vikings
You switch control between three vikings with different abilities, one who runs and smashes walls, one who fights with a sword and bow, and one who blocks with a shield that also works as a glider and a platform. Each level can only be beaten by combining all three.
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.
2008
Braid
A puzzle platformer in which you can rewind time without limit, even after dying, and each world changes the rules of time. Its success without a publisher sparked wide interest in independent games.
2010
Limbo
A boy crosses a black-and-white forest full of deadly traps. Its makers called it a «trial and death» game, because they expect you to die many times while you work out how to solve each puzzle.
2012
Fez
You see a 3D world as if it were flat, from one of its four sides, and you can rotate it. Changing sides connects platforms that were apart before, and that is the basis of all its puzzles.
2018
ONOFF
Twenty-five short levels in which you jump over spikes and pits and, with one button, switch between two layers of the level that appear and disappear. It was made for js13kGames 2018, where the whole game fits in 13 kB, and its code is open source.
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 rooms and note what idea each one teaches. Then raise JUMP_FORCE until the character can jump two blocks and check which rooms stop being puzzles. Finally, design a fourth room with two switches in the text map and test it with a classmate without giving any hints.