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.
g.timeis now as heard: the clock minus the audio output latency. Gameplay runs on it, so what you see matches what you hear.g.beatandg.bargive the position in beats and bars.- Headless (no audio and no canvas),
g.step(dt)advances the clock. Playtests, tests and bots use this. Passheadless: trueto drive a track clock the same way.
Music
g.music(layers) sets the score. Each layer is one of these:
- a pattern or mini-notation string:
"x*4" - a function of state returning a pattern, or
falsefor silence:(s) => s.thrust ? "0 ~ 0 4" : false - an object:
{ pattern, voice, gain, octave, note, length, map }
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.
- The shorter screen side is always
worldSizeunits (1000 by default), so a level plays the same on a phone and a monitor. g.timeScaleslows or speeds every scaled entity, but never the player.- Entities that start off-screen fly in, and start wrapping once they are fully visible.
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.