
Endless runner
Your character runs forward on its own along a course that never ends; you only jump, dodge or switch lanes, and the goal is to get as far as possible before you crash.
Also known as: infinite runner, endless running game, runner
Defining mechanics
- The character moves forward on its own; you do not control where it goes, only the exact moment of each action.
- One or two controls, such as jump, slide, switch lanes or hold to fly, so it can be played with one finger.
- Randomly generated (procedural) obstacles, so that no run is the same as the last.
- The speed rises over time until you fail. The run always ends, and what counts is how long you last.
- Scoring by distance and a personal best, often with coins, upgrades and missions as long-term goals.
Gameplay loop
See what is coming, react at the right moment, survive a few more metres, crash and run again straight away to beat your record.
Our demo
Controls. Space, up arrow, W or click to jump (hold for a higher jump). Down arrow or S to slide. On mobile, tap the screen or use the buttons.
Open full screenThe character runs on its own through a city that never ends. You only decide when to jump and when to slide, while the speed keeps rising. It is the minimum needed to feel what defines the genre, which is a single input, reaction and a difficulty that grows by itself.
Made with Kaplay, in about 133 lines of JavaScript.
How it's made
Setup and controls
Two virtual buttons are defined, jump and slide, so the same game works with a keyboard or a touch screen. The starting speed, maximum speed and acceleration constants are the whole difficulty curve. The record (best) is declared outside the scenes so it survives each restart.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [24, 26, 52], // One main button (jump) and an optional one (slide). buttons: { jump: { keyboard: ["space", "up", "w"] }, slide: { keyboard: ["down", "s"] }, }, }); touchControls(k, [ { button: "slide", label: "▼" }, { button: "jump", label: "⤒", side: "right" }, ]); const GROUND_Y = 300; // the top of the floor const GRAVITY = 2200; const JUMP_FORCE = 720; const START_SPEED = 300; // pixels per second at the start const MAX_SPEED = 760; const ACCELERATION = 9; // speed gained every second const PLAYER_X = 120; setGravity(GRAVITY); // The session record lives outside the scenes, so it survives restarts. let best = 0;The world moves, not the player
The genre's trick: the player stays still on x and everything else scrolls left at the current speed. The floor stripes move at game speed and the background buildings at a quarter of it (parallax), which gives a sense of depth. When something leaves on the left it comes back on the right, so the scenery never runs out.
// The player never moves forward: the world moves left at `speed`. // Background blocks move slower than the floor (parallax) to suggest depth. add([rect(width(), height() - GROUND_Y), pos(0, GROUND_Y), color(70, 80, 120), area(), body({ isStatic: true })]); for (let i = 0; i < 8; i++) { const h = rand(60, 160); add([rect(70, h), pos(i * 90, GROUND_Y - h), color(44, 48, 86), z(-1), "skyline"]); } for (let i = 0; i < 12; i++) add([rect(24, 4), pos(i * 60, GROUND_Y + 20), color(110, 120, 170), "stripe"]); onUpdate("skyline", (b) => { b.move(-speed * 0.25, 0); if (b.pos.x < -80) b.pos.x += 8 * 90; }); onUpdate("stripe", (s) => { s.move(-speed, 0); if (s.pos.x < -30) s.pos.x += 12 * 60; });The player
A rectangle with area() to collide and body() so gravity affects it. With anchor("bot") its position is that of its feet, which makes it easy to change its height when sliding.
const player = add([rect(28, 44), color(120, 210, 255), pos(PLAYER_X, GROUND_Y), anchor("bot"), area(), body(), "player"]);A single button
Jumping is almost the whole interaction, so it must respond instantly and give some control. Releasing the button early cuts the rise short (variable jump), and a click or tap anywhere on the screen counts the same as the button, so it can be played with one hand.
// A single button: press to jump, release early for a shorter jump. // Clicking or tapping anywhere works like the jump button. const jump = () => { if (player.isGrounded()) player.jump(JUMP_FORCE); }; const cutJump = () => { if (player.vel.y < 0) player.vel.y *= 0.5; }; onButtonPress("jump", jump); onButtonRelease("jump", cutJump); onMousePress(jump); onMouseRelease(cutJump);Sliding
While you hold slide on the ground, the player is half as tall and passes under the flying bars. In the air, the same button makes you fall faster. A second action adds variety, but the genre works even without it.
// Optional second action: hold to slide under flying obstacles. // In the air it makes you fall faster, so you can land sooner. player.onUpdate(() => { const sliding = isButtonDown("slide"); player.height = sliding && player.isGrounded() ? 20 : 44; if (sliding && !player.isGrounded()) player.vel.y += 3000 * dt(); });Procedural obstacles
Each new obstacle is chosen at random (a low, tall or wide block, or a flying bar), so no two runs are the same. The key is the gap to the next one. It is not measured in pixels but in seconds of reaction, multiplied by the current speed. If the gap were fixed, at high speed the obstacles would arrive too close together and the game would become impossible.
// Procedural generation: each new obstacle is chosen at random, and the gap // to the previous one depends on the speed. Measuring the gap in TIME // (seconds of reaction) keeps the game fair even when it is very fast. let nextGap = 0; function spawnObstacle() { if (chance(0.3) && distance > 300) { // Flying bar: jump over it with good timing or slide under it. add([rect(44, 22), pos(width(), GROUND_Y - 30), anchor("bot"), color(255, 120, 90), area(), "obstacle"]); } else { const w = choose([22, 30, 54]); const h = choose([30, 44, 56]); add([rect(w, h), pos(width(), GROUND_Y), anchor("bot"), color(255, 200, 80), area(), "obstacle"]); } const seconds = rand(0.9, 1.8); // reaction time until the next obstacle nextGap = seconds * speed; } let sinceLast = 0; onUpdate("obstacle", (o) => { o.move(-speed, 0); if (o.pos.x < -80) destroy(o); }); onUpdate(() => { sinceLast += speed * dt(); if (sinceLast >= nextGap) { spawnObstacle(); sinceLast = 0; } }); player.onCollide("obstacle", () => { shake(8); go("end", Math.floor(distance / 10)); });Rising difficulty
Every second the speed rises a little, up to a cap. There are no levels. Difficulty grows by itself over time, which is why every run starts easy and ends, sooner or later, in a crash.
// Difficulty grows on its own: the world speeds up every second, up to a cap. onUpdate(() => { speed = Math.min(MAX_SPEED, speed + ACCELERATION * dt()); distance += speed * dt(); });Distance and record
The score is the distance covered, one metre every 10 pixels. Next to it is the session record, which is what invites «just one more» run.
// The score is the distance: one metre every 10 pixels. const hud = add([text("", { size: 18, font: FONT }), pos(16, 12), z(10)]); hud.onUpdate(() => { const metres = Math.floor(distance / 10); hud.text = t({ es: `Distancia: ${metres} m Récord: ${best} m`, en: `Distance: ${metres} m Best: ${best} m` }); });Scenes
There are three scenes, title, game and end. The end screen compares the distance with the record and updates it. A short pause before accepting the key keeps you from skipping that screen by accident while the button is still held.
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: 600 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255), ]); // A short pause stops a held key from skipping the screen. let ready = false; wait(0.4, () => (ready = true)); const start = () => ready && next(); onButtonPress("jump", start); onClick(start); } scene("title", () => screen( t({ es: "Corredor sin fin", en: "Endless runner" }), t({ es: "Corres solo. Espacio, flecha arriba o clic para saltar (mantén para saltar más alto).\nFlecha abajo para deslizarte. Llega lo más lejos que puedas.\nPulsa espacio o toca para empezar.", en: "You run on your own. Space, up arrow or click to jump (hold to jump higher).\nDown arrow to slide. Get as far as you can.\nPress space or tap to start.", }), () => go("game"), ), ); scene("end", (metres) => { const record = metres > best; best = Math.max(best, metres); screen( record ? t({ es: "¡Nuevo récord!", en: "New record!" }) : t({ es: "Chocaste", en: "You crashed" }), t({ es: `Distancia: ${metres} m. Récord de la sesión: ${best} m.\nPulsa espacio o toca para correr otra vez.`, en: `Distance: ${metres} m. Session best: ${best} m.\nPress space or tap to run again.`, }), () => go("game"), ); }); go("title");
What we simplified
- The character, the buildings and the obstacles are rectangles; there are no animations or sound.
- There are no coins, power-ups or missions, only distance. Coins and missions are what give long-term goals in commercial games.
- The record lasts while the page is open; it is not saved.
To modify it
- Change ACCELERATION and MAX_SPEED and note at what distance you usually crash. Which values make a run last about a minute?
- Gradually reduce the minimum reaction time between obstacles as the distance grows, and check that dodging them is still possible.
- Add coins in the air that give extra points, so you have to choose between playing safe and taking risks.
Full code
// Endless runner 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, 52],
// One main button (jump) and an optional one (slide).
buttons: {
jump: { keyboard: ["space", "up", "w"] },
slide: { keyboard: ["down", "s"] },
},
});
touchControls(k, [
{ button: "slide", label: "▼" },
{ button: "jump", label: "⤒", side: "right" },
]);
const GROUND_Y = 300; // the top of the floor
const GRAVITY = 2200;
const JUMP_FORCE = 720;
const START_SPEED = 300; // pixels per second at the start
const MAX_SPEED = 760;
const ACCELERATION = 9; // speed gained every second
const PLAYER_X = 120;
setGravity(GRAVITY);
// The session record lives outside the scenes, so it survives restarts.
let best = 0;
// #endregion
scene("game", () => {
let speed = START_SPEED;
let distance = 0;
// #region world
// The player never moves forward: the world moves left at `speed`.
// Background blocks move slower than the floor (parallax) to suggest depth.
add([rect(width(), height() - GROUND_Y), pos(0, GROUND_Y), color(70, 80, 120), area(), body({ isStatic: true })]);
for (let i = 0; i < 8; i++) {
const h = rand(60, 160);
add([rect(70, h), pos(i * 90, GROUND_Y - h), color(44, 48, 86), z(-1), "skyline"]);
}
for (let i = 0; i < 12; i++) add([rect(24, 4), pos(i * 60, GROUND_Y + 20), color(110, 120, 170), "stripe"]);
onUpdate("skyline", (b) => {
b.move(-speed * 0.25, 0);
if (b.pos.x < -80) b.pos.x += 8 * 90;
});
onUpdate("stripe", (s) => {
s.move(-speed, 0);
if (s.pos.x < -30) s.pos.x += 12 * 60;
});
// #endregion
// #region player
const player = add([rect(28, 44), color(120, 210, 255), pos(PLAYER_X, GROUND_Y), anchor("bot"), area(), body(), "player"]);
// #endregion
// #region jump
// A single button: press to jump, release early for a shorter jump.
// Clicking or tapping anywhere works like the jump button.
const jump = () => {
if (player.isGrounded()) player.jump(JUMP_FORCE);
};
const cutJump = () => {
if (player.vel.y < 0) player.vel.y *= 0.5;
};
onButtonPress("jump", jump);
onButtonRelease("jump", cutJump);
onMousePress(jump);
onMouseRelease(cutJump);
// #endregion
// #region slide
// Optional second action: hold to slide under flying obstacles.
// In the air it makes you fall faster, so you can land sooner.
player.onUpdate(() => {
const sliding = isButtonDown("slide");
player.height = sliding && player.isGrounded() ? 20 : 44;
if (sliding && !player.isGrounded()) player.vel.y += 3000 * dt();
});
// #endregion
// #region obstacles
// Procedural generation: each new obstacle is chosen at random, and the gap
// to the previous one depends on the speed. Measuring the gap in TIME
// (seconds of reaction) keeps the game fair even when it is very fast.
let nextGap = 0;
function spawnObstacle() {
if (chance(0.3) && distance > 300) {
// Flying bar: jump over it with good timing or slide under it.
add([rect(44, 22), pos(width(), GROUND_Y - 30), anchor("bot"), color(255, 120, 90), area(), "obstacle"]);
} else {
const w = choose([22, 30, 54]);
const h = choose([30, 44, 56]);
add([rect(w, h), pos(width(), GROUND_Y), anchor("bot"), color(255, 200, 80), area(), "obstacle"]);
}
const seconds = rand(0.9, 1.8); // reaction time until the next obstacle
nextGap = seconds * speed;
}
let sinceLast = 0;
onUpdate("obstacle", (o) => {
o.move(-speed, 0);
if (o.pos.x < -80) destroy(o);
});
onUpdate(() => {
sinceLast += speed * dt();
if (sinceLast >= nextGap) {
spawnObstacle();
sinceLast = 0;
}
});
player.onCollide("obstacle", () => {
shake(8);
go("end", Math.floor(distance / 10));
});
// #endregion
// #region difficulty
// Difficulty grows on its own: the world speeds up every second, up to a cap.
onUpdate(() => {
speed = Math.min(MAX_SPEED, speed + ACCELERATION * dt());
distance += speed * dt();
});
// #endregion
// #region score
// The score is the distance: one metre every 10 pixels.
const hud = add([text("", { size: 18, font: FONT }), pos(16, 12), z(10)]);
hud.onUpdate(() => {
const metres = Math.floor(distance / 10);
hud.text = t({ es: `Distancia: ${metres} m Récord: ${best} m`, en: `Distance: ${metres} m Best: ${best} m` });
});
// #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: 600 }),
pos(center().add(0, 30)),
anchor("center"),
color(200, 210, 255),
]);
// A short pause stops a held key from skipping the screen.
let ready = false;
wait(0.4, () => (ready = true));
const start = () => ready && next();
onButtonPress("jump", start);
onClick(start);
}
scene("title", () =>
screen(
t({ es: "Corredor sin fin", en: "Endless runner" }),
t({
es: "Corres solo. Espacio, flecha arriba o clic para saltar (mantén para saltar más alto).\nFlecha abajo para deslizarte. Llega lo más lejos que puedas.\nPulsa espacio o toca para empezar.",
en: "You run on your own. Space, up arrow or click to jump (hold to jump higher).\nDown arrow to slide. Get as far as you can.\nPress space or tap to start.",
}),
() => go("game"),
),
);
scene("end", (metres) => {
const record = metres > best;
best = Math.max(best, metres);
screen(
record ? t({ es: "¡Nuevo récord!", en: "New record!" }) : t({ es: "Chocaste", en: "You crashed" }),
t({
es: `Distancia: ${metres} m. Récord de la sesión: ${best} m.\nPulsa espacio o toca para correr otra vez.`,
en: `Distance: ${metres} m. Session best: ${best} m.\nPress space or tap to run again.`,
}),
() => go("game"),
);
});
go("title");
// #endregionDesign challenges
- Generating random obstacles without creating impossible situations. The gap between them is better measured in seconds of reaction than in pixels, because higher speed needs more distance.
- A speed curve that keeps the tension without becoming unfair, with the camera showing each obstacle early enough to react.
- Giving depth to a single button (variable jump, double jump, hold to act) without complicating the controls.
- Making restarts instant and offering goals besides distance (missions, collectibles), so repetition does not get boring.
History
- 1991SkiFree
SkiFree, a forerunner: you ski down an endless slope with randomly generated obstacles, chased by an invincible yeti.
Endless runner (Wikipedia) - 2009OriginCanabalt
Canabalt (Adam Saltsman, 2009) is the prototypical endless runner, with one button and distance as the score; Ovenbreak came out two months earlier. Its roots lie in forced-scrolling games such as Speed Race (1974).
Endless runner (Wikipedia) - 2011Temple Run
Temple Run, one of the earliest 3D entries, introduces an over-the-shoulder view and spawns a wave of clones.
Endless runner (Wikipedia) - 2011Jetpack Joyride
Jetpack Joyride swaps jumping for holding the screen to rise and letting go to fall, and adds three missions at a time as goals besides distance.
Jetpack Joyride (Wikipedia) - 2014Dinosaur Game
Google builds into Chrome an endless runner that appears when there is no connection. In 2018 its creators counted about 270 million games a month.
Dinosaur Game (Wikipedia) - 2017A Day In The Life
A Day In The Life fits in 13 KB for js13kGames. A single tap lets you jump, double jump, dash and fly, and it plays in the browser.
A Day In The Life (js13kGames 2017) - 2018Subway Surfers
Subway Surfers (2012) becomes the first game to pass one billion downloads on Google Play.
Subway Surfers (Wikipedia)
Reference games
1991
SkiFree
You ski down a slope that never ends, dodging trees and rocks; past the 2,000-metre mark a yeti appears, chases you and eventually catches you. Its author offers the 32-bit version for free on his site.
2009
Canabalt
You flee across the rooftops of a ruined city with a single button, jump, while the run keeps speeding up; your score is the distance covered. It popularised the endless runner, and its official site offers it for free in HTML5.
2011
Jetpack Joyride
The character moves forward on its own with a jetpack; you hold the screen to rise and let go to fall, dodging lasers and missiles. Three missions at a time give you goals beyond distance.
2011
Temple Run
You run nonstop through a temple with the camera fixed behind the character; you swipe to turn, jump or slide, and tilt the phone to collect coins. It had sequels and many imitators.
2012
Subway Surfers
You run along train tracks and swipe in four directions to dodge oncoming obstacles. In 2018 it became the first game to pass one billion downloads on Google Play.
2014
Dinosaur Game
It ships inside Google Chrome and appears when there is no connection (or at chrome://dino). A dinosaur runs on its own; you jump over cacti and duck under pterosaurs while the speed slowly rises. Its code is open, like the rest of Chromium.
2017
A Day In The Life
A one-tap pixel-art endless runner made for js13kGames 2017. The same button lets you jump, double jump, dash and fly, and you gradually lose those powers as the day goes on.
Exercise
In the demo, change ACCELERATION and MAX_SPEED and note the distance at which you crash. Then change the range of reaction time between obstacles and find the point where the game stops being fair. Finally, play Canabalt and A Day In The Life and compare how each one gets variety out of a single button.