
Racing
A game in which you compete to finish first or in the shortest time on a circuit or a route, almost always at the wheel of a vehicle. The challenge is mastering speed and trajectory without going off the track or losing time in the corners.
Also known as: racing game, racer, driving game
Defining mechanics
- Control with momentum, because the vehicle accelerates, brakes and turns progressively and does not respond instantly.
- The racing line, which means choosing where to enter and leave each corner and when to brake, to carry as much speed as possible.
- Surfaces with different grip (tarmac, dirt, grass), which punish leaving the track and allow drifting.
- Time trials and rivals, because you compete against the clock, against your best lap or against other drivers, human or AI-controlled.
- Variants that add layers, such as the items used to attack in kart racers or the car tuning of simulators.
Gameplay loop
Read the coming corner, brake and turn in time, accelerate out of it, check the lap time and go again to lower it.
Our demo
Controls. Up arrow or W to accelerate, down arrow or S to brake (and reverse), left/right or A/D to steer. With the mouse or a finger, hold down and the car accelerates towards the pointer. On mobile there are also on-screen buttons.
Open full screenA three-lap top-down race against two rivals, with a timer and a best lap. The car has momentum, turns according to its speed, slides if you go into a corner too fast and slows down on the grass. Just enough to feel what defines the genre, which is finding the fastest racing line.
Made with Kaplay, in about 185 lines of JavaScript.
How it's made
Setup and constants
The game is created at a fixed size with virtual buttons (accelerate, brake, left, right), so the same code works with a keyboard or a touch screen. The constants are the car's «handling», because top speed, acceleration, braking, steering, drag and grip decide whether it feels heavy, twitchy or glued to the road. Drag and grip have one value for the track and another for the grass.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [58, 122, 64], // Virtual buttons: the car asks about "accelerate", not about a specific key. buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, accelerate: { keyboard: ["up", "w", "x"] }, brake: { keyboard: ["down", "s", "z"] }, }, }); touchControls(k, [ { button: "left", label: "◀" }, { button: "right", label: "▶" }, { button: "brake", label: "▼", side: "right" }, { button: "accelerate", label: "▲", side: "right" }, ]); const LAPS = 3; const MAX_SPEED = 260; // px/s const ACCELERATION = 170; const BRAKING = 340; const STEERING = 3.2; // radians per second at medium speed const DRAG = { track: 0.35, grass: 2.2 }; // slows forward motion const GRIP = { track: 5, grass: 2 }; // kills sideways sliding const COUNTDOWN = 2; // seconds of start lights // 75.3 s → "1:15.30" const clock = (s) => (isFinite(s) ? `${Math.floor(s / 60)}:${(s % 60).toFixed(2).padStart(5, "0")}` : "—");The circuit as a list of points
The circuit is the list of points on its centre line, closed by joining the last one to the first. The same list draws the tarmac (a very thick line), tells whether a car has left the track (if it is more than half the width away from the centre line) and gives the waypoints the rivals follow. Changing one point changes everything at once.
// The circuit is a list of points (its centre line) that closes on itself. // It is used three times: to draw the track, to know if you are off it, // and as waypoints for the rivals. const CIRCUIT = [ [220, 305], [380, 308], [500, 292], [575, 235], [585, 140], [545, 70], [460, 52], [395, 85], [350, 165], [285, 190], [225, 150], [200, 80], [130, 52], [65, 100], [55, 215], [110, 292], ].map(([x, y]) => vec2(x, y)); const N = CIRCUIT.length; const TRACK_WIDTH = 50; // Closest point on the centre line: its distance and section number. function closest(p) { let best = { dist: Infinity, section: 0 }; for (let i = 0; i < N; i++) { const a = CIRCUIT[i], b = CIRCUIT[(i + 1) % N]; const ab = b.sub(a); const u = clamp(p.sub(a).dot(ab) / ab.dot(ab), 0, 1); const dist = p.dist(a.add(ab.scale(u))); if (dist < best.dist) best = { dist, section: i }; } return best; } function drawCircuit() { const pts = [...CIRCUIT, CIRCUIT[0], CIRCUIT[1]]; drawLines({ pts, width: TRACK_WIDTH + 8, join: "round", color: rgb(235, 235, 235) }); // kerb drawLines({ pts, width: TRACK_WIDTH, join: "round", color: rgb(70, 72, 80) }); // tarmac for (const p of CIRCUIT) drawCircle({ pos: p, radius: 2, color: rgb(150, 150, 160) }); // waypoints // Finish line, perpendicular to the first section. const dir = CIRCUIT[1].sub(CIRCUIT[0]).unit().normal().scale(TRACK_WIDTH / 2); drawLine({ p1: CIRCUIT[0].add(dir), p2: CIRCUIT[0].sub(dir), width: 6, color: rgb(255, 255, 255) }); }The car
Each car stores two different things, its angle (where the nose points) and its velocity (where it is really moving). When they do not match, the car can slide. It also remembers which section of the circuit it is on and how many laps it has done.
// A car is a rectangle with an angle (where it points) and a velocity // (where it moves). They do not always match: that is drifting. function addCar(x, y, c, extra = {}) { return add([ rect(20, 11), pos(x, y), anchor("center"), rotate(0), color(c), outline(2, rgb(20, 20, 20)), { heading: 0, vel: vec2(0), section: 0, laps: 0, ...extra }, ]); }Simplified physics
This is the heart of the genre, in three steps. 1) Steer: the faster you go, the more the car turns, up to a limit, because a stopped car cannot turn and at top speed it turns a little less. 2) Split the velocity into a «forward» part and a «sideways» part relative to the nose (with the dot product). 3) The engine and brake change only the forward part; drag reduces it little by little and grip removes the sideways part. With high grip the car runs on rails; with low grip it keeps sliding where it was going and drifts. On the grass drag rises and grip falls, which is why leaving the track costs time. Since the rule is the same for everyone, the racing line (how you enter and leave each corner) matters more than top speed.
// Simplified physics in three steps, the same for you and the rivals: // 1) steer: turning depends on speed (stopped you cannot turn; flat out you turn less); // 2) split the velocity into "forward" and "sideways" relative to the nose; // 3) engine and brake change the forward part; drag slows everything, // and grip removes the sideways part. Low grip = drift. Off the track // there is high drag and low grip. function physics(c, throttle, steer, skids) { const offTrack = closest(c.pos).dist > TRACK_WIDTH / 2; const surface = offTrack ? "grass" : "track"; let nose = vec2(Math.cos(c.heading), Math.sin(c.heading)); const speedBefore = c.vel.dot(nose); const turnFactor = Math.min(Math.abs(speedBefore) / 80, 1) * (1 - 0.4 * Math.abs(speedBefore) / MAX_SPEED); c.heading += steer * STEERING * turnFactor * Math.sign(speedBefore) * dt(); nose = vec2(Math.cos(c.heading), Math.sin(c.heading)); const side = nose.normal(); let forward = c.vel.dot(nose); let sideways = c.vel.dot(side); if (throttle > 0) forward += ACCELERATION * dt(); else if (throttle < 0) forward -= (forward > 0 ? BRAKING : ACCELERATION * 0.5) * dt(); forward *= Math.exp(-DRAG[surface] * dt()); sideways *= Math.exp(-GRIP[surface] * dt()); forward = clamp(forward, -MAX_SPEED * 0.3, MAX_SPEED); c.vel = nose.scale(forward).add(side.scale(sideways)); c.pos = c.pos.add(c.vel.scale(dt())); c.pos.x = clamp(c.pos.x, 10, width() - 10); c.pos.y = clamp(c.pos.y, 10, height() - 10); c.angle = rad2deg(c.heading); // Sliding a lot sideways leaves tyre marks. if (Math.abs(sideways) > 45 && skids) skids.push(c.pos.clone()); }Counting laps
Each car only advances its section counter when it reaches the next section of the circuit (or the one after). That way cutting across the grass or crossing the line backwards does not count. Going from the last section to the first is crossing the line and adds a lap. The same progress value (laps times sections plus current section) gives your position.
// To count laps, each car remembers its section of the circuit. It only moves // on if it reaches the next section (or the one after), so shortcuts and // reversing do not count. Going from the last section to the first is crossing the line. function advance(c) { const { section } = closest(c.pos); if (section !== (c.section + 1) % N && section !== (c.section + 2) % N) return false; const crossedLine = section < c.section; c.section = section; if (crossedLine) c.laps++; return crossedLine; } const progress = (c) => c.laps * N + c.section; // to rank positionsRivals with waypoints
The rivals use the same physics as you; the AI only decides which buttons to press. They aim at the next waypoint, move on to the following one when they get close or have already passed it, and choose a target speed, which is their pace on the straights and less the more they have to turn. Changing each one's pace is the simplest way to tune the difficulty.
// Rivals "press the same buttons" you do: they aim at the next waypoint and // pick a speed: their pace on the straights, less the more they have to turn. function drive(r) { // Move on to the next waypoint when close, or if it is already behind. const toGo = CIRCUIT[r.wp].sub(r.pos); if (toGo.len() < 40 || (toGo.len() < 90 && toGo.dot(r.vel) < 0)) r.wp = (r.wp + 1) % N; const target = CIRCUIT[r.wp]; const wanted = Math.atan2(target.y - r.pos.y, target.x - r.pos.x); const diff = Math.atan2(Math.sin(wanted - r.heading), Math.cos(wanted - r.heading)); const speed = r.vel.len(); const targetSpeed = r.pace * (1 - 0.6 * Math.min(Math.abs(diff), 1)); const throttle = speed < targetSpeed ? 1 : speed > targetSpeed + 30 ? -1 : 0; physics(r, throttle, clamp(diff * 3, -1, 1)); }The race and the timer
The game scene draws the circuit and the skid marks, places the cars on the grid and waits for a two-second start light. Then, every frame, it reads your controls (keyboard, buttons or holding the mouse towards the pointer), moves everyone and checks whether you crossed the line. Each crossing closes a lap, keeps the best one and, on the third, ends the race. Racing against your own best lap is what makes you want to play again.
const skids = []; onDraw(() => { drawCircuit(); for (const m of skids) drawCircle({ pos: m, radius: 2, color: rgb(30, 30, 34), opacity: 0.6 }); }); // Starting grid just past the finish line, in two lanes. const p0 = CIRCUIT[0]; const player = addCar(p0.x + 20, p0.y + 10, rgb(240, 70, 60)); const rivals = [ addCar(p0.x + 50, p0.y - 10, rgb(70, 140, 240), { wp: 1, pace: 185 }), addCar(p0.x + 80, p0.y + 10, rgb(250, 200, 60), { wp: 1, pace: 210 }), ]; let time = -COUNTDOWN; // negative while the start lights are on let lapStart = 0; let bestLap = Infinity; const hud = add([text("", { size: 14, font: FONT }), pos(10, 6), z(10)]); const notice = add([text("", { size: 48, font: FONT }), pos(center()), anchor("center"), z(10)]); onUpdate(() => { time += dt(); notice.text = time < 0 ? String(Math.ceil(-time * 1.5)) : time < 0.7 ? t({ es: "¡Ya!", en: "Go!" }) : ""; if (time < 0) return; // Your controls: keyboard or buttons, or hold the mouse to head for the pointer. let throttle = (isButtonDown("accelerate") ? 1 : 0) - (isButtonDown("brake") ? 1 : 0); let steer = (isButtonDown("right") ? 1 : 0) - (isButtonDown("left") ? 1 : 0); if (isMouseDown()) { const m = mousePos(); const wanted = Math.atan2(m.y - player.pos.y, m.x - player.pos.x); steer = clamp(3 * Math.atan2(Math.sin(wanted - player.heading), Math.cos(wanted - player.heading)), -1, 1); throttle = 1; } physics(player, throttle, steer, skids); if (skids.length > 400) skids.splice(0, skids.length - 400); rivals.forEach(drive); rivals.forEach(advance); // Time trial: every time you cross the line, a lap is closed. if (advance(player)) { bestLap = Math.min(bestLap, time - lapStart); lapStart = time; if (player.laps >= LAPS) { const place = 1 + rivals.filter((r) => r.laps >= LAPS).length; go("end", { total: time, best: bestLap, place }); } } const place = 1 + rivals.filter((r) => progress(r) > progress(player)).length; hud.text = t({ es: "Vuelta ", en: "Lap " }) + `${Math.min(player.laps + 1, LAPS)}/${LAPS} ` + t({ es: "Tiempo ", en: "Time " }) + clock(time) + " " + t({ es: "Vuelta actual ", en: "Current lap " }) + clock(time - lapStart) + " " + t({ es: "Mejor ", en: "Best " }) + clock(bestLap) + " " + t({ es: "Puesto ", en: "Position " }) + `${place}/${rivals.length + 1}`; });Scenes
The game has three scenes, title, game and end, and go() jumps between them, passing along the results (total time, best lap and position). This restarts the race without cleaning anything up by hand.
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: 560 }), pos(center().add(0, 30)), anchor("center"), color(225, 240, 225), ]); onKeyPress(["space", "enter"], next); onClick(next); } scene("title", () => screen( t({ es: "Carreras", en: "Racing" }), t({ es: `Flecha arriba acelera, abajo frena, izquierda y derecha giran.\nO mantén el clic: el coche va hacia el puntero.\n${LAPS} vueltas contra dos rivales. Fuera del asfalto el coche resbala y frena.\nPulsa espacio o haz clic para empezar.`, en: `Up arrow accelerates, down brakes, left and right steer.\nOr hold the mouse button: the car heads for the pointer.\n${LAPS} laps against two rivals. Off the tarmac the car slides and slows.\nPress space or click to start.`, }), () => go("game"), ), ); scene("end", ({ total, best, place }) => { screen( place === 1 ? t({ es: "¡Ganaste!", en: "You won!" }) : t({ es: `Llegaste en el puesto ${place}`, en: `You finished in position ${place}` }), t({ es: `Tiempo total ${clock(total)} · mejor vuelta ${clock(best)}.\n¿Puedes bajar tu mejor vuelta? Pulsa espacio o haz clic para repetir.`, en: `Total time ${clock(total)} · best lap ${clock(best)}.\nCan you beat your best lap? Press space or click to race again.`, }), () => go("game"), ); }); go("title");
What we simplified
- The cars are rectangles and the track is a thick line; there is no sound, no camera following the car and no gears.
- The cars do not collide with each other, and the screen edges are invisible walls.
- The physics does not model tyres, weight or engine torque, only three numbers you can feel (drag, grip and steering). It is «arcade» physics, not simulator physics.
To modify it
- Change GRIP.track from 5 to 2 and then to 12. Describe how the feel changes and which kind of racing game each value resembles most.
- Design your own circuit by changing the CIRCUIT list. Put a tight corner at the end of a long straight and watch where the rivals brake.
- Add a «ghost» that replays your best lap. Store the car's position every frame and draw it on the next lap.
- Make the cars collide with each other, pushing them apart and sharing the speed when they touch.
Full code
// Racing 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: [58, 122, 64],
// Virtual buttons: the car asks about "accelerate", not about a specific key.
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
accelerate: { keyboard: ["up", "w", "x"] },
brake: { keyboard: ["down", "s", "z"] },
},
});
touchControls(k, [
{ button: "left", label: "◀" },
{ button: "right", label: "▶" },
{ button: "brake", label: "▼", side: "right" },
{ button: "accelerate", label: "▲", side: "right" },
]);
const LAPS = 3;
const MAX_SPEED = 260; // px/s
const ACCELERATION = 170;
const BRAKING = 340;
const STEERING = 3.2; // radians per second at medium speed
const DRAG = { track: 0.35, grass: 2.2 }; // slows forward motion
const GRIP = { track: 5, grass: 2 }; // kills sideways sliding
const COUNTDOWN = 2; // seconds of start lights
// 75.3 s → "1:15.30"
const clock = (s) => (isFinite(s) ? `${Math.floor(s / 60)}:${(s % 60).toFixed(2).padStart(5, "0")}` : "—");
// #endregion
// #region circuit
// The circuit is a list of points (its centre line) that closes on itself.
// It is used three times: to draw the track, to know if you are off it,
// and as waypoints for the rivals.
const CIRCUIT = [
[220, 305], [380, 308], [500, 292], [575, 235], [585, 140], [545, 70], [460, 52],
[395, 85], [350, 165], [285, 190], [225, 150], [200, 80], [130, 52], [65, 100], [55, 215], [110, 292],
].map(([x, y]) => vec2(x, y));
const N = CIRCUIT.length;
const TRACK_WIDTH = 50;
// Closest point on the centre line: its distance and section number.
function closest(p) {
let best = { dist: Infinity, section: 0 };
for (let i = 0; i < N; i++) {
const a = CIRCUIT[i], b = CIRCUIT[(i + 1) % N];
const ab = b.sub(a);
const u = clamp(p.sub(a).dot(ab) / ab.dot(ab), 0, 1);
const dist = p.dist(a.add(ab.scale(u)));
if (dist < best.dist) best = { dist, section: i };
}
return best;
}
function drawCircuit() {
const pts = [...CIRCUIT, CIRCUIT[0], CIRCUIT[1]];
drawLines({ pts, width: TRACK_WIDTH + 8, join: "round", color: rgb(235, 235, 235) }); // kerb
drawLines({ pts, width: TRACK_WIDTH, join: "round", color: rgb(70, 72, 80) }); // tarmac
for (const p of CIRCUIT) drawCircle({ pos: p, radius: 2, color: rgb(150, 150, 160) }); // waypoints
// Finish line, perpendicular to the first section.
const dir = CIRCUIT[1].sub(CIRCUIT[0]).unit().normal().scale(TRACK_WIDTH / 2);
drawLine({ p1: CIRCUIT[0].add(dir), p2: CIRCUIT[0].sub(dir), width: 6, color: rgb(255, 255, 255) });
}
// #endregion
// #region car
// A car is a rectangle with an angle (where it points) and a velocity
// (where it moves). They do not always match: that is drifting.
function addCar(x, y, c, extra = {}) {
return add([
rect(20, 11), pos(x, y), anchor("center"), rotate(0), color(c), outline(2, rgb(20, 20, 20)),
{ heading: 0, vel: vec2(0), section: 0, laps: 0, ...extra },
]);
}
// #endregion
// #region physics
// Simplified physics in three steps, the same for you and the rivals:
// 1) steer: turning depends on speed (stopped you cannot turn; flat out you turn less);
// 2) split the velocity into "forward" and "sideways" relative to the nose;
// 3) engine and brake change the forward part; drag slows everything,
// and grip removes the sideways part. Low grip = drift. Off the track
// there is high drag and low grip.
function physics(c, throttle, steer, skids) {
const offTrack = closest(c.pos).dist > TRACK_WIDTH / 2;
const surface = offTrack ? "grass" : "track";
let nose = vec2(Math.cos(c.heading), Math.sin(c.heading));
const speedBefore = c.vel.dot(nose);
const turnFactor = Math.min(Math.abs(speedBefore) / 80, 1) * (1 - 0.4 * Math.abs(speedBefore) / MAX_SPEED);
c.heading += steer * STEERING * turnFactor * Math.sign(speedBefore) * dt();
nose = vec2(Math.cos(c.heading), Math.sin(c.heading));
const side = nose.normal();
let forward = c.vel.dot(nose);
let sideways = c.vel.dot(side);
if (throttle > 0) forward += ACCELERATION * dt();
else if (throttle < 0) forward -= (forward > 0 ? BRAKING : ACCELERATION * 0.5) * dt();
forward *= Math.exp(-DRAG[surface] * dt());
sideways *= Math.exp(-GRIP[surface] * dt());
forward = clamp(forward, -MAX_SPEED * 0.3, MAX_SPEED);
c.vel = nose.scale(forward).add(side.scale(sideways));
c.pos = c.pos.add(c.vel.scale(dt()));
c.pos.x = clamp(c.pos.x, 10, width() - 10);
c.pos.y = clamp(c.pos.y, 10, height() - 10);
c.angle = rad2deg(c.heading);
// Sliding a lot sideways leaves tyre marks.
if (Math.abs(sideways) > 45 && skids) skids.push(c.pos.clone());
}
// #endregion
// #region laps
// To count laps, each car remembers its section of the circuit. It only moves
// on if it reaches the next section (or the one after), so shortcuts and
// reversing do not count. Going from the last section to the first is crossing the line.
function advance(c) {
const { section } = closest(c.pos);
if (section !== (c.section + 1) % N && section !== (c.section + 2) % N) return false;
const crossedLine = section < c.section;
c.section = section;
if (crossedLine) c.laps++;
return crossedLine;
}
const progress = (c) => c.laps * N + c.section; // to rank positions
// #endregion
// #region rivals
// Rivals "press the same buttons" you do: they aim at the next waypoint and
// pick a speed: their pace on the straights, less the more they have to turn.
function drive(r) {
// Move on to the next waypoint when close, or if it is already behind.
const toGo = CIRCUIT[r.wp].sub(r.pos);
if (toGo.len() < 40 || (toGo.len() < 90 && toGo.dot(r.vel) < 0)) r.wp = (r.wp + 1) % N;
const target = CIRCUIT[r.wp];
const wanted = Math.atan2(target.y - r.pos.y, target.x - r.pos.x);
const diff = Math.atan2(Math.sin(wanted - r.heading), Math.cos(wanted - r.heading));
const speed = r.vel.len();
const targetSpeed = r.pace * (1 - 0.6 * Math.min(Math.abs(diff), 1));
const throttle = speed < targetSpeed ? 1 : speed > targetSpeed + 30 ? -1 : 0;
physics(r, throttle, clamp(diff * 3, -1, 1));
}
// #endregion
scene("game", () => {
// #region race
const skids = [];
onDraw(() => {
drawCircuit();
for (const m of skids) drawCircle({ pos: m, radius: 2, color: rgb(30, 30, 34), opacity: 0.6 });
});
// Starting grid just past the finish line, in two lanes.
const p0 = CIRCUIT[0];
const player = addCar(p0.x + 20, p0.y + 10, rgb(240, 70, 60));
const rivals = [
addCar(p0.x + 50, p0.y - 10, rgb(70, 140, 240), { wp: 1, pace: 185 }),
addCar(p0.x + 80, p0.y + 10, rgb(250, 200, 60), { wp: 1, pace: 210 }),
];
let time = -COUNTDOWN; // negative while the start lights are on
let lapStart = 0;
let bestLap = Infinity;
const hud = add([text("", { size: 14, font: FONT }), pos(10, 6), z(10)]);
const notice = add([text("", { size: 48, font: FONT }), pos(center()), anchor("center"), z(10)]);
onUpdate(() => {
time += dt();
notice.text = time < 0 ? String(Math.ceil(-time * 1.5)) : time < 0.7 ? t({ es: "¡Ya!", en: "Go!" }) : "";
if (time < 0) return;
// Your controls: keyboard or buttons, or hold the mouse to head for the pointer.
let throttle = (isButtonDown("accelerate") ? 1 : 0) - (isButtonDown("brake") ? 1 : 0);
let steer = (isButtonDown("right") ? 1 : 0) - (isButtonDown("left") ? 1 : 0);
if (isMouseDown()) {
const m = mousePos();
const wanted = Math.atan2(m.y - player.pos.y, m.x - player.pos.x);
steer = clamp(3 * Math.atan2(Math.sin(wanted - player.heading), Math.cos(wanted - player.heading)), -1, 1);
throttle = 1;
}
physics(player, throttle, steer, skids);
if (skids.length > 400) skids.splice(0, skids.length - 400);
rivals.forEach(drive);
rivals.forEach(advance);
// Time trial: every time you cross the line, a lap is closed.
if (advance(player)) {
bestLap = Math.min(bestLap, time - lapStart);
lapStart = time;
if (player.laps >= LAPS) {
const place = 1 + rivals.filter((r) => r.laps >= LAPS).length;
go("end", { total: time, best: bestLap, place });
}
}
const place = 1 + rivals.filter((r) => progress(r) > progress(player)).length;
hud.text =
t({ es: "Vuelta ", en: "Lap " }) + `${Math.min(player.laps + 1, LAPS)}/${LAPS} ` +
t({ es: "Tiempo ", en: "Time " }) + clock(time) + " " +
t({ es: "Vuelta actual ", en: "Current lap " }) + clock(time - lapStart) + " " +
t({ es: "Mejor ", en: "Best " }) + clock(bestLap) + " " +
t({ es: "Puesto ", en: "Position " }) + `${place}/${rivals.length + 1}`;
});
// #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: 560 }),
pos(center().add(0, 30)),
anchor("center"),
color(225, 240, 225),
]);
onKeyPress(["space", "enter"], next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Carreras", en: "Racing" }),
t({
es: `Flecha arriba acelera, abajo frena, izquierda y derecha giran.\nO mantén el clic: el coche va hacia el puntero.\n${LAPS} vueltas contra dos rivales. Fuera del asfalto el coche resbala y frena.\nPulsa espacio o haz clic para empezar.`,
en: `Up arrow accelerates, down brakes, left and right steer.\nOr hold the mouse button: the car heads for the pointer.\n${LAPS} laps against two rivals. Off the tarmac the car slides and slows.\nPress space or click to start.`,
}),
() => go("game"),
),
);
scene("end", ({ total, best, place }) => {
screen(
place === 1 ? t({ es: "¡Ganaste!", en: "You won!" }) : t({ es: `Llegaste en el puesto ${place}`, en: `You finished in position ${place}` }),
t({
es: `Tiempo total ${clock(total)} · mejor vuelta ${clock(best)}.\n¿Puedes bajar tu mejor vuelta? Pulsa espacio o haz clic para repetir.`,
en: `Total time ${clock(total)} · best lap ${clock(best)}.\nCan you beat your best lap? Press space or click to race again.`,
}),
() => go("game"),
);
});
go("title");
// #endregionDesign challenges
- Finding the balance between arcade and simulation, with physics believable enough to reward a good racing line and forgiving enough to be fun from the first lap.
- Designing circuits with rhythm, alternating straights and different kinds of corners, that can be memorised and still leave room to improve.
- Rival AI that seems to really compete without being unbeatable or silly, often adjusting its pace to how you are doing (the «rubber band» effect).
- Conveying a sense of speed through the camera, sound and scenery, even when the real on-screen speed is modest.
History
- 1974OriginGran Trak 10
Space Race (1973), also by Atari, was already a race, but between spaceships. Gran Trak 10 is the first arcade car racing game, with a track seen from above, and the first with a steering wheel.
Gran Trak 10 (Wikipedia) - 1976Night Driver
Night Driver is one of the first first-person racing games, with the night road marked only by posts.
Night Driver (Wikipedia) - 1982Pole Position
Pole Position, regarded as one of the most important racing games, is the first based on a real circuit and introduces the qualifying lap.
Racing video game (Wikipedia) - 1986Out Run
Out Run swaps the circuit for a journey with forks, in which you choose the route and reach one of five endings.
Racing video game (Wikipedia) - 1992Super Mario Kart
Super Mario Kart popularises kart racing, with simple driving and items to attack rivals.
Racing video game (Wikipedia) - 1995
Sega Rally Championship introduces rallying and is the first in which you drive on different surfaces, each with its own grip.
Racing video game (Wikipedia) - 1997Gran Turismo
Gran Turismo, regarded as the most realistic racing simulation of its time, adds real cars and an open-ended career mode of licences, purchases and tuning.
Racing video game (Wikipedia)
Reference games
1974
Gran Trak 10
You drive a car around a track seen from above against the clock, with a steering wheel, pedals and a gear stick as in a real car. It is the first arcade car racing game and the first with a steering wheel.
1976
Night Driver
You drive at night in first person and only see the posts that mark the edges of the road; if you follow them without leaving the road, you get further before time runs out.
1982
Pole Position
A Formula One race seen from behind the car on the real Fuji circuit. First you drive a qualifying lap against the clock to earn your grid place, then the race against other cars.
1986
Out Run
You drive a convertible along roads with traffic against the clock, and at every fork you choose the way, which leads to one of several endings. The journey matters more than beating a rival.
1992
Super Mario Kart
Kart racing with Mario characters on fantasy tracks, where besides driving you pick up items to attack or defend yourself. It has a two-player split-screen mode.
1997
Gran Turismo
A driving simulator with 140 real cars. In its career mode you earn driving licences, win money in races and spend it buying and tuning cars.
2007
SuperTuxKart
Free and open-source kart racing with Tux and other free-software mascots, items to throw and fantasy tracks. It does not run in the browser, but it is a free download and its code can be studied.
2022
PolyTrack
Time trials on low-poly tracks with loops and jumps, in the style of TrackMania; every millisecond counts, you can retry instantly and it has an editor to build and share tracks. Its author publishes it free and playable in the browser.
2024
DR1V3N WILD
A retro-style arcade race, seen from behind the car, across ten stages with other AI-controlled vehicles; the game saves your best distance and your fastest time. It was made for js13kGames 2024, where the whole game fits in 13 kB, and its author published it playable in the browser (on itch.io it is called Driven Wild).
Exercise
Play the demo and write down your best lap. Then change the grass grip and drag in the code and check how your racing line and lap times change. Finally, draw a new circuit on paper, turn it into the list of points and explain which corner is the hardest and why.
Subgenres
- Kart racingYou race karts around fantasy tracks with simple, forgiving handling, and pick up items along the way to attack your rivals, defend yourself or gain speed, so positions can change right up to the final corner.
- Sim racingYou race cars that behave like real ones: fuel runs down, tyres lose grip and the car's setup changes how it corners. To be fast you have to brake at the limit, take the right racing line and keep control, ideally with a wheel and pedals.