Saltar al contenido
GameGenresEnglish
Deportes

Carreras

Juego en el que compites por llegar primero o en el menor tiempo por un circuito o un recorrido, casi siempre al volante de un vehículo. El reto es dominar la velocidad y la trayectoria sin salirte ni perder tiempo en las curvas.

También se llama: juego de carreras, videojuego de carreras, carreras de coches

Mecánicas que lo definen

  • Control con inercia, porque el vehículo acelera, frena y gira de forma progresiva y no responde al instante.
  • Trazada, que es elegir por dónde entrar y salir de cada curva y cuándo frenar, para llevar la mayor velocidad posible.
  • Superficies con distinto agarre (asfalto, tierra, pasto), que castigan salirse de la pista y permiten derrapar.
  • Contrarreloj y rivales, porque compites contra el cronómetro, contra tu mejor vuelta o contra otros pilotos humanos o controlados por la IA.
  • Variantes que añaden capas, como los objetos para atacar de las carreras de karts o el ajuste de coches de los simuladores.

Bucle de juego

Leer la curva que viene, frenar y girar a tiempo, acelerar a la salida, mirar el tiempo de la vuelta y repetir para bajarlo.

Demo propia

Controles. Flecha arriba o W para acelerar, flecha abajo o S para frenar (y dar marcha atrás), izquierda/derecha o A/D para girar. Con el ratón o el dedo, mantén pulsado y el coche acelera hacia el puntero. En móvil también hay botones en pantalla.

Abrir en pantalla completa

Una carrera a tres vueltas en vista cenital contra dos rivales, con cronómetro y mejor vuelta. El coche tiene inercia, gira según su velocidad, derrapa si entras demasiado rápido y pierde velocidad en el pasto. Lo justo para sentir lo que define al género, que es encontrar la trazada más rápida.

Hecha con Kaplay, en unas 185 líneas de JavaScript.

