Cómo construir un juego de plataformas
Este tutorial fue escrito en febrero de 2026, para la v2 del motor.
El motor no tiene física integrada — ni gravedad, ni respuesta a colisiones, ni función de salto. Todo lo construyes tú con unas pocas variables y matemáticas sencillas. Vamos a empezar con un rectángulo que cae e ir añadiendo progresivamente plataformas sólidas, movimiento horizontal, salto, plataformas de un sentido, coyote time, aplastamiento de enemigos y bloques reactivos. Al final tendrás un pequeño juego de plataformas jugable y un patrón reutilizable para construir el tuyo. Si quieres ver estas mecánicas en un juego completo, el playground de plataformas usa el mismo enfoque a mayor escala.
Gravedad y caída
Todo juego de plataformas empieza con la gravedad. La idea es simple: una variable velocityY registra la velocidad vertical del jugador. En cada frame, la gravedad incrementa velocityY por una constante pequeña, y velocityY se suma a la posición y del jugador. El jugador acelera hacia abajo hasta que choca con algo. Un límite maxFall evita que la velocidad crezca indefinidamente. Usamos rectfill en lugar de sprites aquí para mantener el foco en la física:
engine.scope(({ start, cls, rectfill, text }) => {
const gravity = 0.3;
const maxFall = 4;
const floor = 112;
let y = 20;
let velocityY = 0;
function update() {
velocityY += gravity;
if (velocityY > maxFall) velocityY = maxFall;
y += velocityY;
if (y >= floor) {
y = floor;
velocityY = 0;
}
}
function draw() {
cls(12);
rectfill(0, 120, 128, 128, 4);
rectfill(60, y, 68, y + 8, 8);
text('vel: ' + velocityY.toFixed(1), 4, 4, 7);
}
start({ sprites: {}, sounds: {}, update, draw, target });
});
Plataformas sólidas con flags de sprites
Una línea de suelo fija no escala. En su lugar, definimos sprites con el flag 0 en true para marcarlos como sólidos, y luego comprobamos colisiones contra una cuadrícula de tiles. La función checkTileCollision convierte las cuatro esquinas del jugador en coordenadas de tile y verifica si alguna se superpone con un tile sólido usando fget(tile, 0). La función moveY avanza píxel a píxel para que el jugador se detenga exactamente en la superficie en lugar de atravesarla:
engine.scope(({ start, cls, spr, rectfill, fget }) => {
const gravity = 0.3;
const maxFall = 4;
const sprites = {
ground: [
[
11, 11, 11, 11, 11, 11, 11, 11,
11, 11, 11, 11, 11, 11, 11, 11,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 5, 4, 4, 4, 5, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 5, 4, 4, 4, 4,
],
[true, false, false, false, false, false, false, false],
],
};
// 16x16 tile grid — 0 = empty, 'ground' = solid
const level = [];
for (let r = 0; r < 16; r++) level[r] = new Array(16).fill(0);
for (let c = 0; c < 16; c++) level[15][c] = 'ground';
for (let c = 3; c <= 6; c++) level[11][c] = 'ground';
for (let c = 9; c <= 12; c++) level[8][c] = 'ground';
let px = 8,
py = 20,
vy = 0;
function checkTileCollision(x, y, w, h) {
const corners = [
[Math.floor(x / 8), Math.floor(y / 8)],
[Math.floor((x + w - 1) / 8), Math.floor(y / 8)],
[Math.floor(x / 8), Math.floor((y + h - 1) / 8)],
[Math.floor((x + w - 1) / 8), Math.floor((y + h - 1) / 8)],
];
for (const [tx, ty] of corners) {
const tile = level[ty]?.[tx];
if (tile && fget(tile, 0)) return true;
}
return false;
}
function moveY(dy) {
const step = dy > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(Math.round(dy)); i++) {
py += step;
if (checkTileCollision(px, py, 8, 8)) {
py -= step;
vy = 0;
break;
}
}
}
function update() {
vy += gravity;
if (vy > maxFall) vy = maxFall;
moveY(vy);
}
function draw() {
cls(12);
for (let r = 0; r < 16; r++) {
for (let c = 0; c < 16; c++) {
if (level[r][c]) spr(level[r][c], c * 8, r * 8);
}
}
rectfill(px, py, px + 8, py + 8, 8);
}
start({ sprites, sounds: {}, update, draw, target });
});
Usar el flag 0 para solidez es una convención, no una regla. Puedes usar cualquiera de los 8 flags para cualquier propósito — solo sé consistente en todos tus sprites.
Movimiento horizontal
Caminar funciona igual que caer: una variable velocityX, aceleración desde la entrada del teclado y fricción para frenar cuando no se presionan teclas. Limitamos la velocidad para que el jugador no pueda acelerar indefinidamente. La función moveX replica a moveY — intenta mover, revierte si hay colisión. Separar el movimiento X e Y en dos funciones hace que el movimiento diagonal se resuelva correctamente: el jugador se desliza por las paredes en lugar de quedarse atascado:
// inside update(), before moveY
if (btn('ArrowLeft')) vx -= 0.3;
if (btn('ArrowRight')) vx += 0.3;
if (!btn('ArrowLeft') && !btn('ArrowRight')) {
vx *= 0.8;
if (Math.abs(vx) < 0.1) vx = 0;
}
vx = Math.max(-1.5, Math.min(1.5, vx));
// moveX works like moveY but on the horizontal axis
function moveX(dx) {
px += dx;
if (checkTileCollision(px, py, 8, 8)) {
px -= dx;
vx = 0;
}
}
moveX(vx);
Salto
Saltar es simplemente asignar un número negativo a velocityY. La gravedad ya se encarga del arco — el jugador sube, desacelera y vuelve a caer naturalmente. La restricción clave: solo permitir saltos cuando grounded es verdadero. Detectamos si está en el suelo probando un píxel debajo del jugador en busca de un tile sólido — si hay suelo directamente bajo los pies, el jugador está en el suelo. Este fragmento combina todo lo anterior en un ejemplo completo y ejecutable con un sprite de jugador:
engine.scope(({ start, cls, spr, rectfill, btn, btnp, fget }) => {
const gravity = 0.3;
const maxFall = 4;
const jumpPower = -5;
const sprites = {
player: [
[
-1, -1, 8, 8, 8, 8, -1, -1,
-1, -1, 8, 8, 8, 8, 8, 8,
-1, -1, 15, 15, 15, 1, -1, -1,
-1, -1, 15, 15, 15, 15, 15, -1,
-1, 2, 2, 2, 2, 2, -1, -1,
-1, 15, 2, 2, 2, 2, 15, -1,
-1, -1, 1, 1, 9, 1, -1, -1,
-1, 4, 4, -1, -1, 4, 4, -1,
],
[false, false, false, false, false, false, false, false],
],
ground: [
[
11, 11, 11, 11, 11, 11, 11, 11,
11, 11, 11, 11, 11, 11, 11, 11,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 5, 4, 4, 4, 5, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 5, 4, 4, 4, 4,
],
[true, false, false, false, false, false, false, false],
],
};
const level = [];
for (let r = 0; r < 16; r++) level[r] = new Array(16).fill(0);
for (let c = 0; c < 16; c++) level[15][c] = 'ground';
for (let c = 3; c <= 6; c++) level[11][c] = 'ground';
for (let c = 9; c <= 12; c++) level[8][c] = 'ground';
let px = 8,
py = 104,
vx = 0,
vy = 0,
grounded = false,
facing = 1;
function checkTileCollision(x, y, w, h) {
const corners = [
[Math.floor(x / 8), Math.floor(y / 8)],
[Math.floor((x + w - 1) / 8), Math.floor(y / 8)],
[Math.floor(x / 8), Math.floor((y + h - 1) / 8)],
[Math.floor((x + w - 1) / 8), Math.floor((y + h - 1) / 8)],
];
for (const [tx, ty] of corners) {
const tile = level[ty]?.[tx];
if (tile && fget(tile, 0)) return true;
}
return false;
}
function moveX(dx) {
px += dx;
if (checkTileCollision(px, py, 8, 8)) {
px -= dx;
vx = 0;
}
}
function moveY(dy) {
const step = dy > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(Math.round(dy)); i++) {
py += step;
if (checkTileCollision(px, py, 8, 8)) {
py -= step;
if (dy > 0) grounded = true;
vy = 0;
break;
}
}
}
function update() {
if (btn('ArrowLeft')) {
vx -= 0.3;
facing = -1;
}
if (btn('ArrowRight')) {
vx += 0.3;
facing = 1;
}
if (!btn('ArrowLeft') && !btn('ArrowRight')) {
vx *= 0.8;
if (Math.abs(vx) < 0.1) vx = 0;
}
vx = Math.max(-1.5, Math.min(1.5, vx));
if (btnp(' ') && grounded) {
vy = jumpPower;
grounded = false;
}
vy += gravity;
if (vy > maxFall) vy = maxFall;
moveX(vx);
moveY(vy);
grounded = checkTileCollision(px, py + 1, 8, 8);
}
function draw() {
cls(12);
for (let r = 0; r < 16; r++) {
for (let c = 0; c < 16; c++) {
if (level[r][c]) spr(level[r][c], c * 8, r * 8);
}
}
spr('player', px, py, 1, 1, facing === -1);
}
start({ sprites, sounds: {}, update, draw, target });
});
Plataformas de un sentido
Las plataformas de un sentido dejan al jugador saltar a través de ellas desde abajo pero aterrizar sobre ellas desde arriba. Usamos un segundo flag de sprite — fget(tile, 1) — para marcar tiles de un sentido. En la comprobación de colisión, los tiles de un sentido solo cuentan como sólidos cuando el jugador está cayendo (velocityY > 0) y sus pies están cerca del borde superior del tile. Un dropTimer permite al jugador presionar Abajo + Espacio para caer a través — ignora temporalmente las colisiones de un sentido durante unos frames:
// flag 0 = solid, flag 1 = one-way
let dropTimer = 0;
function checkTileCollision(x, y, w, h, vy) {
const corners = [
[Math.floor(x / 8), Math.floor(y / 8)],
[Math.floor((x + w - 1) / 8), Math.floor(y / 8)],
[Math.floor(x / 8), Math.floor((y + h - 1) / 8)],
[Math.floor((x + w - 1) / 8), Math.floor((y + h - 1) / 8)],
];
for (const [tx, ty] of corners) {
const tile = level[ty]?.[tx];
if (tile && fget(tile, 0)) return true;
if (tile && fget(tile, 1) && dropTimer <= 0 && vy > 0) {
const tileTop = ty * 8;
if (y + h - 1 >= tileTop && y + h - 1 < tileTop + 2) return true;
}
}
return false;
}
// inside update()
if (dropTimer > 0) dropTimer--;
if (btn('ArrowDown') && btnp(' ')) dropTimer = 8;
Siempre resuelve el movimiento X e Y en pasos separados. Si te mueves en diagonal en un solo paso, el jugador puede recortar esquinas o atravesar plataformas finas. Mueve X primero, comprueba colisiones, luego mueve Y y comprueba de nuevo.
Coyote time
El coyote time es un pequeño periodo de gracia que permite al jugador saltar durante unos frames después de caminar fuera de un borde. Sin él, los jugadores que presionan salto un frame tarde caen al vacío y culpan a los controles. La implementación es un contador: asígnale un valor fijo cuando el jugador está en el suelo, decrémentalo cada frame mientras está en el aire. Reemplazamos la condición grounded en el salto con coyoteTimer > 0. Cuando el jugador salta, ponemos el contador a cero para que no pueda hacer doble salto:
// add to your player variables
const coyoteFrames = 5;
let coyoteTimer = 0;
// inside update(), before the jump check
if (grounded) {
coyoteTimer = coyoteFrames;
} else {
coyoteTimer--;
}
// replace the old jump condition
if (btnp(' ') && coyoteTimer > 0) {
vy = jumpPower;
coyoteTimer = 0;
grounded = false;
}
De 3 a 6 frames a 60fps es una ventana de coyote time típica. Demasiado corta y los jugadores no la notarán. Demasiado larga y saltarán desde el aire de formas que parecen incorrectas.
Colisión con enemigos
Un enemigo puede ser tan simple como una posición y un flag alive colocado en el mundo. La parte interesante es lo que ocurre cuando el jugador lo toca. Usamos boxesCollide para detectar la superposición, luego comprobamos la dirección: si el jugador está cayendo y su borde inferior está por encima del centro del enemigo, es un aplastamiento — elimina al enemigo, rebota al jugador hacia arriba y otorga puntos. Si el jugador camina hacia el enemigo por el lado, recibe daño (aquí, se reinicia en el punto de aparición). El mismo patrón funciona para cualquier colisión donde el resultado depende de la dirección de aproximación:
// enemy state — just a position and whether it's alive
const enemy = { x: 80, y: 96, alive: true };
// inside update() — collision with player
if (enemy.alive) {
if (boxesCollide([px, py, 8, 8], [enemy.x, enemy.y, 8, 8])) {
const playerBottom = py + 8;
const enemyCenter = enemy.y + 4;
if (vy > 0 && playerBottom < enemyCenter) {
// landed on top — stomp
enemy.alive = false;
vy = -3;
score += 10;
} else {
// walked into the side — take damage
px = 8;
py = 96;
vx = 0;
vy = 0;
}
}
}
Bloques reactivos
Los bloques de interrogación que sueltan monedas al golpearlos desde abajo — un clásico de los juegos de plataformas. La comprobación ocurre dentro de moveY: cuando el jugador se mueve hacia arriba y golpea un tile sólido, llamamos a checkBlockHit. Esa función encuentra el tile directamente sobre la cabeza del jugador, comprueba si es un sprite 'question', lo cambia por 'question-empty' e incrementa la puntuación. Aquí está el moveY actualizado con la llamada al golpe de bloque conectada:
let score = 0;
function checkBlockHit() {
const headX = Math.floor((px + 4) / 8);
const headY = Math.floor((py - 1) / 8);
const tile = level[headY]?.[headX];
if (tile === 'question') {
level[headY][headX] = 'question-empty';
score++;
}
}
// inside moveY(), when moving upward and a collision is detected:
// if (dy < 0) { ... }
function moveY(dy) {
const step = dy > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(Math.round(dy)); i++) {
py += step;
if (checkTileCollision(px, py, 8, 8, dy)) {
py -= step;
if (dy > 0) grounded = true;
if (dy < 0) checkBlockHit();
vy = 0;
break;
}
}
}
Todo junto
Aquí está todo en un único ejemplo ejecutable: gravedad, plataformas sólidas, movimiento horizontal, salto, plataformas de un sentido, coyote time, un enemigo y un bloque de interrogación. El nivel es una cuadrícula de 16x16 tiles que cabe en la pantalla de 128x128 sin desplazamiento. Flechas para moverte, Espacio para saltar, Abajo + Espacio para caer a través de plataformas de un sentido. Aplasta al enemigo y golpea el bloque de interrogación desde abajo:
engine.scope(({ start, cls, spr, text, btn, btnp, fget, boxesCollide }) => {
const gravity = 0.3;
const maxFall = 4;
const jumpPower = -5;
const coyoteFrames = 5;
const sprites = {
player: [
[
-1, -1, 8, 8, 8, 8, -1, -1,
-1, -1, 8, 8, 8, 8, 8, 8,
-1, -1, 15, 15, 15, 1, -1, -1,
-1, -1, 15, 15, 15, 15, 15, -1,
-1, 2, 2, 2, 2, 2, -1, -1,
-1, 15, 2, 2, 2, 2, 15, -1,
-1, -1, 1, 1, 9, 1, -1, -1,
-1, 4, 4, -1, -1, 4, 4, -1,
],
[false, false, false, false, false, false, false, false],
],
ground: [
[
11, 11, 11, 11, 11, 11, 11, 11,
11, 11, 11, 11, 11, 11, 11, 11,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 5, 4, 4, 4, 5, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 5, 4, 4, 4, 4,
],
[true, false, false, false, false, false, false, false],
],
platform: [
[
6, 6, 6, 6, 6, 6, 6, 6,
13, 13, 13, 13, 13, 13, 13, 13,
-1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
],
[false, true, false, false, false, false, false, false],
],
enemy: [
[
-1, -1, 4, 4, 4, 4, -1, -1,
-1, 4, 4, 4, 4, 4, 4, -1,
-1, 4, 4, 4, 4, 4, 4, -1,
4, 0, 4, 4, 0, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
1, 1, 4, 2, 2, 4, 4, 1,
1, 1, -1, -1, -1, -1, 1, 1,
],
[false, false, false, false, false, false, false, false],
],
question: [
[
9, 9, 9, 9, 9, 9, 9, 9,
9, 10, 10, 10, 10, 10, 10, 9,
9, 10, 7, 7, 7, 7, 10, 9,
9, 10, 7, 10, 10, 7, 10, 9,
9, 10, 7, 7, 7, 7, 10, 9,
9, 10, 10, 7, 7, 10, 10, 9,
9, 10, 10, 10, 10, 10, 10, 9,
9, 9, 9, 9, 9, 9, 9, 9,
],
[true, false, false, false, false, false, false, false],
],
'question-empty': [
[
9, 9, 9, 9, 9, 9, 9, 9,
9, 4, 4, 4, 4, 4, 4, 9,
9, 4, 5, 5, 5, 5, 4, 9,
9, 4, 5, 4, 4, 5, 4, 9,
9, 4, 5, 5, 5, 5, 4, 9,
9, 4, 4, 5, 5, 4, 4, 9,
9, 4, 4, 4, 4, 4, 4, 9,
9, 9, 9, 9, 9, 9, 9, 9,
],
[true, false, false, false, false, false, false, false],
],
};
const level = [];
for (let r = 0; r < 16; r++) level[r] = new Array(16).fill(0);
for (let c = 0; c < 16; c++) level[15][c] = 'ground';
for (let c = 0; c <= 6; c++) level[13][c] = 'ground';
for (let c = 9; c <= 15; c++) level[13][c] = 'ground';
for (let c = 3; c <= 5; c++) level[10][c] = 'ground';
for (let c = 10; c <= 12; c++) level[10][c] = 'platform';
level[9][7] = 'question';
let px = 8,
py = 96,
vx = 0,
vy = 0,
grounded = false,
facing = 1,
coyoteTimer = 0,
dropTimer = 0,
score = 0;
const enemy = { x: 80, y: 96, alive: true };
function checkTileCollision(x, y, w, h, velY) {
const corners = [
[Math.floor(x / 8), Math.floor(y / 8)],
[Math.floor((x + w - 1) / 8), Math.floor(y / 8)],
[Math.floor(x / 8), Math.floor((y + h - 1) / 8)],
[Math.floor((x + w - 1) / 8), Math.floor((y + h - 1) / 8)],
];
for (const [tx, ty] of corners) {
const tile = level[ty]?.[tx];
if (tile && fget(tile, 0)) return true;
if (tile && fget(tile, 1) && dropTimer <= 0 && velY > 0) {
const tileTop = ty * 8;
if (y + h - 1 >= tileTop && y + h - 1 < tileTop + 2) return true;
}
}
return false;
}
function moveX(dx) {
px += dx;
if (checkTileCollision(px, py, 8, 8, 0)) {
px -= dx;
vx = 0;
}
}
function checkBlockHit() {
const headX = Math.floor((px + 4) / 8);
const headY = Math.floor((py - 1) / 8);
const tile = level[headY]?.[headX];
if (tile === 'question') {
level[headY][headX] = 'question-empty';
score++;
}
}
function moveY(dy) {
const step = dy > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(Math.round(dy)); i++) {
py += step;
if (checkTileCollision(px, py, 8, 8, dy)) {
py -= step;
if (dy > 0) grounded = true;
if (dy < 0) checkBlockHit();
vy = 0;
break;
}
}
}
function update() {
if (btn('ArrowLeft')) {
vx -= 0.3;
facing = -1;
}
if (btn('ArrowRight')) {
vx += 0.3;
facing = 1;
}
if (!btn('ArrowLeft') && !btn('ArrowRight')) {
vx *= 0.8;
if (Math.abs(vx) < 0.1) vx = 0;
}
vx = Math.max(-1.5, Math.min(1.5, vx));
if (grounded) coyoteTimer = coyoteFrames;
else coyoteTimer--;
if (dropTimer > 0) dropTimer--;
if (btn('ArrowDown') && btnp(' ')) {
dropTimer = 8;
} else if (btnp(' ') && coyoteTimer > 0) {
vy = jumpPower;
coyoteTimer = 0;
grounded = false;
}
vy += gravity;
if (vy > maxFall) vy = maxFall;
moveX(vx);
moveY(vy);
grounded = checkTileCollision(px, py + 1, 8, 8, 1);
if (enemy.alive) {
if (boxesCollide([px, py, 8, 8], [enemy.x, enemy.y, 8, 8])) {
if (vy > 0 && py + 8 < enemy.y + 4) {
enemy.alive = false;
vy = -3;
score += 10;
} else {
px = 8;
py = 96;
vx = 0;
vy = 0;
}
}
}
if (py > 128) {
px = 8;
py = 96;
vx = 0;
vy = 0;
}
}
function draw() {
cls(12);
for (let r = 0; r < 16; r++) {
for (let c = 0; c < 16; c++) {
if (level[r][c]) spr(level[r][c], c * 8, r * 8);
}
}
if (enemy.alive) spr('enemy', enemy.x, enemy.y);
spr('player', px, py, 1, 1, facing === -1);
text('coins: ' + score, 4, 4, 7);
}
start({ sprites, sounds: {}, update, draw, target });
});
Para ir más allá
- Altura de salto variable — reduce
velocityYcuando el jugador suelta Espacio antes, de modo que una pulsación corta da un salto pequeño y mantener pulsado da un salto completo - Desplazamiento de cámara — usa
camera()para seguir al jugador a través de niveles más anchos que la pantalla - Sprites animados — alterna entre frames de reposo, caminata y salto según la velocidad y el estado de suelo
- Deslizamiento en paredes — detecta colisiones laterales en el aire y reduce la caída, opcionalmente permitiendo saltos de pared
- Doble salto — lleva un contador
airJumpsque se reinicia al aterrizar y se decrementa con cada salto en el aire - Plataformas móviles — dale velocidad a las plataformas y añade esa velocidad al jugador cuando esté de pie sobre una