How to Build a Platformer
This tutorial was written in February 2026, for v2 of the engine.
The engine has no built-in physics — no gravity, no collision response, no jump function. You build all of it yourself with a few variables and some simple math. We're going to start with a falling rectangle and progressively add solid platforms, horizontal movement, jumping, one-way platforms, coyote time, enemy stomping, and reactive blocks. By the end you'll have a small playable platformer and a reusable pattern for building your own. If you want to see these mechanics in a complete game, the platformer playground uses the same approach at a larger scale.
Gravity and Falling
Every platformer starts with gravity. The idea is simple: a velocityY variable tracks how fast the player is moving vertically. Each frame, gravity increases velocityY by a small constant, and velocityY gets added to the player's y position. The player accelerates downward until they hit something. A maxFall cap keeps the speed from growing forever. We're using rectfill instead of sprites here to keep the focus on the physics:
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 });
});
Solid Platforms with Sprite Flags
A hard-coded floor line doesn't scale. Instead, we define sprites with flag 0 set to true to mark them as solid, then check for collisions against a tile grid. The checkTileCollision function converts the player's four corners into tile coordinates and checks whether any of them overlap a solid tile using fget(tile, 0). The moveY function steps one pixel at a time so the player stops exactly at the surface instead of clipping through:
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 });
});
Using flag 0 for solidity is a convention, not a rule. You can use any of the 8 flags for any purpose — just be consistent across your sprites.
Horizontal Movement
Walking works the same way as falling: a velocityX variable, acceleration from input, and friction to slow down when no keys are pressed. We clamp the speed so the player can't accelerate forever. The moveX function mirrors moveY — try to move, revert if there's a collision. Separating X and Y movement into two functions means diagonal movement resolves correctly: the player slides along walls instead of getting stuck:
// 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);
Jumping
Jumping is just setting velocityY to a negative number. Gravity already handles the arc — the player rises, slows, and falls back down naturally. The key constraint: only allow jumps when grounded is true. We detect grounded by testing one pixel below the player for a solid tile — if there's ground directly underfoot, the player is grounded. This snippet combines everything so far into a complete, runnable example with a player sprite:
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 });
});
One-Way Platforms
One-way platforms let the player jump up through them from below but land on them from above. We use a second sprite flag — fget(tile, 1) — to mark one-way tiles. In the collision check, one-way tiles only count as solid when the player is falling (velocityY > 0) and their feet are near the top edge of the tile. A dropTimer lets the player press Down + Space to fall through — it temporarily ignores one-way collisions for a few 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;
Always resolve X and Y movement in separate steps. If you move diagonally in one step, the player can clip corners or phase through thin platforms. Move X first, check collisions, then move Y and check again.
Coyote Time
Coyote time is a small grace period that lets the player jump for a few frames after walking off a ledge. Without it, players who press jump one frame late fall to their death and blame the controls. The implementation is a counter: set it to a fixed value when grounded, decrement it each frame while airborne. We replace the grounded check in the jump condition with coyoteTimer > 0. When the player jumps, we zero the timer so they can't double-jump:
// 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;
}
3 to 6 frames at 60fps is a typical coyote time window. Too short and players won't notice it. Too long and they'll jump from mid-air in ways that look wrong.
Enemy Collision
An enemy can be as simple as a position and an alive flag sitting in the world. The interesting part is what happens when the player touches it. We use boxesCollide to detect overlap, then check the direction: if the player is falling and their bottom edge is above the enemy's center, it's a stomp — kill the enemy, bounce the player upward, and award points. If the player walks into the enemy from the side, they take damage (here, they reset to spawn). The same pattern works for any collision where the outcome depends on approach direction:
// 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;
}
}
}
Reactive Blocks
Question blocks that spit out coins when hit from below — a platformer staple. The check happens inside moveY: when the player is moving upward and hits a solid tile, we call checkBlockHit. That function finds the tile directly above the player's head, checks if it's a 'question' sprite, swaps it to 'question-empty', and increments the score. Here's the updated moveY with the block-hit call wired in:
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;
}
}
}
Putting It All Together
Here's everything in one runnable example: gravity, solid platforms, horizontal movement, jumping, one-way platforms, coyote time, an enemy, and a question block. The level is a 16x16 tile grid that fits the 128x128 screen without scrolling. Arrow keys to move, Space to jump, Down + Space to drop through one-way platforms. Stomp the enemy and hit the question block from below:
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 });
});
Going Further
- Variable jump height — cut
velocityYwhen the player releases Space early, so tapping gives a short hop and holding gives a full jump - Camera scrolling — use
camera()to follow the player through levels wider than the screen - Animated sprites — cycle between stand, walk, and jump frames based on velocity and grounded state
- Wall sliding — detect side collisions while airborne and slow the fall, optionally allowing wall jumps
- Double jump — track an
airJumpscounter that resets on landing and decrements on each mid-air jump - Moving platforms — give platforms a velocity and add that velocity to the player when they're standing on one