Cómo está hecha

  1. Configuración y constantes

    Se crea el juego con un tamaño fijo y botones virtuales (accelerate, brake, left, right), así el mismo código sirve con teclado o pantalla táctil. Las constantes son el «manejo» del coche, porque velocidad máxima, aceleración, frenado, giro, roce y agarre deciden si se siente pesado, nervioso o pegado al suelo. El roce y el agarre tienen un valor para la pista y otro para el pasto.

    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")}` : "—");
  2. El circuito como lista de puntos

    El circuito es la lista de puntos de su línea central, y se cierra uniendo el último con el primero. La misma lista sirve para dibujar el asfalto (una línea muy gruesa), para saber si un coche se salió (si está a más de medio ancho de la línea central) y como waypoints que siguen los rivales. Cambiar un punto cambia todo a la vez.

    // 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) });
    }
  3. El coche

    Cada coche guarda dos cosas distintas, su ángulo (hacia dónde apunta el morro) y su velocidad (hacia dónde se mueve de verdad). Que no coincidan es lo que permite derrapar. También recuerda en qué tramo del circuito va y cuántas vueltas lleva.

    // 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 },
      ]);
    }
  4. Física simplificada

    Es el corazón del género, en tres pasos. 1) Girar, cuanto más rápido vas más gira el coche, hasta un límite, porque parado no gira y a tope gira algo menos. 2) Descomponer la velocidad en una parte «hacia delante» y otra «de lado», respecto del morro (con el producto escalar). 3) El motor y el freno cambian solo la parte de delante; el roce la reduce poco a poco y el agarre elimina la parte de lado. Con mucho agarre el coche va sobre raíles; con poco, sigue deslizándose hacia donde iba y derrapa. En el pasto el roce sube y el agarre baja, por eso salirse cuesta tiempo. Como la regla es la misma para todos, la trazada (por dónde entras y sales de cada curva) importa más que la velocidad máxima.

    // 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());
    }
  5. Contar vueltas

    Cada coche solo avanza su contador de tramo si llega al tramo siguiente del circuito (o al otro). Así no cuenta atajar por el pasto ni cruzar la meta marcha atrás. Pasar del último tramo al primero es cruzar la meta y suma una vuelta. El mismo recorrido (vueltas por tramos más tramo actual) sirve para calcular tu puesto.

    // 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
  6. Rivales con waypoints

    Los rivales usan la misma física que tú, la IA solo decide qué botones apretar. Apuntan al siguiente waypoint, pasan al otro cuando llegan cerca o ya lo dejaron atrás, y eligen una velocidad objetivo, que es su ritmo en recta y menos cuanto más tienen que girar. Cambiar el ritmo de cada uno es la forma más simple de ajustar la dificultad.

    // 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));
    }
  7. La carrera y el cronómetro

    La escena de juego dibuja el circuito y las marcas de derrape, pone los coches en la parrilla y espera un semáforo de dos segundos. Luego, cada fotograma, lee tus controles (teclado, botones o mantener el clic hacia el puntero), mueve a todos y comprueba si cruzaste la meta. Cada cruce cierra una vuelta, guarda la mejor y, a la tercera, termina la carrera. La contrarreloj contra tu propia mejor vuelta es lo que da ganas de repetir.

    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}`;
    });
  8. Escenas

    El juego tiene tres escenas (title, game y end) y go() salta de una a otra pasando los resultados (tiempo total, mejor vuelta y puesto). Así se repite la carrera sin limpiar nada a mano.

    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");

Qué simplificamos

  • Los coches son rectángulos y la pista es una línea gruesa; no hay sonido, cámara que siga al coche ni marchas.
  • Los coches no chocan entre sí, y los bordes de la pantalla son muros invisibles.
  • La física no modela neumáticos, peso ni par motor, solo tres números que se notan (roce, agarre y giro). Es física «arcade», no de simulador.

Para modificarla

  • Cambia GRIP.track de 5 a 2 y luego a 12. Describe cómo cambia la sensación y qué tipo de juego de carreras se parece más a cada valor.
  • Diseña tu propio circuito cambiando la lista CIRCUIT. Pon una curva cerrada al final de una recta larga y observa dónde frenan los rivales.
  • Añade un «fantasma» que repita tu mejor vuelta. Guarda la posición del coche en cada fotograma y dibújala en la siguiente vuelta.
  • Haz que los coches choquen entre sí, separándolos y repartiendo la velocidad cuando se tocan.
Código completo
// 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");
// #endregion

Retos de diseño

  • Encontrar el punto entre arcade y simulación, con una física lo bastante creíble para premiar la buena trazada y lo bastante permisiva para que se disfrute desde la primera vuelta.
  • Diseñar circuitos con ritmo, alternando rectas y curvas de distinto tipo, que se aprendan de memoria y aun así guarden margen para mejorar.
  • Una IA rival que parezca competir de verdad sin ser imbatible ni ridícula, a menudo ajustando su ritmo según cómo vas (el «efecto goma»).
  • Transmitir la sensación de velocidad con la cámara, el sonido y el paisaje, aunque la velocidad real en pantalla sea modesta.

Historia

  1. 1974
    OrigenGran Trak 10

    Space Race (1973), también de Atari, ya era una carrera, pero de naves espaciales. Gran Trak 10 es el primer juego de carreras de coches de los arcades, con un circuito visto desde arriba y el primero con volante.

    Gran Trak 10 (Wikipedia)
  2. 1976
    Night Driver

    Night Driver es uno de los primeros juegos de carreras en primera persona, con la carretera nocturna marcada solo por postes.

    Night Driver (Wikipedia)
  3. 1982
    Pole Position

    Pole Position, considerado uno de los juegos de carreras más importantes, es el primero basado en un circuito real e introduce la vuelta de clasificación.

    Racing video game (Wikipedia)
  4. 1986
    Out Run

    Out Run cambia el circuito por un viaje con bifurcaciones, en el que eliges la ruta y llegas a uno de cinco finales.

    Racing video game (Wikipedia)
  5. 1992
    Super Mario Kart

    Super Mario Kart populariza las carreras de karts, con conducción sencilla y objetos para atacar a los rivales.

    Racing video game (Wikipedia)
  6. 1995

    Sega Rally Championship introduce el rally y es el primero en que conduces sobre superficies distintas, cada una con su agarre.

    Racing video game (Wikipedia)
  7. 1997
    Gran Turismo

    Gran Turismo, considerado la simulación de carreras más realista de su época, suma coches reales y un modo carrera abierto de licencias, compras y ajustes.

    Racing video game (Wikipedia)

Juegos de referencia

1974

Gran Trak 10

Atari. arcade

Conduces un coche por un circuito visto desde arriba contra el reloj, con volante, pedales y palanca de cambios como en un auto real. Es el primer juego de carreras de coches de los arcades y el primero con volante.

1976

Night Driver

Atari. arcade

Conduces de noche en primera persona y solo ves los postes que marcan los bordes de la carretera; si los sigues sin salirte, avanzas más antes de que se acabe el tiempo.

1982

Pole Position

Namco. arcade

Carrera de Fórmula 1 vista desde detrás del coche en el circuito real de Fuji. Primero corres una vuelta de clasificación contra el reloj para ganar tu lugar en la parrilla y luego la carrera contra otros coches.

1986

Out Run

Sega. arcade

Conduces un descapotable por carreteras con tráfico contra el reloj, y en cada bifurcación eliges el camino, lo que lleva a uno de varios finales. Importa más el viaje que ganarle a un rival.

1992

Super Mario Kart

Nintendo EAD. Super Nintendo

Carreras de karts con personajes de Mario en circuitos de fantasía, donde además de conducir recoges objetos para atacar o defenderte. Tiene modo para dos jugadores en pantalla dividida.

1997

Gran Turismo

Polys Entertainment. PlayStation

Simulador de conducción con 140 coches reales. En su modo carrera sacas licencias de conducir, ganas dinero en carreras y lo gastas en comprar y ajustar coches.

2022

PolyTrack

Kodub. navegador, Windows, macOS, Linux, Android

Contrarreloj en circuitos de bajo poligonaje con rizos y saltos, al estilo de TrackMania; cada milésima cuenta, puedes reintentar al instante y tiene un editor para crear y compartir pistas. Su autor lo publica gratis y jugable en el navegador.

2024

DR1V3N WILD

Frank Force. navegador

Carrera arcade de estilo retro, vista desde detrás del coche, a través de diez etapas con otros vehículos controlados por la IA; el juego guarda tu mejor distancia y tu mejor tiempo. Se hizo para js13kGames 2024, donde todo el juego cabe en 13 kB, y su autor lo publicó jugable en el navegador (en itch.io se llama Driven Wild).

Ejercicio

Juega la demo y anota tu mejor vuelta. Luego cambia en el código el agarre y el roce del pasto y comprueba cómo cambian tu trazada y tus tiempos. Por último, dibuja en papel un circuito nuevo, pásalo a la lista de puntos y explica qué curva es la más difícil y por qué.

Subgéneros

  • Carreras de kartsCompites en carreras de karts por pistas de fantasía con una conducción sencilla y permisiva, y además recoges objetos por el camino para atacar a tus rivales, defenderte o ganar velocidad, así que la posición puede cambiar hasta la última curva.
  • Simulador de carrerasCorres con coches que se comportan como los reales: el combustible se gasta, los neumáticos pierden agarre y el reglaje del coche cambia cómo dobla. Para ser rápido tienes que frenar en el límite, trazar bien las curvas y no perder el control, mejor con volante y pedales.

Fuentes