Saltar al contenido
GameGenresEnglish
Acción

Ritmo

Pulsas botones, tocas la pantalla o golpeas un instrumento de plástico justo cuando lo marca la música; cuanto más exacta es tu sincronía con el ritmo, más puntos sumas.

También se llama: videojuego de ritmo, juego de ritmo

Mecánicas que lo definen

  • Notas que avanzan por carriles o por la pantalla hacia una línea o zona de golpe, colocadas sobre el pulso de la canción. El mapa de notas (chart) es la partitura que lees.
  • Juicio por precisión temporal, con ventanas de unas decenas de milisegundos alrededor de cada nota que deciden si fue perfecto, bien o fallo.
  • Combo y multiplicador, porque encadenar aciertos multiplica los puntos y un solo fallo rompe la cadena.
  • Un medidor de rendimiento que sube con los aciertos y baja con los fallos; si se vacía, la canción termina antes.
  • Mandos muy distintos según el juego, como botones, pantalla táctil, alfombras de baile, instrumentos de plástico o sables en realidad virtual, y varias dificultades por canción.

Bucle de juego

Ver la nota que llega, anticipar el pulso, pulsar en el momento justo, recibir el juicio y sostener el combo hasta el final de la canción; luego repetirla para subir la puntuación.

Demo propia

Controles. D, F, J y K (o las cuatro flechas), una tecla por carril, cuando la nota cruza la línea blanca. Con el ratón o el dedo, toca el carril. Espacio, Enter o clic para empezar.

Abrir en pantalla completa

Una canción corta de 16 compases en la que las notas bajan por cuatro carriles hacia una línea. Cada golpe se juzga por su precisión en milisegundos (perfecto, bien o fallo), y los aciertos seguidos suben el combo y multiplican los puntos. La música es un metrónomo y una melodía generados con WebAudio, sincronizados con el reloj del audio.

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

