Skip to content
GameGenresEspañol
Action

Rhythm

You press buttons, tap the screen or hit a plastic instrument exactly when the music says so; the tighter your timing with the beat, the more points you score.

Also known as: rhythm game, rhythm action

Defining mechanics

  • Notes that move along lanes or across the screen towards a hit line or zone, placed on the beat of the song. The note chart is the score you read.
  • Judgement by timing accuracy, with windows of a few tens of milliseconds around each note that decide whether a hit was perfect, good or a miss.
  • Combo and multiplier, because chaining hits multiplies the points and a single miss breaks the chain.
  • A performance gauge that rises with hits and drops with misses; if it empties, the song ends early.
  • Very different controllers depending on the game, such as buttons, touch screens, dance mats, plastic instruments or sabres in virtual reality, and several difficulty levels per song.

Gameplay loop

See the incoming note, anticipate the beat, press at the right moment, get the judgement and keep the combo going to the end of the song; then replay it to raise the score.

Our demo

Controls. D, F, J and K (or the four arrow keys), one key per lane, when the note crosses the white line. With the mouse or a finger, tap the lane. Space, Enter or click to start.

Open full screen

A short 16-bar song in which notes fall down four lanes towards a line. Each hit is judged by its accuracy in milliseconds (perfect, good or miss), and hits in a row raise the combo and multiply the points. The music is a metronome and a melody generated with WebAudio, synchronised with the audio clock.

Made with Kaplay, in about 182 lines of JavaScript.

How it's made

  1. Setup and constants

    The game is created with one virtual button per lane, so the same code works with keys or taps. The constants define the song (BPM, bars) and how the notes read. NOTE_SPEED does not change the rhythm, only how early you see each note coming.

    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. The chart

    The chart is a list of notes with their time (in song seconds) and lane. It is built on the beat grid, with one note per beat at first and some off-beat (eighth) notes later, plus a rest at the end of each phrase. A seeded random is used so the song is always the same and can be learned.

    // 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. Sound with WebAudio

    Each sound is an oscillator with a quickly fading volume envelope, with no audio files. The song is a list of timed events, which are a metronome click on every beat (louder on the first of the bar) and one tone per note, with a different pitch per lane. The sound helps, but it is not needed to play.

    // 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. The audio clock

    This is the key to the genre. If the song position were computed by adding up dt() every frame, small errors would pile up (frames do not last the same and the tab may stall), and what you hear would drift away from what you see. ctx.currentTime is the clock the sound card plays by, so the song moves forward exactly as much as that clock did. Sounds are also scheduled slightly ahead (LOOKAHEAD) for an exact audio time, instead of being fired «now» each frame. If the browser does not let the audio play, the game falls back to dt() and stays playable, just with no sound to lock onto.

    // 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. Judgement, combo and points

    When a lane is pressed, the closest pending note is found and the error is measured, which is the press time minus the note time. Up to 45 ms is «perfect», up to 100 ms is «good», and a hit that is far too early uses up the note as a miss. A note that passes the line without a hit is also a miss. The combo counts hits in a row and multiplies the points up to four times, and a miss resets it to zero.

    // 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. Input

    Each virtual button hits its lane. With the mouse or a finger, the lane is worked out from the x coordinate of the tap. Both paths call the same function, so the judgement is identical.

    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. Drawing the notes

    The notes are not objects that move on their own. Every frame they are drawn where the clock says, because their height depends only on how long is left until their time (note time minus song time). That is why they never fall out of sync, even if the game runs at fewer frames per second.

    // 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. Score and on-screen judgement

    The display shows the points, the combo with its multiplier, the countdown over the first four beats and the last judgement with its error in milliseconds, early or late. Seeing that number teaches what the windows mean and whether you tend to rush.

    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. Scenes

    The game has three scenes, title, game and end. Starting resumes the audio, because browsers only allow sound after a click or a key press. The end screen shows the accuracy, the spread of judgements and the highest combo.

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

