mattalcock.com/labs

Labs · Library

Jump

A game library where the game and the music share one clock.

In a normal engine the frame is the unit of time. In Jump the beat is. Physics and drawing still run every frame, but anything the world decides to do (spawning, firing, waves, the soundtrack) is written in musical time.

One rule holds it together:

The player's actions are instant. The world's actions land on the beat.

A complete game in one script

This is a cut-down Asteroids. The same code is in examples/mini-asteroids.ts, and a test runs it headless.

import { jump } from "@chop-jump/jump";

const g = jump({ canvas: document.querySelector("canvas")!, audio: new AudioContext(), bpm: 120, key: "c:minor" });
g.set({ danger: 0, lives: 3, thrust: false });

// Entities: plain objects with position, velocity and a radius.
g.entity("ship", {
  radius: 12,
  scaled: false, // the player is never slowed down
  update: (e, dt, g) => {
    if (g.input.down("left")) e.angle -= 4 * dt;
    if (g.input.down("right")) e.angle += 4 * dt;
    if (g.input.pressed("fire")) {
      g.spawn("bullet", { x: e.x, y: e.y, vx: Math.cos(e.angle) * 700, vy: Math.sin(e.angle) * 700, life: 0.8 });
      g.hit("shot"); // instant bullet, quantised sound
    }
  },
  draw: (e, gfx) => gfx.poly([[16, 0], [-10, -9], [-10, 9]], e.x, e.y, e.angle),
});
g.entity("bullet", {
  radius: 3,
  scaled: false,
  hits: ["rock"],
  update: (e, dt, g) => {
    e.life = (e.life as number) - dt;
    if ((e.life as number) <= 0) g.kill(e);
  },
  onHit: (b, rock, g) => {
    g.kill(b);
    g.kill(rock);
    g.hit("rock", { size: rock.size });
  },
  draw: (e, gfx) => gfx.dot(e.x, e.y),
});
g.entity("rock", {
  radius: 40,
  init: (e, g) => {
    e.x = 0; // enter from the left edge
    e.y = g.rng() * g.bounds.h;
    e.vx = 40 + g.rng() * 80;
    e.vy = (g.rng() - 0.5) * 160;
  },
  draw: (e, gfx) => gfx.circle(e.x, e.y, e.r),
});
g.spawn("ship", { angle: -Math.PI / 2 });

// What the hits sound like, and which grid each one lands on.
g.sound("shot", () => ({ voice: "blip", note: 72 }));
g.sound("rock", (d) => ({ voice: "boom", size: d.size === "L" ? 1 : 0.5 }));
g.quantise({ shot: "16n", rock: "8n" });

// Gameplay as patterns: rocks enter on the beat, a new wave every four bars.
g.every("4m", () => g.wave("rock:L ~ rock:L rock:L"));

// Music that reads the same state the world writes.
g.music({
  pulse: { pattern: "0 ~ 4 ~", octave: 1 },
  hats: (s) => ((s.danger as number) > 0.3 ? "[~ x]*4" : false),
  chord: (s) => (s.lives === 1 ? "c:minor" : "c:major"),
});

g.onUpdate(() => {
  const ship = g.all("ship")[0];
  if (!ship) return;
  const nearest = Math.min(Infinity, ...g.all("rock").map((r) => Math.hypot(r.x - ship.x, r.y - ship.y)));
  g.set({ danger: nearest < 150 ? 1 : nearest < 300 ? 0.5 : 0 });
});
g.on("beat", (b, g) => g.pulse(b.beatInBar === 0 ? 1 : 0.5)); // lines thicken on the beat

g.start();

The clock

jump({ bpm }) makes a steady tempo clock on the AudioContext. jump({ clock }) follows any Clock, such as Chop's track clock, so the game runs on a record.

Music

g.music(layers) sets the score. Each layer is one of these:

Layers are only evaluated when the scheduler books their next steps, about 120 ms ahead. A state change lands on the next step that hasn't been booked. You never have to cut anything.

Step values mean:

Step Plays
"x" the layer's voice, at its default note
"c2", "eb3", "f#4" that note
"0", "2", "-1" a degree of the game's key (g.key), at the layer's octave
"c:minor", "a:major" that triad
"chord" the tonic triad of the current key

The voice defaults to the layer name, singular, so hats plays hat, kicks plays kick and chords plays pad. For anything else, map(value, hap, state) returns the sound event yourself.

Hits

g.sound(name, def) defines a one-off sound. g.quantise({ name: grid }) sets its grid, and g.hit(name, data) plays it on the next line of that grid. A hit that arrives up to 30 ms late counts as on the line it was aimed at and plays straight away, rather than waiting for the next one. g.hit returns the exact time it will be heard. g.play(event) skips quantisation.

Voices

The built-in voices are stark synth patches made of square waves, triangle waves and noise, to suit a black-and-white screen: kick, snare, hat, click, blip, zap, boom, pulse, bass, lead, pad and riser. Add your own with sink.define(name, voice). A voice receives the AudioContext, an output node and the event.

Sound goes through a sink. In the browser that is a WebAudioSink. In tests it is a RecordingSink, so you can assert exactly what would have played and when.

Time

Call Does
g.at(time, fn) run fn(time, late) on the first frame at or after time
g.atBar(bar, fn) the same, at a bar number
g.every(grid, fn) run on every grid line; returns a stop function
g.wave(pattern, { lead, bars, from }) spawn from a pattern; "rock:L" spawns a rock with size: "L" and arrive set to its step's time
g.on("16n" | "8n" | "beat" | "bar", fn) grid events, with { time, beat, bar, beatInBar }

late tells a callback how far behind its frame is. Use it to place things exactly. Asteroids moves a rock that spawned 9 ms late 9 ms along its path, which is how rocks land within a frame of their beat.

World

g.entity(type, def) defines a type with radius, wrap, scaled, hits, and the hooks init, update, draw, onHit and onDeath. g.spawn(type, props) creates one, and g.kill, g.all(type) and g.count(type) manage them.

Input

g.input.down(action), g.input.pressed(action) (first frame only), and g.input.stick for touch. Default keys: ← → or A D to turn, ↑ or W to thrust, space, Enter, J or K to fire, shift, H, ↓ or S for hyperspace, and escape or P for pause. On touch screens, the left half of the screen is a stick and the right half is a fire button. Bots drive the same actions with press and release.

Set g.input.enabled = false while a menu is showing. The game then ignores keys and touches and leaves them alone, so buttons work normally. Keys aimed at buttons, links and form fields are always left alone.

Feel

g.flash(amount), g.shake(amount) and g.pulse(amount) all decay on their own. Pulse thickens every line for a moment, and on("beat", ...) is the usual place to call it.