How to Build a Couch Co-op Racer
This tutorial was written in February 2026, for v2 of the engine.
The engine doesn't have built-in physics — no velocity, no friction, no collision response. We're building all of that from scratch with a handful of variables and some basic trig. We'll start with a tilemap race track, put a car on it, then layer on off-track penalties, a second player, car collisions, and a three-lap finish.
Drawing the Track
Every racing game needs a track. We'll use the tilemap system to build an oval loop on the 16×16 grid.
We need two sprite types: road with flag 0 set to true (driveable surface) and grass with flag 0 set to false (off-track). A third finish sprite marks the start/finish line — flag 0 for surface detection, flag 1 so we can spot it later for lap counting.
buildTrack fills the entire grid with grass, carves out a two-tile-wide rectangular loop, and places the finish line on the top straight:
engine.scope(({ start, cls, spr, mset, map, fget }) => {
const sprites = {
grass: [
[
3, 3, 11, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 11, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 11, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 11, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 11, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 11,
],
[false, false, false, false, false, false, false, false],
],
road: [
[
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
],
[true, false, false, false, false, false, false, false],
],
finish: [
[
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
],
[true, true, false, false, false, false, false, false],
],
};
function buildTrack() {
for (let r = 0; r < 16; r++) {
for (let c = 0; c < 16; c++) {
mset(c, r, 'grass');
}
}
for (let c = 2; c <= 13; c++) { mset(c, 2, 'road'); mset(c, 3, 'road'); }
for (let c = 2; c <= 13; c++) { mset(c, 12, 'road'); mset(c, 13, 'road'); }
for (let r = 2; r <= 13; r++) { mset(2, r, 'road'); mset(3, r, 'road'); }
for (let r = 2; r <= 13; r++) { mset(12, r, 'road'); mset(13, r, 'road'); }
mset(7, 2, 'finish');
mset(7, 3, 'finish');
}
function init() {
buildTrack();
}
function draw() {
cls(0);
map();
}
start({ sprites, sounds: {}, init, draw, target });
});
Flag 0 for "driveable surface" is a convention, not a rule. You can use any of the 8 flags for whatever you want — just stay consistent across your sprites.
A Single Racer
We need something to drive. The car is a filled circle with a line pointing in its facing direction — no sprites needed, just circfill and line.
Steering is tank-style: Left and Right rotate the facing angle, Up accelerates forward, Down reverses. Math.cos(angle) and Math.sin(angle) convert the angle into X and Y components, which gives us the direction of travel. Friction multiplies the speed by a value just under 1 each frame, so the car slows down when you let go. A max speed cap keeps things from getting out of hand:
engine.scope(({ start, cls, spr, mset, map, circfill, line, btn, text }) => {
const sprites = {
grass: [
[
3, 3, 11, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 11, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 11, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 11, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 11, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 11,
],
[false, false, false, false, false, false, false, false],
],
road: [
[
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
],
[true, false, false, false, false, false, false, false],
],
finish: [
[
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
],
[true, true, false, false, false, false, false, false],
],
};
const accel = 0.08;
const friction = 0.97;
const turnSpeed = 0.05;
const maxSpeed = 1.5;
let x = 60, y = 24;
let angle = 0;
let speed = 0;
function buildTrack() {
for (let r = 0; r < 16; r++) {
for (let c = 0; c < 16; c++) {
mset(c, r, 'grass');
}
}
for (let c = 2; c <= 13; c++) { mset(c, 2, 'road'); mset(c, 3, 'road'); }
for (let c = 2; c <= 13; c++) { mset(c, 12, 'road'); mset(c, 13, 'road'); }
for (let r = 2; r <= 13; r++) { mset(2, r, 'road'); mset(3, r, 'road'); }
for (let r = 2; r <= 13; r++) { mset(12, r, 'road'); mset(13, r, 'road'); }
mset(7, 2, 'finish');
mset(7, 3, 'finish');
}
function init() {
buildTrack();
}
function update() {
if (btn('ArrowLeft')) angle -= turnSpeed;
if (btn('ArrowRight')) angle += turnSpeed;
if (btn('ArrowUp')) speed += accel;
if (btn('ArrowDown')) speed -= accel;
speed *= friction;
if (speed > maxSpeed) speed = maxSpeed;
if (speed < -maxSpeed / 2) speed = -maxSpeed / 2;
if (Math.abs(speed) < 0.01) speed = 0;
x += Math.cos(angle) * speed;
y += Math.sin(angle) * speed;
if (x < 4) x = 4;
if (x > 124) x = 124;
if (y < 4) y = 4;
if (y > 124) y = 124;
}
function draw() {
cls(0);
map();
circfill(x, y, 3, 8);
line(x, y, x + Math.cos(angle) * 5, y + Math.sin(angle) * 5, 10);
}
start({ sprites, sounds: {}, init, update, draw, target });
});
A turn rate of 0.05 radians per frame gives smooth steering at 60fps. Too high and the car feels twitchy. Too low and corners become impossible. Tune this alongside your max speed — faster cars usually need a tighter turn rate.
Staying on Track
Right now there's no penalty for cutting across the grass. Let's fix that.
Each frame, we convert the car's pixel position to tile coordinates with Math.floor(x / 8), grab the tile with mget, and check flag 0 with fget. On road: normal acceleration, normal friction, normal max speed. On grass: halved acceleration, stronger friction, and a much lower speed cap. The car doesn't stop dead — it bleeds speed until it matches the grass limit:
engine.scope(({ start, cls, spr, mset, mget, map, fget, circfill, line, btn, text }) => {
const sprites = {
grass: [
[
3, 3, 11, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 11, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 11, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 11, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 11, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 11,
],
[false, false, false, false, false, false, false, false],
],
road: [
[
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
],
[true, false, false, false, false, false, false, false],
],
finish: [
[
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
],
[true, true, false, false, false, false, false, false],
],
};
const turnSpeed = 0.05;
let x = 60, y = 24;
let angle = 0;
let speed = 0;
let onRoad = true;
function buildTrack() {
for (let r = 0; r < 16; r++) {
for (let c = 0; c < 16; c++) {
mset(c, r, 'grass');
}
}
for (let c = 2; c <= 13; c++) { mset(c, 2, 'road'); mset(c, 3, 'road'); }
for (let c = 2; c <= 13; c++) { mset(c, 12, 'road'); mset(c, 13, 'road'); }
for (let r = 2; r <= 13; r++) { mset(2, r, 'road'); mset(3, r, 'road'); }
for (let r = 2; r <= 13; r++) { mset(12, r, 'road'); mset(13, r, 'road'); }
mset(7, 2, 'finish');
mset(7, 3, 'finish');
}
function isOnRoad(px, py) {
const tile = mget(Math.floor(px / 8), Math.floor(py / 8));
return tile && fget(tile, 0);
}
function init() {
buildTrack();
}
function update() {
onRoad = isOnRoad(x, y);
const accel = onRoad ? 0.08 : 0.04;
const friction = onRoad ? 0.97 : 0.92;
const max = onRoad ? 1.5 : 0.6;
if (btn('ArrowLeft')) angle -= turnSpeed;
if (btn('ArrowRight')) angle += turnSpeed;
if (btn('ArrowUp')) speed += accel;
if (btn('ArrowDown')) speed -= accel;
speed *= friction;
if (speed > max) speed = max;
if (speed < -max / 2) speed = -max / 2;
if (Math.abs(speed) < 0.01) speed = 0;
x += Math.cos(angle) * speed;
y += Math.sin(angle) * speed;
if (x < 4) x = 4;
if (x > 124) x = 124;
if (y < 4) y = 4;
if (y > 124) y = 124;
}
function draw() {
cls(0);
map();
circfill(x, y, 3, 8);
line(x, y, x + Math.cos(angle) * 5, y + Math.sin(angle) * 5, 10);
text(onRoad ? 'road' : 'grass', 2, 2, 7);
}
start({ sprites, sounds: {}, init, update, draw, target });
});
Adding a Second Racer
Couch co-op means two players on one keyboard. Let's extract the car state into an object and write a generic updateCar function that takes key bindings as parameters.
Player 1 keeps the arrow keys. Player 2 uses a/d to steer, w to accelerate, and s to reverse. Each car gets its own position, angle, speed, and color. A makeCar factory keeps things tidy:
engine.scope(({ start, cls, spr, mset, mget, map, fget, circfill, line, btn, text }) => {
const sprites = {
grass: [
[
3, 3, 11, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 11, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 11, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 11, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 11, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 11,
],
[false, false, false, false, false, false, false, false],
],
road: [
[
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
],
[true, false, false, false, false, false, false, false],
],
finish: [
[
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
],
[true, true, false, false, false, false, false, false],
],
};
const turnSpeed = 0.05;
function makeCar(sx, sy, sa, color) {
return { x: sx, y: sy, angle: sa, speed: 0, color };
}
const p1 = makeCar(60, 20, 0, 8);
const p2 = makeCar(60, 28, 0, 12);
function buildTrack() {
for (let r = 0; r < 16; r++) {
for (let c = 0; c < 16; c++) {
mset(c, r, 'grass');
}
}
for (let c = 2; c <= 13; c++) { mset(c, 2, 'road'); mset(c, 3, 'road'); }
for (let c = 2; c <= 13; c++) { mset(c, 12, 'road'); mset(c, 13, 'road'); }
for (let r = 2; r <= 13; r++) { mset(2, r, 'road'); mset(3, r, 'road'); }
for (let r = 2; r <= 13; r++) { mset(12, r, 'road'); mset(13, r, 'road'); }
mset(7, 2, 'finish');
mset(7, 3, 'finish');
}
function isOnRoad(px, py) {
const tile = mget(Math.floor(px / 8), Math.floor(py / 8));
return tile && fget(tile, 0);
}
function updateCar(car, left, right, gas, reverse) {
const onRoad = isOnRoad(car.x, car.y);
const accel = onRoad ? 0.08 : 0.04;
const friction = onRoad ? 0.97 : 0.92;
const max = onRoad ? 1.5 : 0.6;
if (btn(left)) car.angle -= turnSpeed;
if (btn(right)) car.angle += turnSpeed;
if (btn(gas)) car.speed += accel;
if (btn(reverse)) car.speed -= accel;
car.speed *= friction;
if (car.speed > max) car.speed = max;
if (car.speed < -max / 2) car.speed = -max / 2;
if (Math.abs(car.speed) < 0.01) car.speed = 0;
car.x += Math.cos(car.angle) * car.speed;
car.y += Math.sin(car.angle) * car.speed;
if (car.x < 4) car.x = 4;
if (car.x > 124) car.x = 124;
if (car.y < 4) car.y = 4;
if (car.y > 124) car.y = 124;
}
function drawCar(car) {
circfill(car.x, car.y, 3, car.color);
line(car.x, car.y, car.x + Math.cos(car.angle) * 5, car.y + Math.sin(car.angle) * 5, 10);
}
function init() {
buildTrack();
}
function update() {
updateCar(p1, 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown');
updateCar(p2, 'a', 'd', 'w', 's');
}
function draw() {
cls(0);
map();
drawCar(p1);
drawCar(p2);
text('p1: arrows', 2, 2, 8);
text('p2: wasd', 2, 10, 12);
}
start({ sprites, sounds: {}, init, update, draw, target });
});
Car Collisions
Two cars that pass through each other don't feel like a race. We need collision response.
Each frame, we check the distance between car centers. If it's less than twice the radius, the cars overlap. We figure out the direction from one car to the other, push each car half the overlap distance along that line, and dampen both speeds on impact — bump into someone and you both lose momentum:
const carRadius = 3;
function resolveCollision(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = carRadius * 2;
if (dist < minDist && dist > 0) {
const nx = dx / dist;
const ny = dy / dist;
const overlap = (minDist - dist) / 2;
a.x -= nx * overlap;
a.y -= ny * overlap;
b.x += nx * overlap;
b.y += ny * overlap;
a.speed *= 0.7;
b.speed *= 0.7;
}
}
// inside update(), after both cars move:
resolveCollision(p1, p2);
The 0.7 dampening factor controls how punishing collisions feel. Lower values make bumps devastating — higher values make them gentle nudges. Tune it to match the feel you're after.
Three Laps
A race needs a finish condition. Detecting when a car crosses the finish line isn't enough on its own — a player could drive back and forth over it to farm laps.
The fix is checkpoints. We divide the track into three zones based on screen position: top-right, bottom-right, and bottom-left. Each car tracks which zones it's visited in a Set. When a car crosses the finish line tile (flag 1) and has hit all three checkpoints, that's one lap. The set resets, the counter goes up, and the first car to three laps wins:
engine.scope(({ start, cls, spr, mset, mget, map, fget, circfill, line, btn, text }) => {
const sprites = {
grass: [
[
3, 3, 11, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 11, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 11, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 11, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 11, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 11,
],
[false, false, false, false, false, false, false, false],
],
road: [
[
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
],
[true, false, false, false, false, false, false, false],
],
finish: [
[
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
7, 0, 7, 0, 7, 0, 7, 0,
0, 7, 0, 7, 0, 7, 0, 7,
],
[true, true, false, false, false, false, false, false],
],
};
const turnSpeed = 0.05;
const carRadius = 3;
const totalLaps = 3;
let winner = null;
function makeCar(sx, sy, sa, color) {
return { x: sx, y: sy, angle: sa, speed: 0, color, lap: 0, checkpoints: new Set(), onFinish: false };
}
const p1 = makeCar(60, 20, 0, 8);
const p2 = makeCar(60, 28, 0, 12);
function buildTrack() {
for (let r = 0; r < 16; r++) {
for (let c = 0; c < 16; c++) {
mset(c, r, 'grass');
}
}
for (let c = 2; c <= 13; c++) { mset(c, 2, 'road'); mset(c, 3, 'road'); }
for (let c = 2; c <= 13; c++) { mset(c, 12, 'road'); mset(c, 13, 'road'); }
for (let r = 2; r <= 13; r++) { mset(2, r, 'road'); mset(3, r, 'road'); }
for (let r = 2; r <= 13; r++) { mset(12, r, 'road'); mset(13, r, 'road'); }
mset(7, 2, 'finish');
mset(7, 3, 'finish');
}
function isOnRoad(px, py) {
const tile = mget(Math.floor(px / 8), Math.floor(py / 8));
return tile && fget(tile, 0);
}
function getCheckpoint(px, py) {
if (px > 64 && py < 64) return 0;
if (px > 64 && py >= 64) return 1;
if (px <= 64 && py >= 64) return 2;
return -1;
}
function updateCar(car, left, right, gas, reverse) {
if (winner) return;
const onRoad = isOnRoad(car.x, car.y);
const accel = onRoad ? 0.08 : 0.04;
const friction = onRoad ? 0.97 : 0.92;
const max = onRoad ? 1.5 : 0.6;
if (btn(left)) car.angle -= turnSpeed;
if (btn(right)) car.angle += turnSpeed;
if (btn(gas)) car.speed += accel;
if (btn(reverse)) car.speed -= accel;
car.speed *= friction;
if (car.speed > max) car.speed = max;
if (car.speed < -max / 2) car.speed = -max / 2;
if (Math.abs(car.speed) < 0.01) car.speed = 0;
car.x += Math.cos(car.angle) * car.speed;
car.y += Math.sin(car.angle) * car.speed;
if (car.x < 4) car.x = 4;
if (car.x > 124) car.x = 124;
if (car.y < 4) car.y = 4;
if (car.y > 124) car.y = 124;
const cp = getCheckpoint(car.x, car.y);
if (cp >= 0) car.checkpoints.add(cp);
const tile = mget(Math.floor(car.x / 8), Math.floor(car.y / 8));
const onFinishTile = tile && fget(tile, 1);
if (onFinishTile && !car.onFinish && car.checkpoints.size >= 3) {
car.lap++;
car.checkpoints.clear();
if (car.lap >= totalLaps) winner = car;
}
car.onFinish = onFinishTile;
}
function resolveCollision(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = carRadius * 2;
if (dist < minDist && dist > 0) {
const nx = dx / dist;
const ny = dy / dist;
const overlap = (minDist - dist) / 2;
a.x -= nx * overlap;
a.y -= ny * overlap;
b.x += nx * overlap;
b.y += ny * overlap;
a.speed *= 0.7;
b.speed *= 0.7;
}
}
function drawCar(car) {
circfill(car.x, car.y, carRadius, car.color);
line(car.x, car.y, car.x + Math.cos(car.angle) * 5, car.y + Math.sin(car.angle) * 5, 10);
}
function init() {
buildTrack();
}
function update() {
updateCar(p1, 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown');
updateCar(p2, 'a', 'd', 'w', 's');
resolveCollision(p1, p2);
}
function draw() {
cls(0);
map();
drawCar(p1);
drawCar(p2);
text('p1 lap ' + Math.min(p1.lap + 1, totalLaps) + '/' + totalLaps, 2, 2, 8);
text('p2 lap ' + Math.min(p2.lap + 1, totalLaps) + '/' + totalLaps, 2, 10, 12);
if (winner) {
const label = winner === p1 ? 'p1 wins!' : 'p2 wins!';
text(label, 48, 60, winner.color);
}
}
start({ sprites, sounds: {}, init, update, draw, target });
});
Going Further
- Boost pads — add a tile with a special flag that multiplies the car's speed when driven over
- AI opponents — store a list of waypoints around the track and steer an AI car toward the next one each frame
- Multiple tracks — swap
buildTrackfor a different tilemap layout. Store layouts as 2D arrays and pick one at random - Sound effects — use
sfx()for engine hum, tire squeal on grass, and a chime when a lap completes - Split-screen — use
camera()to give each player their own viewport on a larger track, drawing each half of the screen separately - Drift mechanic — when turning at high speed, reduce grip and let the car slide sideways for a frame or two before the angle catches up