What we simplified

  • The song is a metronome with one tone per note, with no audio files. A real game loads a recording and synchronises the chart with it.
  • The chart is generated at random on the beat (with a fixed seed, so it is always the same). In a real game a designer writes it by hand while listening to the song.
  • There are no hold notes, no life gauge and no calibration of the offset between audio and screen.
  • The game does not punish pressing when no note is near, and with a finger you can only tap one lane at a time.

To modify it

  • Add an offset constant added to the time of every press, and a screen to calibrate it by tapping along with the metronome.
  • Add hold notes that must be held down until their end, and think about how to judge the release.
  • Add a life gauge that drops with every miss and ends the song if it empties.
  • Write the chart of a well-known melody by hand (a list of times and lanes) and compare it with the generated one.
Full code
// 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

Design challenges

  • Synchronising sound, picture and input. Audio, screen and controller each have their own delay, so the game must keep time with the audio clock and let players calibrate the offset.
  • Writing charts that feel like part of the music, with notes on what is actually playing, and grading the difficulty across songs and within each one.
  • Choosing fair judgement windows. Too narrow and the game frustrates; too wide and it stops rewarding precision.
  • Getting the music, which must be composed or licensed, and keeping the game playable for people who do not hear well, with clear visual cues.

History

  1. 1996
    OriginPaRappa the Rapper

    PaRappa the Rapper (1996) is credited as the first true rhythm game: you press buttons in the order they appear. Earlier, Dance Aerobics (1987) was already rhythm-based.

    Rhythm game (Wikipedia)
  2. 1997
    Beatmania

    Beatmania becomes a hit in Japanese arcades and gives its name to Konami's Bemani division.

    Rhythm game (Wikipedia)
  3. 1998
    Dance Dance Revolution

    Dance Dance Revolution moves rhythm to the feet, with an arrow mat and judgements from Perfect to Miss, and popularises music games played by stepping.

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

    Guitar Hero popularises guitar-controller rhythm games in the West.

    Rhythm game (Wikipedia)
  5. 2007
    osu!

    Dean Herbert releases osu!, a free PC game based on Osu! Tatakae! Ouendan, whose songs are made by the community itself.

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

    Crypt of the NecroDancer crosses rhythm with the roguelike, since you can only move and attack on the beat of the music.

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

    Beat Saber takes the genre into virtual reality, where you slice blocks with two sabres to the beat, and at launch it becomes the best-selling and highest-rated VR game on Steam.

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

    Friday Night Funkin' is born at the Ludum Dare 47 game jam and is released free and playable in the browser on itch.io and Newgrounds; its code is also open source.

    Friday Night Funkin' (Wikipedia)

Reference games

1996

PaRappa the Rapper

NanaOn-Sha. PlayStation

A rapping dog repeats, by pressing buttons in time, the lines each teacher raps at him. Each song is rated from «Awful» to «Cool», and it is regarded as the first true rhythm game.

1997

Beatmania

Konami. arcade

You play DJ on a five-key console with a turntable; notes fall down columns and each one sounds when you hit it. Its success gave its name to Bemani, Konami's music division.

1998

Dance Dance Revolution

Konami (Bemani). arcade, PlayStation

You step on four arrows on a mat when the arrows rising up the screen reach the step zone. Each step is judged from «Perfect» to «Miss», and a dance gauge decides whether you finish the song.

2005

Guitar Hero

Harmonix. PlayStation 2

You play rock songs with a guitar-shaped controller, with five fret buttons and a strum bar, following the notes that scroll along a fretboard.

2015

Crypt of the NecroDancer

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

A roguelike with randomly generated dungeons in which you can only move or attack on the beat of the music; losing the beat leaves you exposed to monsters.

2018

Beat Saber

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

In virtual reality, you slice the blocks that come at you in time with the music with two light sabres, in the direction each one shows, while dodging bombs and walls.

2019

A Dance of Fire and Ice

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

A one-button rhythm game; two planets orbit each other and you press on the exact beat to move them along a winding path.

Exercise

Play the demo and watch the milliseconds shown with each judgement. In the code, change the «perfect» window to 20 and then to 80 ms and discuss which feels fairer. Then change the BPM and the note speed and explain why the notes still fall on time. Finally, write a 4-bar chart by hand for a melody you know and replace the generated one.

Sources