
Corredor sin fin
Tu personaje corre solo y sin parar por un recorrido que nunca termina; tú solo saltas, esquivas o cambias de carril, y la meta es llegar lo más lejos posible antes de chocar.
También se llama: corredor infinito, endless runner, runner, corredores
Mecánicas que lo definen
- El personaje avanza solo; tú no controlas hacia dónde va, sino el momento exacto de cada acción.
- Uno o dos controles, como saltar, deslizarse, cambiar de carril o mantener pulsado para volar, de modo que se juega con un dedo.
- Obstáculos generados al azar (procedurales), para que ninguna partida sea igual a la anterior.
- La velocidad sube con el tiempo hasta que fallas. La partida siempre termina, y lo que cuenta es cuánto aguantas.
- Puntuación por distancia y récord personal, a menudo con monedas, mejoras y misiones como metas a largo plazo.
Bucle de juego
Ver lo que viene, reaccionar en el momento justo, sobrevivir unos metros más, chocar y volver a correr al instante para superar tu récord.
Demo propia
Controles. Espacio, flecha arriba, W o clic para saltar (mantén pulsado para saltar más alto). Flecha abajo o S para deslizarte. En móvil, toca la pantalla o usa los botones.
Abrir en pantalla completaEl personaje corre solo por una ciudad que no termina. Tú solo decides cuándo saltar y cuándo deslizarte, mientras la velocidad sube sin parar. Es lo mínimo para sentir lo que define al género, que es un solo input, reacción y una dificultad que crece sola.
Hecha con Kaplay, en unas 133 líneas de JavaScript.
Cómo está hecha
Configuración y controles
Se definen dos botones virtuales, jump y slide, para que el mismo juego funcione con teclado o con pantalla táctil. Las constantes de velocidad inicial, velocidad máxima y aceleración son la curva de dificultad entera. El récord (best) se declara fuera de las escenas para que sobreviva a cada reinicio.
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;El mundo se mueve, no el jugador
El truco del género: el jugador se queda quieto en x y todo lo demás se desplaza hacia la izquierda a la velocidad actual. Las rayas del suelo van a la velocidad del juego y los edificios del fondo a un cuarto (paralaje), lo que da sensación de profundidad. Cuando algo sale por la izquierda vuelve a la derecha, así el escenario no se acaba nunca.
// 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; });El jugador
Un rectángulo con area() para chocar y body() para que le afecte la gravedad. Con anchor("bot") su posición es la de los pies, lo que facilita cambiar su altura al deslizarse.
const player = add([rect(28, 44), color(120, 210, 255), pos(PLAYER_X, GROUND_Y), anchor("bot"), area(), body(), "player"]);Un solo botón
Saltar es casi toda la interacción, así que tiene que responder al instante y dar algo de control. Soltar el botón antes corta la subida (salto variable), y un clic o toque en cualquier parte de la pantalla cuenta igual que el botón, para que se pueda jugar con una mano.
// 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);Deslizarse
Mientras mantienes slide en el suelo, el jugador mide la mitad de alto y pasa por debajo de las barras voladoras. En el aire, el mismo botón te hace caer más rápido. Una segunda acción da variedad, pero el género funciona aunque no la tenga.
// 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(); });Obstáculos procedurales
Cada obstáculo nuevo se elige al azar (bloque bajo, alto o ancho, o una barra voladora), así ninguna partida es igual. La clave está en la distancia al siguiente. No se mide en píxeles sino en segundos de reacción, multiplicados por la velocidad actual. Si la distancia fuera fija, a mucha velocidad los obstáculos llegarían demasiado seguidos y el juego se volvería imposible.
// 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)); });Dificultad creciente
Cada segundo la velocidad sube un poco hasta un tope. No hay niveles. La dificultad crece sola con el tiempo, y por eso cada partida empieza fácil y termina, tarde o temprano, en un choque.
// 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(); });Distancia y récord
La puntuación es la distancia recorrida, un metro cada 10 píxeles. Al lado se ve el récord de la sesión, que es lo que invita a darle «una más».
// 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` }); });Escenas
Hay tres escenas (title, game y end). La pantalla final compara la distancia con el récord y lo actualiza. Una pausa breve antes de aceptar la tecla evita saltarse esa pantalla sin querer, con el botón todavía pulsado.
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");
Qué simplificamos
- El personaje, los edificios y los obstáculos son rectángulos; no hay animaciones ni sonido.
- No hay monedas, potenciadores ni misiones, solo la distancia. Las monedas y las misiones son lo que en los juegos comerciales da metas a largo plazo.
- El récord dura mientras la página está abierta; no se guarda.
Para modificarla
- Cambia ACCELERATION y MAX_SPEED y anota a qué distancia sueles chocar. ¿Qué valores hacen que una partida dure alrededor de un minuto?
- Reduce poco a poco el tiempo de reacción mínimo entre obstáculos a medida que sube la distancia, y comprueba que sigue siendo posible esquivarlos.
- Añade monedas en el aire que den puntos extra, para que haya que elegir entre ir seguro o arriesgarse.
Código completo
// 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");
// #endregionRetos de diseño
- Generar obstáculos al azar sin crear situaciones imposibles. El espacio entre ellos se mide mejor en segundos de reacción que en píxeles, porque a más velocidad hace falta más distancia.
- Una curva de velocidad que mantenga la tensión sin volverse injusta, con la cámara mostrando el obstáculo con tiempo suficiente para reaccionar.
- Dar profundidad a un solo botón (salto variable, doble salto, mantener pulsado) sin complicar el control.
- Que reiniciar sea instantáneo y que haya metas aparte de la distancia (misiones, coleccionables), para que repetir no aburra.
Historia
- 1991SkiFree
SkiFree, un precursor: bajas esquiando por una pendiente sin fin con obstáculos generados al azar, perseguido por un yeti invencible.
Endless runner (Wikipedia) - 2009OrigenCanabalt
Canabalt (Adam Saltsman, 2009) es el corredor sin fin prototípico, con un botón y la distancia como puntuación; Ovenbreak salió dos meses antes. Su raíz está en juegos de desplazamiento forzado como Speed Race (1974).
Endless runner (Wikipedia) - 2011Temple Run
Temple Run, uno de los primeros en 3D, introduce la cámara sobre el hombro y genera una ola de clones.
Endless runner (Wikipedia) - 2011Jetpack Joyride
Jetpack Joyride cambia el salto por mantener pulsada la pantalla para subir y soltarla para bajar, y añade tres misiones a la vez como metas aparte de la distancia.
Jetpack Joyride (Wikipedia) - 2014Dinosaur Game
Google incluye en Chrome un corredor sin fin que aparece cuando no hay conexión. En 2018 sus autores contaron unos 270 millones de partidas al mes.
Dinosaur Game (Wikipedia) - 2017A Day In The Life
A Day In The Life cabe en 13 KB para js13kGames. Un solo toque sirve para saltar, hacer doble salto, embestir y volar, y se juega en el navegador.
A Day In The Life (js13kGames 2017) - 2018Subway Surfers
Subway Surfers (2012) se convierte en el primer juego que supera los mil millones de descargas en Google Play.
Subway Surfers (Wikipedia)
Juegos de referencia
1991
SkiFree
Bajas esquiando por una pendiente que no termina, esquivando árboles y rocas; pasados los 2000 metros aparece un yeti que te persigue y acaba atrapándote. Su autor ofrece gratis la versión de 32 bits en su sitio.
2009
Canabalt
Huyes por los tejados de una ciudad en ruinas con un solo botón, saltar, mientras la carrera se acelera; la puntuación es la distancia recorrida. Popularizó el corredor sin fin, y su sitio oficial lo ofrece gratis en HTML5.
2011
Jetpack Joyride
El personaje avanza solo con una mochila cohete; mantienes pulsada la pantalla para subir y la sueltas para bajar, esquivando láseres y misiles. Tres misiones simultáneas dan metas más allá de la distancia.
2011
Temple Run
Corres sin parar por un templo con la cámara fija detrás del personaje; deslizas el dedo para girar, saltar o agacharte, e inclinas el teléfono para recoger monedas. Tuvo secuelas y muchos imitadores.
2012
Subway Surfers
Corres por las vías del tren y deslizas el dedo en cuatro direcciones para esquivar los obstáculos que vienen de frente. En 2018 fue el primer juego en superar los mil millones de descargas en Google Play.
2014
Dinosaur Game
Viene dentro de Google Chrome y aparece cuando no hay conexión (o en chrome://dino). Un dinosaurio corre solo; saltas cactus y te agachas bajo pterosaurios mientras la velocidad sube poco a poco. Su código es abierto, como el resto de Chromium.
2017
A Day In The Life
Corredor sin fin de un solo toque, en pixel art, hecho para js13kGames 2017. Con el mismo botón saltas, haces doble salto, embistes y vuelas, y vas perdiendo esos poderes a lo largo del día.
Ejercicio
En la demo, cambia ACCELERATION y MAX_SPEED y anota a qué distancia chocas. Luego cambia el rango del tiempo de reacción entre obstáculos y busca el punto en que el juego deja de ser justo. Por último, juega Canabalt y A Day In The Life y compara cómo cada uno saca variedad de un solo botón.