Cómo está hecha

  1. Configuración y constantes

    Se crea el juego con un botón virtual por carril, así el mismo código sirve con teclas o con toques. Las constantes definen la canción (BPM, compases) y la lectura de las notas. NOTE_SPEED no cambia el ritmo, solo cuánto tiempo antes ves venir cada nota.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [22, 18, 44],
      // One virtual button per lane, plus one to start.
      buttons: {
        lane0: { keyboard: ["d", "left"] }, lane1: { keyboard: ["f", "down"] },
        lane2: { keyboard: ["j", "up"] }, lane3: { keyboard: ["k", "right"] },
        start: { keyboard: ["space", "enter"] },
      },
    });
    
    const BPM = 112;
    const BEAT = 60 / BPM; // seconds per beat
    const BARS = 16; // song length: 16 bars of 4 beats
    const COUNT_IN = 4; // metronome beats before the first note
    const LANES = 4;
    const LANE_W = 72;
    const LEFT = (640 - LANES * LANE_W) / 2;
    const HIT_Y = 300; // the hit line
    const NOTE_SPEED = 320; // px per second: how early you see a note coming
    const KEYS = ["D", "F", "J", "K"];
    const LANE_COLORS = [rgb(255, 99, 132), rgb(255, 196, 61), rgb(80, 220, 160), rgb(90, 160, 255)];
    const JUDGE_TEXT = { perfect: { es: "¡Perfecto!", en: "Perfect!" }, good: { es: "Bien", en: "Good" }, miss: { es: "Fallo", en: "Miss" } };
    const JUDGE_COLOR = { perfect: rgb(120, 255, 200), good: rgb(255, 230, 120), miss: rgb(255, 110, 110) };
  2. El chart, la partitura

    El chart es una lista de notas con su momento (en segundos de canción) y su carril. Se construye sobre la cuadrícula de pulsos, con una nota por pulso al principio y algunas a contratiempo (corcheas) después, y un silencio al cerrar cada frase. Se usa un azar con semilla para que la canción sea siempre la misma y se pueda aprender.

    // The chart is the list of notes: when (in song seconds) and in which lane.
    // We generate it from the beat grid with a seeded random, so it is always the same song.
    function makeChart() {
      let seed = 2024;
      const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
      const notes = [];
      let lane = 1;
      for (let beat = 0; beat < BARS * 4; beat++) {
        const bar = Math.floor(beat / 4);
        if (bar % 4 === 3 && beat % 4 === 3) continue; // a rest closes each 4-bar phrase
        // First 4 bars: one note per beat. Then some off-beat (eighth) notes appear.
        const steps = bar >= 4 && random() < 0.25 + bar * 0.02 ? [0, 0.5] : [0];
        for (const step of steps) {
          lane = (lane + 1 + Math.floor(random() * 3)) % LANES; // never the same lane twice in a row
          notes.push({ time: (beat + step) * BEAT, lane, judged: false });
        }
      }
      return notes;
    }
  3. Sonido con WebAudio

    Cada sonido es un oscilador con una envolvente de volumen que se apaga rápido, sin archivos de audio. La canción es una lista de eventos con su tiempo, que son un clic de metrónomo en cada pulso (más fuerte en el primero del compás) y un tono por nota, con una altura distinta por carril. El sonido acompaña, pero no hace falta para jugar.

    // Minimal WebAudio: each sound is an oscillator with a short volume envelope.
    // Nothing here is needed to play: if the audio is muted or blocked, the game still works.
    const ctx = k.audioCtx;
    const PITCHES = [262, 330, 392, 523]; // C E G C: one pitch per lane
    
    function beep(when, freq, duration, type, volume) {
      const osc = new OscillatorNode(ctx, { type, frequency: freq });
      const gain = ctx.createGain();
      gain.gain.setValueAtTime(volume, when);
      gain.gain.exponentialRampToValueAtTime(0.001, when + duration);
      osc.connect(gain).connect(ctx.destination);
      osc.start(when);
      osc.stop(when + duration);
    }
    
    // The song as a list of timed sounds: a metronome click on every beat
    // (louder on the first beat of each bar) and a tone for every note.
    function makeSong(chart) {
      const events = [];
      for (let beat = -COUNT_IN; beat < BARS * 4; beat++) {
        const downbeat = beat % 4 === 0;
        events.push({ time: beat * BEAT, play: (w) => beep(w, downbeat ? 1200 : 800, 0.05, "square", downbeat ? 0.08 : 0.04) });
      }
      for (const n of chart) events.push({ time: n.time, play: (w) => beep(w, PITCHES[n.lane], 0.18, "triangle", 0.12) });
      return events.sort((a, b) => a.time - b.time);
    }
  4. El reloj del audio

    Es la clave del género. Si la posición de la canción se calculara sumando dt() en cada fotograma, los pequeños errores se acumularían (los fotogramas no duran lo mismo y la pestaña puede trabarse) y lo que oyes se iría separando de lo que ves. ctx.currentTime es el reloj con el que la tarjeta de sonido reproduce, así que la canción avanza lo que avanzó ese reloj. Además, los sonidos se programan un poco antes (LOOKAHEAD) para un instante exacto del audio, en vez de dispararse «ahora» en cada fotograma. Si el navegador no deja sonar el audio, el juego usa dt() y sigue siendo jugable, aunque sin sonido al que ajustarse.

    // WHY THE AUDIO CLOCK: notes must line up with what you hear. Adding up dt()
    // every frame drifts (frames are uneven, the tab may stall) and the sound
    // would slowly slip away from the notes. ctx.currentTime is the clock the
    // sound card itself plays by, so we advance the song by how much IT moved.
    // If the audio is not running (muted by the browser, no device), we fall back
    // to dt(): the game stays playable, it just is not locked to any sound.
    let songTime = -COUNT_IN * BEAT; // negative during the count-in
    let lastAudio = null; // ctx.currentTime on the previous frame
    function tickClock() {
      const running = ctx.state === "running";
      songTime += running && lastAudio !== null ? ctx.currentTime - lastAudio : dt();
      lastAudio = running ? ctx.currentTime : null;
    }
    // Exact song time right now, between two frames.
    const now = () => (lastAudio === null ? songTime : songTime + ctx.currentTime - lastAudio);
    
    // Sounds are scheduled a little ahead, at a precise audio time, instead of
    // played "now" each frame: that way they do not wobble with the frame rate.
    const LOOKAHEAD = 0.15;
    let nextEvent = 0;
    function scheduleSounds() {
      while (nextEvent < song.length && song[nextEvent].time < songTime + LOOKAHEAD) {
        const when = ctx.currentTime + (song[nextEvent].time - songTime);
        if (ctx.state === "running" && when >= ctx.currentTime) song[nextEvent].play(when);
        nextEvent++;
      }
    }
  5. Juicio, combo y puntos

    Al pulsar un carril se busca la nota pendiente más cercana y se mide el error, que es el instante de la pulsación menos el de la nota. Hasta 45 ms es «perfecto», hasta 100 ms es «bien», y un golpe demasiado temprano gasta la nota como fallo. Una nota que pasa la línea sin golpe también es fallo. El combo cuenta los aciertos seguidos y multiplica los puntos hasta por cuatro, y un fallo lo devuelve a cero.

    // Timing windows in seconds: how far from the note's time a hit may be.
    const WINDOW = { perfect: 0.045, good: 0.1, early: 0.16 };
    const POINTS = { perfect: 300, good: 100, miss: 0 };
    const stats = { perfect: 0, good: 0, miss: 0, score: 0, combo: 0, maxCombo: 0 };
    let feedback = null; // last judgement shown on screen
    const flash = [0, 0, 0, 0];
    
    function judge(kind, error) {
      stats[kind]++;
      stats.combo = kind === "miss" ? 0 : stats.combo + 1;
      stats.maxCombo = Math.max(stats.maxCombo, stats.combo);
      stats.score += POINTS[kind] * multiplier();
      feedback = { kind, error, timer: 0.6 };
    }
    // The combo multiplies the points: x1, x2 after 10 hits in a row… up to x4.
    const multiplier = () => Math.min(4, 1 + Math.floor(stats.combo / 10));
    
    function hitLane(lane) {
      flash[lane] = 0.12;
      const time = now();
      // The closest note still waiting in this lane.
      let best = null;
      for (const n of chart) {
        if (n.judged || n.lane !== lane || Math.abs(n.time - time) > WINDOW.early) continue;
        if (!best || Math.abs(n.time - time) < Math.abs(best.time - time)) best = n;
      }
      if (!best) return; // pressing with no note nearby is not punished
      best.judged = true;
      const error = time - best.time; // negative = early, positive = late
      judge(Math.abs(error) <= WINDOW.perfect ? "perfect" : Math.abs(error) <= WINDOW.good ? "good" : "miss", error);
    }
  6. Entrada

    Cada botón virtual golpea su carril. Con el ratón o el dedo se calcula el carril a partir de la coordenada x del toque. Las dos vías llaman a la misma función, así el juicio es idéntico.

    for (let i = 0; i < LANES; i++) onButtonPress("lane" + i, () => hitLane(i));
    // Mouse or finger: tap the lane itself.
    onMousePress(() => {
      const lane = Math.floor((mousePos().x - LEFT) / LANE_W);
      if (lane >= 0 && lane < LANES) hitLane(lane);
    });
  7. Dibujar las notas

    Las notas no son objetos que se mueven solos. En cada fotograma se dibujan donde dice el reloj, porque su altura depende solo de cuánto falta para su momento (tiempo de la nota menos tiempo de la canción). Por eso nunca se desincronizan, aunque el juego vaya a menos fotogramas por segundo.

    // Notes are not game objects: each frame we draw them where the clock says.
    // y depends only on (note time − song time), so they can never drift from the music.
    onDraw(() => {
      for (let i = 0; i < LANES; i++) {
        const x = LEFT + i * LANE_W;
        drawRect({ pos: vec2(x, 0), width: LANE_W - 4, height: 360, color: rgb(40, 34, 72), opacity: 0.8 });
        drawRect({ pos: vec2(x, HIT_Y - 14), width: LANE_W - 4, height: 28, radius: 6, color: LANE_COLORS[i], opacity: 0.25 + flash[i] * 5 });
        drawText({ text: KEYS[i], pos: vec2(x + LANE_W / 2 - 2, HIT_Y + 30), size: 16, font: FONT, anchor: "center", color: rgb(200, 200, 230) });
      }
      drawRect({ pos: vec2(LEFT, HIT_Y - 1), width: LANES * LANE_W - 4, height: 2, color: rgb(255, 255, 255) });
      for (const n of chart) {
        if (n.judged) continue;
        const y = HIT_Y - (n.time - songTime) * NOTE_SPEED;
        if (y < -20 || y > 380) continue;
        drawRect({ pos: vec2(LEFT + n.lane * LANE_W + 4, y - 9), width: LANE_W - 12, height: 18, radius: 5, color: LANE_COLORS[n.lane] });
      }
  8. Marcador y juicio en pantalla

    Se muestran los puntos, el combo con su multiplicador, la cuenta atrás de los primeros cuatro pulsos y el último juicio con su error en milisegundos, antes o tarde. Ver ese número enseña qué significan las ventanas y si sueles adelantarte.

    drawText({ text: t({ es: "Puntos", en: "Score" }) + `\n${stats.score}`, pos: vec2(20, 20), size: 18, font: FONT });
    drawText({ text: t({ es: "Combo", en: "Combo" }) + `\n${stats.combo}  x${multiplier()}`, pos: vec2(500, 20), size: 18, font: FONT });
    // Count-in: 4, 3, 2, 1 on the metronome beats before the first note.
    if (songTime < 0) drawText({ text: String(Math.ceil(-songTime / BEAT)), pos: vec2(320, 150), size: 48, font: FONT, anchor: "center" });
    if (feedback && feedback.timer > 0) {
      drawRect({ pos: vec2(320, 175), width: 220, height: 64, radius: 10, anchor: "center", color: rgb(10, 8, 24), opacity: 0.75 * Math.min(1, feedback.timer * 3) });
      const { kind, error, timer } = feedback;
      // Showing the error in milliseconds teaches what the windows mean.
      const when = t(error < 0 ? { es: "antes", en: "early" } : { es: "tarde", en: "late" });
      const detail = error === null ? "" : `\n${Math.round(Math.abs(error) * 1000)} ms ${when}`;
      drawText({ text: t(JUDGE_TEXT[kind]) + detail, pos: vec2(320, 170), size: 26, font: FONT, anchor: "center", align: "center", color: JUDGE_COLOR[kind], opacity: Math.min(1, timer * 3) });
    }
  9. Escenas

    El juego tiene tres escenas (title, game y end). Al empezar se reanuda el audio, porque los navegadores solo dejan sonar después de un clic o una tecla. Al final se muestran la precisión, el reparto de juicios y el combo máximo.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
      add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
      // Browsers only allow sound after a click or a key press, so we resume it here.
      const startGame = () => (ctx.resume(), next());
      onButtonPress("start", startGame);
      onClick(startGame);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Ritmo", en: "Rhythm" }),
        t({
          es: "Pulsa D F J K (o las flechas) cuando cada nota cruce la línea blanca, o toca su carril.\nCuanto más cerca del pulso, más puntos. Espacio o clic para empezar.",
          en: "Press D F J K (or the arrows) as each note crosses the white line, or tap its lane.\nThe closer to the beat, the more points. Space or click to start.",
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (stats, total) => {
      const accuracy = Math.round(((stats.perfect + stats.good * 0.5) / total) * 100);
      screen(
        t({ es: `${stats.score} puntos`, en: `${stats.score} points` }),
        t({
          es: `Perfecto ${stats.perfect} · Bien ${stats.good} · Fallo ${stats.miss}\nPrecisión ${accuracy}% · Combo máximo ${stats.maxCombo}\nEspacio o clic para repetir.`,
          en: `Perfect ${stats.perfect} · Good ${stats.good} · Miss ${stats.miss}\nAccuracy ${accuracy}% · Max combo ${stats.maxCombo}\nSpace or click to play again.`,
        }),
        () => go("game"),
      );
    });
    
    go("title");

Qué simplificamos

  • La canción es un metrónomo con un tono por nota, sin archivos de audio. Un juego real carga una grabación y sincroniza el chart con ella.
  • El chart se genera al azar sobre el pulso (con semilla fija, así siempre es igual). En un juego real lo escribe a mano un diseñador que escucha la canción.
  • No hay notas largas que se mantienen, ni medidor de vida, ni calibración del desfase entre audio y pantalla.
  • El juego no castiga pulsar cuando no hay ninguna nota cerca, y con el dedo solo se puede tocar un carril a la vez.

Para modificarla

  • Añade una constante de desfase (offset) que se sume al tiempo de cada pulsación y una pantalla para calibrarla pulsando al ritmo del metrónomo.
  • Añade notas largas que haya que mantener pulsadas hasta su final, y piensa cómo juzgar cuándo se suelta.
  • Añade un medidor de vida que baje con cada fallo y termine la canción si se vacía.
  • Escribe a mano el chart de una melodía conocida (una lista de tiempos y carriles) y compárala con la generada.
Código completo
// Rhythm demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";

// #region setup
const k = kaplay({
  width: 640,
  height: 360,
  letterbox: true, pixelDensity,
  background: [22, 18, 44],
  // One virtual button per lane, plus one to start.
  buttons: {
    lane0: { keyboard: ["d", "left"] }, lane1: { keyboard: ["f", "down"] },
    lane2: { keyboard: ["j", "up"] }, lane3: { keyboard: ["k", "right"] },
    start: { keyboard: ["space", "enter"] },
  },
});

const BPM = 112;
const BEAT = 60 / BPM; // seconds per beat
const BARS = 16; // song length: 16 bars of 4 beats
const COUNT_IN = 4; // metronome beats before the first note
const LANES = 4;
const LANE_W = 72;
const LEFT = (640 - LANES * LANE_W) / 2;
const HIT_Y = 300; // the hit line
const NOTE_SPEED = 320; // px per second: how early you see a note coming
const KEYS = ["D", "F", "J", "K"];
const LANE_COLORS = [rgb(255, 99, 132), rgb(255, 196, 61), rgb(80, 220, 160), rgb(90, 160, 255)];
const JUDGE_TEXT = { perfect: { es: "¡Perfecto!", en: "Perfect!" }, good: { es: "Bien", en: "Good" }, miss: { es: "Fallo", en: "Miss" } };
const JUDGE_COLOR = { perfect: rgb(120, 255, 200), good: rgb(255, 230, 120), miss: rgb(255, 110, 110) };
// #endregion

// #region chart
// The chart is the list of notes: when (in song seconds) and in which lane.
// We generate it from the beat grid with a seeded random, so it is always the same song.
function makeChart() {
  let seed = 2024;
  const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
  const notes = [];
  let lane = 1;
  for (let beat = 0; beat < BARS * 4; beat++) {
    const bar = Math.floor(beat / 4);
    if (bar % 4 === 3 && beat % 4 === 3) continue; // a rest closes each 4-bar phrase
    // First 4 bars: one note per beat. Then some off-beat (eighth) notes appear.
    const steps = bar >= 4 && random() < 0.25 + bar * 0.02 ? [0, 0.5] : [0];
    for (const step of steps) {
      lane = (lane + 1 + Math.floor(random() * 3)) % LANES; // never the same lane twice in a row
      notes.push({ time: (beat + step) * BEAT, lane, judged: false });
    }
  }
  return notes;
}
// #endregion

// #region audio
// Minimal WebAudio: each sound is an oscillator with a short volume envelope.
// Nothing here is needed to play: if the audio is muted or blocked, the game still works.
const ctx = k.audioCtx;
const PITCHES = [262, 330, 392, 523]; // C E G C: one pitch per lane

function beep(when, freq, duration, type, volume) {
  const osc = new OscillatorNode(ctx, { type, frequency: freq });
  const gain = ctx.createGain();
  gain.gain.setValueAtTime(volume, when);
  gain.gain.exponentialRampToValueAtTime(0.001, when + duration);
  osc.connect(gain).connect(ctx.destination);
  osc.start(when);
  osc.stop(when + duration);
}

// The song as a list of timed sounds: a metronome click on every beat
// (louder on the first beat of each bar) and a tone for every note.
function makeSong(chart) {
  const events = [];
  for (let beat = -COUNT_IN; beat < BARS * 4; beat++) {
    const downbeat = beat % 4 === 0;
    events.push({ time: beat * BEAT, play: (w) => beep(w, downbeat ? 1200 : 800, 0.05, "square", downbeat ? 0.08 : 0.04) });
  }
  for (const n of chart) events.push({ time: n.time, play: (w) => beep(w, PITCHES[n.lane], 0.18, "triangle", 0.12) });
  return events.sort((a, b) => a.time - b.time);
}
// #endregion

scene("game", () => {
  const chart = makeChart();
  const song = makeSong(chart);
  const end = chart[chart.length - 1].time + 1.5;

  // #region clock
  // WHY THE AUDIO CLOCK: notes must line up with what you hear. Adding up dt()
  // every frame drifts (frames are uneven, the tab may stall) and the sound
  // would slowly slip away from the notes. ctx.currentTime is the clock the
  // sound card itself plays by, so we advance the song by how much IT moved.
  // If the audio is not running (muted by the browser, no device), we fall back
  // to dt(): the game stays playable, it just is not locked to any sound.
  let songTime = -COUNT_IN * BEAT; // negative during the count-in
  let lastAudio = null; // ctx.currentTime on the previous frame
  function tickClock() {
    const running = ctx.state === "running";
    songTime += running && lastAudio !== null ? ctx.currentTime - lastAudio : dt();
    lastAudio = running ? ctx.currentTime : null;
  }
  // Exact song time right now, between two frames.
  const now = () => (lastAudio === null ? songTime : songTime + ctx.currentTime - lastAudio);

  // Sounds are scheduled a little ahead, at a precise audio time, instead of
  // played "now" each frame: that way they do not wobble with the frame rate.
  const LOOKAHEAD = 0.15;
  let nextEvent = 0;
  function scheduleSounds() {
    while (nextEvent < song.length && song[nextEvent].time < songTime + LOOKAHEAD) {
      const when = ctx.currentTime + (song[nextEvent].time - songTime);
      if (ctx.state === "running" && when >= ctx.currentTime) song[nextEvent].play(when);
      nextEvent++;
    }
  }
  // #endregion

  // #region judge
  // Timing windows in seconds: how far from the note's time a hit may be.
  const WINDOW = { perfect: 0.045, good: 0.1, early: 0.16 };
  const POINTS = { perfect: 300, good: 100, miss: 0 };
  const stats = { perfect: 0, good: 0, miss: 0, score: 0, combo: 0, maxCombo: 0 };
  let feedback = null; // last judgement shown on screen
  const flash = [0, 0, 0, 0];

  function judge(kind, error) {
    stats[kind]++;
    stats.combo = kind === "miss" ? 0 : stats.combo + 1;
    stats.maxCombo = Math.max(stats.maxCombo, stats.combo);
    stats.score += POINTS[kind] * multiplier();
    feedback = { kind, error, timer: 0.6 };
  }
  // The combo multiplies the points: x1, x2 after 10 hits in a row… up to x4.
  const multiplier = () => Math.min(4, 1 + Math.floor(stats.combo / 10));

  function hitLane(lane) {
    flash[lane] = 0.12;
    const time = now();
    // The closest note still waiting in this lane.
    let best = null;
    for (const n of chart) {
      if (n.judged || n.lane !== lane || Math.abs(n.time - time) > WINDOW.early) continue;
      if (!best || Math.abs(n.time - time) < Math.abs(best.time - time)) best = n;
    }
    if (!best) return; // pressing with no note nearby is not punished
    best.judged = true;
    const error = time - best.time; // negative = early, positive = late
    judge(Math.abs(error) <= WINDOW.perfect ? "perfect" : Math.abs(error) <= WINDOW.good ? "good" : "miss", error);
  }
  // #endregion

  // #region input
  for (let i = 0; i < LANES; i++) onButtonPress("lane" + i, () => hitLane(i));
  // Mouse or finger: tap the lane itself.
  onMousePress(() => {
    const lane = Math.floor((mousePos().x - LEFT) / LANE_W);
    if (lane >= 0 && lane < LANES) hitLane(lane);
  });
  // #endregion

  onUpdate(() => {
    tickClock();
    scheduleSounds();
    // A note that went past the line without being hit is a miss.
    for (const n of chart) {
      if (!n.judged && songTime - n.time > WINDOW.good) {
        n.judged = true;
        judge("miss", null);
      }
    }
    for (let i = 0; i < LANES; i++) flash[i] = Math.max(0, flash[i] - dt());
    if (feedback) feedback.timer -= dt();
    if (songTime > end) go("end", stats, chart.length);
  });

  // #region notes
  // Notes are not game objects: each frame we draw them where the clock says.
  // y depends only on (note time − song time), so they can never drift from the music.
  onDraw(() => {
    for (let i = 0; i < LANES; i++) {
      const x = LEFT + i * LANE_W;
      drawRect({ pos: vec2(x, 0), width: LANE_W - 4, height: 360, color: rgb(40, 34, 72), opacity: 0.8 });
      drawRect({ pos: vec2(x, HIT_Y - 14), width: LANE_W - 4, height: 28, radius: 6, color: LANE_COLORS[i], opacity: 0.25 + flash[i] * 5 });
      drawText({ text: KEYS[i], pos: vec2(x + LANE_W / 2 - 2, HIT_Y + 30), size: 16, font: FONT, anchor: "center", color: rgb(200, 200, 230) });
    }
    drawRect({ pos: vec2(LEFT, HIT_Y - 1), width: LANES * LANE_W - 4, height: 2, color: rgb(255, 255, 255) });
    for (const n of chart) {
      if (n.judged) continue;
      const y = HIT_Y - (n.time - songTime) * NOTE_SPEED;
      if (y < -20 || y > 380) continue;
      drawRect({ pos: vec2(LEFT + n.lane * LANE_W + 4, y - 9), width: LANE_W - 12, height: 18, radius: 5, color: LANE_COLORS[n.lane] });
    }
    // #endregion

    // #region hud
    drawText({ text: t({ es: "Puntos", en: "Score" }) + `\n${stats.score}`, pos: vec2(20, 20), size: 18, font: FONT });
    drawText({ text: t({ es: "Combo", en: "Combo" }) + `\n${stats.combo}  x${multiplier()}`, pos: vec2(500, 20), size: 18, font: FONT });
    // Count-in: 4, 3, 2, 1 on the metronome beats before the first note.
    if (songTime < 0) drawText({ text: String(Math.ceil(-songTime / BEAT)), pos: vec2(320, 150), size: 48, font: FONT, anchor: "center" });
    if (feedback && feedback.timer > 0) {
      drawRect({ pos: vec2(320, 175), width: 220, height: 64, radius: 10, anchor: "center", color: rgb(10, 8, 24), opacity: 0.75 * Math.min(1, feedback.timer * 3) });
      const { kind, error, timer } = feedback;
      // Showing the error in milliseconds teaches what the windows mean.
      const when = t(error < 0 ? { es: "antes", en: "early" } : { es: "tarde", en: "late" });
      const detail = error === null ? "" : `\n${Math.round(Math.abs(error) * 1000)} ms ${when}`;
      drawText({ text: t(JUDGE_TEXT[kind]) + detail, pos: vec2(320, 170), size: 26, font: FONT, anchor: "center", align: "center", color: JUDGE_COLOR[kind], opacity: Math.min(1, timer * 3) });
    }
    // #endregion
  });
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
  add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
  // Browsers only allow sound after a click or a key press, so we resume it here.
  const startGame = () => (ctx.resume(), next());
  onButtonPress("start", startGame);
  onClick(startGame);
}

scene("title", () =>
  screen(
    t({ es: "Ritmo", en: "Rhythm" }),
    t({
      es: "Pulsa D F J K (o las flechas) cuando cada nota cruce la línea blanca, o toca su carril.\nCuanto más cerca del pulso, más puntos. Espacio o clic para empezar.",
      en: "Press D F J K (or the arrows) as each note crosses the white line, or tap its lane.\nThe closer to the beat, the more points. Space or click to start.",
    }),
    () => go("game"),
  ),
);

scene("end", (stats, total) => {
  const accuracy = Math.round(((stats.perfect + stats.good * 0.5) / total) * 100);
  screen(
    t({ es: `${stats.score} puntos`, en: `${stats.score} points` }),
    t({
      es: `Perfecto ${stats.perfect} · Bien ${stats.good} · Fallo ${stats.miss}\nPrecisión ${accuracy}% · Combo máximo ${stats.maxCombo}\nEspacio o clic para repetir.`,
      en: `Perfect ${stats.perfect} · Good ${stats.good} · Miss ${stats.miss}\nAccuracy ${accuracy}% · Max combo ${stats.maxCombo}\nSpace or click to play again.`,
    }),
    () => go("game"),
  );
});

go("title");
// #endregion

Retos de diseño

  • Sincronizar sonido, imagen y entrada. El audio, la pantalla y el mando tienen cada uno su retraso, así que el juego debe medir el tiempo con el reloj del audio y dejar calibrar el desfase.
  • Escribir charts que se sientan parte de la música, con las notas sobre lo que suena, y graduar la dificultad entre canciones y dentro de cada una.
  • Elegir ventanas de juicio justas. Si son muy estrechas el juego frustra, y si son muy anchas deja de premiar la precisión.
  • Conseguir la música, que hay que componer o licenciar, y que el juego siga siendo jugable para quien no oye bien, con señales visuales claras.

Historia

  1. 1996
    OrigenPaRappa the Rapper

    PaRappa the Rapper (1996) se considera el primer juego de ritmo propiamente dicho: pulsas los botones en el orden en que aparecen. Antes, Dance Aerobics (1987) ya se basaba en el ritmo.

    Rhythm game (Wikipedia)
  2. 1997
    Beatmania

    Beatmania triunfa en los arcades japoneses y da nombre a la división Bemani de Konami.

    Rhythm game (Wikipedia)
  3. 1998
    Dance Dance Revolution

    Dance Dance Revolution lleva el ritmo a los pies, con una alfombra de flechas y juicios de Perfect a Miss, y populariza los juegos musicales que se juegan pisando.

    Dance Dance Revolution (1998 video game) (Wikipedia)
  4. 2005
    Guitar Hero

    Guitar Hero populariza en Occidente el ritmo con mando en forma de guitarra.

    Rhythm game (Wikipedia)
  5. 2007
    osu!

    Dean Herbert publica osu!, un juego gratuito para PC basado en Osu! Tatakae! Ouendan, cuyas canciones crea la propia comunidad.

    osu! (Wikipedia)
  6. 2015
    Crypt of the NecroDancer

    Crypt of the NecroDancer cruza el ritmo con el roguelike, porque solo puedes moverte y atacar al compás de la música.

    Crypt of the NecroDancer (Wikipedia)
  7. 2018
    Beat Saber

    Beat Saber lleva el género a la realidad virtual, donde cortas bloques con dos sables al ritmo de la música, y al salir se vuelve el juego de realidad virtual más vendido y mejor valorado de Steam.

    Rhythm game (Wikipedia)
  8. 2020
    Friday Night Funkin'

    Friday Night Funkin' nace en la game jam Ludum Dare 47 y se publica gratis y jugable en el navegador en itch.io y Newgrounds; además, su código es abierto.

    Friday Night Funkin' (Wikipedia)

Juegos de referencia

1996

PaRappa the Rapper

NanaOn-Sha. PlayStation

Un perro rapero repite, pulsando botones a tiempo, los versos que le canta cada maestro. Cada canción se califica de «Awful» a «Cool», y se considera el primer juego de ritmo propiamente dicho.

1997

Beatmania

Konami. arcade

Haces de DJ con una consola de cinco teclas y un plato giratorio; las notas bajan por columnas y cada una suena al pulsarla. Su éxito dio nombre a Bemani, la división musical de Konami.

1998

Dance Dance Revolution

Konami (Bemani). arcade, PlayStation

Pisas cuatro flechas de una alfombra cuando las flechas que suben por la pantalla llegan a la zona de paso. Cada paso se juzga de «Perfect» a «Miss» y un medidor de baile decide si terminas la canción.

2005

Guitar Hero

Harmonix. PlayStation 2

Tocas canciones de rock con un mando en forma de guitarra, con cinco botones de traste y una barra para rasguear, siguiendo las notas que avanzan por un mástil.

2015

Crypt of the NecroDancer

Brace Yourself Games. Windows, macOS, Linux, PlayStation 4, Nintendo Switch

Un roguelike de mazmorras generadas al azar en el que solo puedes moverte o atacar al compás de la música; perder el pulso te deja expuesto a los monstruos.

2018

Beat Saber

Beat Games. PC (realidad virtual), PlayStation 4, Meta Quest

En realidad virtual, cortas con dos sables de luz los bloques que vienen hacia ti al ritmo de la música, en la dirección que indica cada uno, y esquivas bombas y muros.

2019

A Dance of Fire and Ice

7th Beat Games. navegador, Windows, macOS, Android, iOS

Un juego de ritmo de un solo botón; dos planetas giran uno alrededor del otro y pulsas en el compás exacto para que avancen por un camino lleno de curvas.

Ejercicio

Juega la demo y fíjate en los milisegundos que muestra cada juicio. Cambia en el código la ventana de «perfecto» a 20 y luego a 80 ms y discute cuál se siente más justa. Después cambia el BPM y la velocidad de las notas y explica por qué las notas siguen cayendo a tiempo. Por último, escribe a mano un chart de 4 compases para una melodía que conozcas y reemplaza el generado.

Fuentes