Skip to content
SciStack
Minigame Python Beginner 25 min

Metronome synchronization: why five on a rolling board fall into step

Afterwards you can explain why coupled metronomes lock in step or in opposition, measure it with the Kuramoto order parameter, and find the coupling threshold.

Field
Biology, Physics
Prerequisites
none beyond Python basics
Libraries
matplotlib 3.11.2numpy 2.4.3scipy 1.18.1
Download notebook Save Mark as done

py-metronome-sync.ipynb, executed with the versions above. The download needs a free account

Run it yourself. In a terminal, this installs exactly the versions above:

pip install numpy==2.4.3 scipy==1.18.1 matplotlib==3.11.2 jupyterlab

Play: five metronomes on a board on two cans

Five metronomes, set to 207, 207.5, 208, 208.5, and 209 ticks a minute, within 1 % of one another, stand on a 250 g board that rests on two soda cans. Started at random, they fall into synchronization within 40 s: they tick in step, and the board rocks under them. Christiaan Huygens saw the same kind of thing in 1665, with two pendulum clocks hanging from one beam. When the page opens the five swing out of step and the board is clamped (Fixed). Press On cans at once to free the board, and watch the pendulums, the board, the dots on the ring in the corner, and the readout r: it starts at 0.30 and stays above 0.9 after 23 s. Then make the board heavier with the slider, press Damped, or press the button 2 for a pair. Tap a metronome to stop it and tap it again to let it go from 34°, so you can give each any start you like. On a keyboard, B switches the board, N the number of metronomes, and the keys 1 to 5 stop and start them.

Download the game

Show code
// game.js: five metronomes on a board that rests on two soda cans. Tap a metronome to stop or start it,
// free the board, make it heavier, or damp it, and watch whether the pendulums fall into step.
// Units: meters, kilograms, seconds, radians. The notebook writes every parameter into #data.
"use strict";

const DATA = JSON.parse(document.getElementById("data").textContent);
const {omega0: W0, l: L, mu: MU, theta0: TH0, amp: AMP, dt: DT, draw_scale: SCALE, r_lock: R_LOCK} = DATA;
const MASSES = DATA.board_masses;
const N = DATA.rates.length;                           // the group: five, unless the data says otherwise
const BOARD_LEN = 0.62, CAN_R = 0.033;                 // the drawing's board and cans, in meters

// ---- the state: everything that changes, in one object
const state = {n: N, board: "fixed", boardMass: 0.25, rates: [], theta: [], omega: [], on: [], x: 0, v: 0,
  t: 0, sound: false, running: true, vPeak: 0, tFree: 0, tAbove: null};

const angular = (rate) => 2 * Math.PI * (rate / 120) / DATA.rho;    // a tick is half a swing
const beta = () => DATA.m_bob / (state.boardMass + state.n * DATA.m_metronome);
const gamma = () => state.board === "damped" ? DATA.gamma_damped : 0;

// ---- the equations of the notebook: the board's acceleration first, then each pendulum's
function accelerations(s, theta, omega, v) {
  const w = s.rates.map(angular), b = beta();
  const f = theta.map((th, i) => s.on[i]
    ? -w[i] * w[i] * Math.sin(th) - MU * W0 * ((th / TH0) ** 2 - 1) * omega[i] : 0);   // own swing and escapement
  let a = 0;
  if (s.board !== "fixed") {
    let push = 0, inertia = 0;
    theta.forEach((th, i) => {
      if (!s.on[i]) return;
      push += Math.sin(th) * omega[i] ** 2 - Math.cos(th) * f[i];
      inertia += Math.cos(th) ** 2;
    });
    a = (b * L * push - gamma() * W0 * v) / (1 - b * inertia);
  }
  return {alpha: theta.map((th, i) => s.on[i] ? f[i] - (a / L) * Math.cos(th) : 0), a};
}

// one Runge-Kutta step: four slopes, averaged
function step(s, h) {
  const n = s.theta.length;
  const slope = (th, om, v) => { const {alpha, a} = accelerations(s, th, om, v); return {dth: om, dom: alpha, dx: v, dv: a}; };
  const shift = (k, c) => [s.theta.map((x, i) => x + c * k.dth[i]), s.omega.map((x, i) => x + c * k.dom[i]), s.v + c * k.dv];
  const k1 = slope(s.theta, s.omega, s.v);
  const k2 = slope(...shift(k1, h / 2));
  const k3 = slope(...shift(k2, h / 2));
  const k4 = slope(...shift(k3, h));
  const mix = (key, i) => (k1[key][i] + 2 * k2[key][i] + 2 * k3[key][i] + k4[key][i]) / 6;
  const before = s.theta.slice();
  for (let i = 0; i < n; i++) { s.theta[i] += h * mix("dth", i); s.omega[i] += h * mix("dom", i); }
  s.x += h * (k1.dx + 2 * k2.dx + 2 * k3.dx + k4.dx) / 6;
  s.v += h * (k1.dv + 2 * k2.dv + 2 * k3.dv + k4.dv) / 6;
  s.t += h;
  before.forEach((th, i) => { if (s.on[i] && th * s.theta[i] < 0) tick(); });   // a tick at every pass through the middle
}

// ---- phases and the order parameter r, over the swinging metronomes
const phase = (theta, omega) => Math.atan2(-omega / W0, theta);
function order(s) {
  let c = 0, sn = 0, k = 0;
  s.theta.forEach((th, i) => { if (s.on[i]) { const p = phase(th, s.omega[i]); c += Math.cos(p); sn += Math.sin(p); k++; } });
  return k ? {r: Math.hypot(c, sn) / k, psi: Math.atan2(sn, c)} : {r: 0, psi: 0};
}

// The board starts without a push: total momentum zero, so it rocks in place instead of drifting off.
function settleBoard() {
  let sx = 0, sv = 0;
  if (state.board !== "fixed") state.theta.forEach((th, i) => { sx += Math.sin(th); sv += Math.cos(th) * state.omega[i]; });
  Object.assign(state, {x: -beta() * L * sx, v: -beta() * L * sv, tFree: state.t, tAbove: null, vPeak: 0});
}

function start(phases) {
  const n = phases.length;
  Object.assign(state, {rates: n === N ? DATA.rates.slice() : DATA.rates2.slice(), on: Array(n).fill(true),
    theta: phases.map((p) => AMP * Math.cos(p)), omega: phases.map((p) => -AMP * W0 * Math.sin(p)), t: 0});
  settleBoard();
}

// ---- sound: a 3 ms click through the Web Audio API, allowed only after a tap
let audio = null, click = null;
function wakeAudio() {
  if (audio) return;
  audio = new (window.AudioContext || window.webkitAudioContext)();
  click = audio.createBuffer(1, Math.round(audio.sampleRate * 0.003), audio.sampleRate);
  click.getChannelData(0).forEach((_, i, d) => { d[i] = (Math.random() * 2 - 1) * (1 - i / d.length); });
  state.sound = true;
}
function tick() {
  if (!state.sound || !audio) return;
  const src = audio.createBufferSource(), gain = audio.createGain();
  gain.gain.value = 0.5;
  src.buffer = click;
  src.connect(gain).connect(audio.destination);
  src.start();
}

// ---- drawing
const $ = (id) => document.getElementById(id);
const canvas = $("scene");
let dpr = 1;

function layout() {
  dpr = window.devicePixelRatio || 1;
  const box = canvas.getBoundingClientRect();
  canvas.width = Math.round(box.width * dpr);
  canvas.height = Math.round(box.height * dpr);
  render();
}

function colors() {
  const css = getComputedStyle(document.documentElement);
  return Object.fromEntries(["bg", "fg", "ink", "accent", "second", "muted", "pale"].map((k) => [k, css.getPropertyValue(`--${k}`).trim()]));
}

const thickness = (mass) => 0.006 * Math.sqrt(mass / 0.25);      // a shelf at 0.25 kg, a plank at 5 kg
const pixelsPerMeter = (W, H) => Math.min(W / 0.74, H / 0.5);   // the scene, 0.74 m wide, fits the frame
const spots = (n) => n === 2 ? [-BOARD_LEN / 6, BOARD_LEN / 6] : Array.from({length: n}, (_, i) => -0.24 + 0.48 * i / (n - 1));

function drawScene(c) {
  const ctx = canvas.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  const W = canvas.width / dpr, H = canvas.height / dpr;
  ctx.fillStyle = c.bg;
  ctx.fillRect(0, 0, W, H);
  const k = pixelsPerMeter(W, H);
  const floor = Math.min(H - 22, H / 2 + 0.3 * k);       // the scene low in a wide frame, centered in a tall one
  const X = (x) => W / 2 + x * k, Y = (y) => floor - y * k;
  const shift = SCALE * state.x, T = thickness(state.boardMass), top = 2 * CAN_R + T;

  ctx.lineWidth = 1.5;
  ctx.strokeStyle = c.muted;                             // floor, mat, cans, clamps
  ctx.beginPath(); ctx.moveTo(0, Y(0)); ctx.lineTo(W, Y(0)); ctx.stroke();
  if (state.board === "damped") {
    ctx.beginPath();
    for (let x = -0.33; x < 0.33; x += 0.012) { ctx.moveTo(X(x), Y(0)); ctx.lineTo(X(x + 0.012), Y(0.012)); }
    ctx.stroke();
  }
  const lift = state.board === "damped" ? 0.012 : 0;
  for (const cx of [-0.2, 0.2]) {                        // a can rolls half as far as the board on it
    ctx.beginPath(); ctx.arc(X(cx + shift / 2), Y(CAN_R + lift), CAN_R * k, 0, 2 * Math.PI); ctx.stroke();
  }
  if (state.board === "fixed") {
    for (const sgn of [-1, 1]) {
      const x0 = sgn * (BOARD_LEN / 2 + 0.006);
      ctx.beginPath(); ctx.moveTo(X(x0), Y(0)); ctx.lineTo(X(x0), Y(top + lift + 0.01));
      ctx.lineTo(X(x0 - sgn * 0.02), Y(top + lift + 0.01)); ctx.stroke();
    }
  }
  ctx.fillStyle = c.pale;                                // the board
  ctx.strokeStyle = c.ink;
  ctx.beginPath();
  ctx.rect(X(shift - BOARD_LEN / 2), Y(top + lift), BOARD_LEN * k, T * k);
  ctx.fill(); ctx.stroke();

  ctx.font = `${Math.round(Math.min(22, Math.max(14, 0.022 * k)))}px system-ui, sans-serif`;   // grows with the scene
  ctx.textAlign = "center";
  ctx.textBaseline = "bottom";
  spots(state.n).forEach((sx, i) => {                    // the metronomes: box, rod, weight, rate
    const x = sx + shift, y = top + lift, on = state.on[i];
    ctx.strokeStyle = on ? c.ink : c.muted;
    ctx.beginPath();
    ctx.moveTo(X(x - 0.045), Y(y)); ctx.lineTo(X(x - 0.022), Y(y + 0.17));
    ctx.lineTo(X(x + 0.022), Y(y + 0.17)); ctx.lineTo(X(x + 0.045), Y(y)); ctx.closePath();
    ctx.stroke();
    const th = state.theta[i], px = X(x), py = Y(y + 0.035);
    ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px + 0.19 * k * Math.sin(th), py - 0.19 * k * Math.cos(th)); ctx.stroke();
    ctx.fillStyle = on ? c.ink : c.muted;
    ctx.beginPath(); ctx.arc(px + 0.12 * k * Math.sin(th), py - 0.12 * k * Math.cos(th), 0.011 * k, 0, 2 * Math.PI); ctx.fill();
    ctx.lineWidth = 1.5;
    ctx.fillText(String(state.rates[i]), X(x), Y(y) - 2);
  });

  // the phase ring: one dot per swinging metronome, the mean arrow of length r
  const R = Math.max(18, Math.min((floor - 0.33 * k - 14) / 2, 0.11 * W, 64)), cx = 10 + R, cy = 10 + R;   // above the pendulums
  ctx.strokeStyle = c.muted;
  ctx.beginPath(); ctx.arc(cx, cy, R, 0, 2 * Math.PI); ctx.stroke();
  ctx.fillStyle = c.ink;
  state.theta.forEach((th, i) => {
    if (!state.on[i]) return;
    const p = phase(th, state.omega[i]);
    ctx.beginPath(); ctx.arc(cx + R * Math.cos(p), cy - R * Math.sin(p), Math.max(4, R / 10), 0, 2 * Math.PI); ctx.fill();
  });
  const {r, psi} = order(state);
  ctx.strokeStyle = c.accent;
  ctx.lineWidth = 3;
  ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + r * R * Math.cos(psi), cy - r * R * Math.sin(psi)); ctx.stroke();
  ctx.fillStyle = c.muted;
  ctx.font = "14px system-ui, sans-serif";
  ctx.textAlign = "right";
  ctx.textBaseline = "top";
  ctx.fillText(`board motion ×${SCALE}`, W - 6, Y(0) + 4);     // under the floor, out of the readouts' corner
}

function render() {
  const c = colors();
  drawScene(c);
  const {r} = order(state);
  $("r").textContent = `r ${r.toFixed(2)}`;
  $("speed").textContent = `board ${(100 * state.vPeak).toFixed(1)} cm/s`;
  $("time").textContent = `t ${Math.floor(state.t)} s`;
  const locked = state.board !== "fixed" && state.tAbove !== null && state.t - state.tAbove >= 5;
  $("status").textContent = locked ? `in step after ${Math.round(state.tAbove - state.tFree)} s` : "";
  for (const b of ["fixed", "cans", "damped"]) $(b).setAttribute("aria-pressed", state.board === b);
  $("n2").setAttribute("aria-pressed", state.n === 2);
  $("n5").setAttribute("aria-pressed", state.n === N);
  const idx = MASSES.indexOf(state.boardMass);
  $("mass").value = idx;
  $("mass-val").textContent = `${state.boardMass} kg`;
  for (const id of ["mass", "lighter", "heavier"]) $(id).disabled = state.board === "fixed";
  $("play").textContent = state.running ? "Pause" : "Play";
  $("sound").textContent = state.sound ? "Sound on" : "Sound off";
}

// ---- controls, keys, and the loop
function set(partial) {
  const n = partial.n !== undefined && partial.n !== state.n;
  Object.assign(state, partial);
  if (n) start(state.n === N ? DATA.start : DATA.start2);
  else if ("board" in partial || "boardMass" in partial) settleBoard();
  render();
}

function toggle(i) {                                     // a swinging metronome stops at the bottom; a stopped one is let go at 34°
  if (i >= state.n) return;
  state.on[i] = !state.on[i];
  state.theta[i] = state.on[i] ? AMP : 0;
  state.omega[i] = 0;
  settleBoard();
  render();
}

let seed = 382;
function restart() {                                     // new random phases from a small seeded generator
  const rand = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
  start(Array.from({length: state.n}, () => 2 * Math.PI * rand()));
  render();
}

const nudge = (d) => { const i = Math.min(MASSES.length - 1, Math.max(0, MASSES.indexOf(state.boardMass) + d)); set({boardMass: MASSES[i]}); };
const cycle = () => set({board: {fixed: "cans", cans: "damped", damped: "fixed"}[state.board]});

for (const b of ["fixed", "cans", "damped"]) $(b).addEventListener("click", () => set({board: b}));
$("n2").addEventListener("click", () => set({n: 2}));
$("n5").addEventListener("click", () => set({n: N}));
$("n5").textContent = N;                                 // the group's button names its size
$("mass").addEventListener("input", (e) => set({boardMass: MASSES[Number(e.target.value)]}));
$("lighter").addEventListener("click", () => nudge(-1));
$("heavier").addEventListener("click", () => nudge(1));
$("play").addEventListener("click", () => set({running: !state.running}));
$("restart").addEventListener("click", restart);
$("sound").addEventListener("click", () => { if (audio) set({sound: !state.sound}); else { wakeAudio(); render(); } });
document.addEventListener("pointerdown", (e) => { if (e.target !== $("sound")) wakeAudio(); });
canvas.addEventListener("pointerdown", (e) => {          // which metronome: the nearest column to the touch
  const box = canvas.getBoundingClientRect(), W = box.width, k = pixelsPerMeter(W, box.height);
  const x = (e.clientX - box.left - W / 2) / k - SCALE * state.x;
  const d = spots(state.n).map((sx) => Math.abs(sx - x)), i = d.indexOf(Math.min(...d));
  if (d[i] < 0.06) toggle(i);
});
document.addEventListener("keydown", (e) => {
  wakeAudio();
  const key = e.key.toLowerCase();
  const actions = {" ": () => set({running: !state.running}), b: cycle, "[": () => nudge(-1), "]": () => nudge(1),
    n: () => set({n: state.n === N ? 2 : N}), r: restart, s: () => set({sound: !state.sound})};
  if (/^[1-5]$/.test(key)) toggle(Number(key) - 1);
  else if (actions[key] && !(e.target instanceof HTMLButtonElement && key === " ")) { e.preventDefault(); actions[key](); }
});

let last = null, owed = 0, window_ = 0, vMax = 0;
function frame(now) {                                    // called by the browser once per screen refresh
  const elapsed = last === null ? 0 : Math.min((now - last) / 1000, 0.05);   // capped: a background tab does not jump
  last = now;
  if (state.running) {
    for (owed += elapsed; owed >= DT; owed -= DT) {    // fixed steps of DT; the remainder waits for the next frame
      step(state, DT);
      vMax = Math.max(vMax, Math.abs(state.v));
    }
    window_ += elapsed;
    if (window_ >= 0.6) { state.vPeak = vMax; vMax = 0; window_ = 0; }        // the peak speed over about one swing
    const {r} = order(state);
    if (r < R_LOCK) state.tAbove = null;
    else if (state.tAbove === null) state.tAbove = state.t;
  }
  render();
  requestAnimationFrame(frame);
}

start(DATA.start);
window.addEventListener("resize", layout);
layout();
requestAnimationFrame(frame);
window.game = {state, set, render};

The metronomes fall into step

On cans the board starts to twitch at once. By 30 s the five swing as one, the board rocks 2 mm each way, and the clicks merge.

Show code
import json
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp

plt.rcParams.update({
    "figure.figsize": (7, 3.6), "figure.dpi": 110,
    "axes.spines.top": False, "axes.spines.right": False,
    "axes.grid": True, "grid.alpha": 0.25,
    "font.size": 11, "lines.linewidth": 1.8,
})
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"

G = 9.81                                        # m/s²
RATES = np.array([207, 207.5, 208, 208.5, 209])  # ticks a minute; a tick is half a swing
RATES2 = np.array([208, 208])                   # the pair
MU, THETA0, AMP = 0.05, 0.3, 0.6                # escapement strength, its turning swing / rad, start swing / rad
M_BOB, M_METRONOME = 0.015, 0.25                # kg
BOARD_MASSES = [0.25, 0.5, 1, 2, 3, 4, 5]       # kg, the game's slider
GAMMA_DAMPED = 2.0                              # board damping c / (M ω₀)
DT_GAME, DRAW_SCALE, R_LOCK = 0.004, 10, 0.9    # the game's time step / s, board drawn ×10, r that counts as in step

rng = np.random.default_rng(382)
START = rng.uniform(0, 2 * np.pi, 5)            # the game's first start
STARTS = rng.uniform(0, 2 * np.pi, (20, 5))     # twenty more, for lock times
START2 = np.radians([0, 90])                    # the pair's start in the game

def beta(n, board_mass):
    """The coupling: one bob's mass over everything the board carries."""
    return M_BOB / (board_mass + n * M_METRONOME)

def accelerations(theta, omega, v, w, b, g, isochronous=False):
    """Angular accelerations (runs, n) and board accelerations (runs,), the board's first."""
    restore = theta if isochronous else np.sin(theta)
    f = -w**2 * restore - MU * W0 * ((theta / THETA0) ** 2 - 1) * omega       # own swing and escapement
    c, s = np.cos(theta), np.sin(theta)
    a = (b * L * np.sum(s * omega**2 - c * f, axis=-1) - g * W0 * v) / (1 - b * np.sum(c**2, axis=-1))
    return f - (a / L)[:, None] * c, a

def simulate(phi0, board="cans", board_mass=0.25, seconds=60, rates=None, coupling=None, gamma=None,
             isochronous=False, every=0.05):
    """Integrate any number of starts at once. phi0: start phases, (n,) or (runs, n).
    Returns t (T,), theta and omega (runs, n, T), board position x and speed v (runs, T)."""
    phi0 = np.atleast_2d(phi0)
    runs, n = phi0.shape
    rates = (RATES if n == 5 else RATES2) if rates is None else np.asarray(rates)
    w = 2 * np.pi * (rates / 120) / RHO
    b = 0.0 if board == "fixed" else beta(n, board_mass) if coupling is None else coupling
    b = np.broadcast_to(b, (runs,)).astype(float)
    g = np.broadcast_to((GAMMA_DAMPED if board == "damped" else 0.0) if gamma is None else gamma, (runs,))
    theta, omega = AMP * np.cos(phi0), -AMP * W0 * np.sin(phi0)
    x0 = -b * L * np.sin(theta).sum(axis=1)                    # no push at the start: zero total momentum
    v0 = -b * L * (np.cos(theta) * omega).sum(axis=1)

    def rhs(t, y):
        Y = y.reshape(runs, 2 * n + 2)
        alpha, a = accelerations(Y[:, :n], Y[:, n:2 * n], Y[:, -1], w, b, g, isochronous)
        return np.concatenate([Y[:, n:2 * n], alpha, Y[:, -1:], a[:, None]], axis=1).ravel()

    y0 = np.concatenate([theta, omega, x0[:, None], v0[:, None]], axis=1).ravel()
    t = np.arange(0, seconds + every / 2, every)
    sol = solve_ivp(rhs, (0, seconds), y0, method="DOP853", rtol=1e-8, atol=1e-10, t_eval=t)
    Y = sol.y.reshape(runs, 2 * n + 2, -1)
    return sol.t, Y[:, :n], Y[:, n:2 * n], Y[:, 2 * n], Y[:, 2 * n + 1]

def phases(theta, omega):
    """Where each metronome is in its swing, as an angle: the angle of the point (θ, −θ̇/ω₀)."""
    return np.arctan2(-omega / W0, theta)

def order_parameter(phi):
    """r: the length of the mean of the unit arrows at the phases, over the metronomes (axis −2)."""
    return np.abs(np.exp(1j * phi).mean(axis=-2))

def lock_time(t, phi, window=30):
    """Per run: the time after which r stays above 0.9, if the mean rates over the last window
    agree to 0.1 %; NaN if the run never locks."""
    r, late = order_parameter(phi), t >= t[-1] - window
    turns = np.unwrap(phi[..., late], axis=-1)
    rate = (turns[..., -1] - turns[..., 0]) / (t[late][-1] - t[late][0])
    agree = np.ptp(rate, axis=-1) / np.abs(rate).mean(axis=-1) < 1e-3
    below = [np.flatnonzero(rk < R_LOCK) for rk in r]
    return np.array([np.nan if not ok or (len(bk) and bk[-1] + 1 == len(t)) else t[bk[-1] + 1] if len(bk) else 0.0
                     for bk, ok in zip(below, agree)])

# ω₀: a lone 208 metronome on a fixed board must tick 208 times a minute at its own swing
def ticks_per_minute(theta, t):
    up = np.flatnonzero((theta[:-1] < 0) & (theta[1:] >= 0) & (t[:-1] > 10))
    tc = t[up] - theta[up] / (theta[up + 1] - theta[up]) * (t[1] - t[0])
    return 120 * (len(tc) - 1) / (tc[-1] - tc[0])

RHO, W0 = 1.0, 2 * np.pi * 208 / 120
L = G / W0**2
t, theta, *_ = simulate([0.0], board="fixed", rates=[208], seconds=40, every=0.001)
RHO = ticks_per_minute(theta[0, 0], t) / 208          # how much slower the 34° swing is than a small one
W0 = 2 * np.pi * (208 / 120) / RHO
L = G / W0**2
t, theta, *_ = simulate([0.0], board="fixed", rates=[208], seconds=40, every=0.001)
print(f"swing {np.degrees(np.abs(theta[0, 0, t > 20]).max()):.1f}°, {(1 / RHO - 1):.1%} slower than a small one; "
      f"a lone 208 metronome ticks {ticks_per_minute(theta[0, 0], t):.1f} a minute")
print(f"ω₀ = {W0:.2f} rad/s, l = {100 * L:.1f} cm, β = {beta(5, 0.25):.4f} for five and {beta(2, 0.25):.4f} for two on 0.25 kg")

# ---- the game's scene in Matplotlib, in meters, the board drawn ×10
BOARD_LEN, CAN_R = 0.62, 0.033

def draw_scene(ax, theta, x, board="cans", board_mass=0.25, phi=None, label=None):
    n = len(theta)
    T, lift = 0.006 * np.sqrt(board_mass / 0.25), 0.012 if board == "damped" else 0.0
    top, shift = 2 * CAN_R + T + lift, DRAW_SCALE * x
    ax.set_axis_off()
    ax.set(xlim=(-0.37, 0.37), ylim=(-0.02, 0.48), aspect="equal")
    ax.axhline(0, color=MUTED, lw=1)
    if board == "damped":
        for xm in np.arange(-0.33, 0.33, 0.012):
            ax.plot([xm, xm + 0.012], [0, 0.012], color=MUTED, lw=0.8)
    for cx in [-0.2, 0.2]:
        ax.add_patch(plt.Circle((cx + shift / 2, CAN_R + lift), CAN_R, fill=False, color=MUTED, lw=1))
    if board == "fixed":
        for sgn in [-1, 1]:
            x0 = sgn * (BOARD_LEN / 2 + 0.006)
            ax.plot([x0, x0, x0 - sgn * 0.02], [0, top + 0.01, top + 0.01], color=MUTED, lw=1)
    ax.add_patch(plt.Rectangle((shift - BOARD_LEN / 2, top - T), BOARD_LEN, T, facecolor=MUTED, alpha=0.25, lw=0))
    ax.add_patch(plt.Rectangle((shift - BOARD_LEN / 2, top - T), BOARD_LEN, T, fill=False, edgecolor=INK, lw=1))
    spots = [-0.24, -0.12, 0, 0.12, 0.24] if n == 5 else [-BOARD_LEN / 6, BOARD_LEN / 6]
    for sx, th in zip(spots, theta):
        xs = sx + shift
        ax.add_patch(plt.Polygon([(xs - 0.045, top), (xs - 0.022, top + 0.17), (xs + 0.022, top + 0.17),
                                  (xs + 0.045, top)], fill=False, edgecolor=INK, lw=1))
        py = top + 0.035
        ax.plot([xs, xs + 0.19 * np.sin(th)], [py, py + 0.19 * np.cos(th)], color=INK, lw=1.5)
        ax.add_patch(plt.Circle((xs + 0.12 * np.sin(th), py + 0.12 * np.cos(th)), 0.011, color=INK))
    if phi is not None:
        draw_ring(ax, phi, (-0.30, 0.41), 0.06)
    if label:
        ax.text(0.37, 0.47, label, ha="right", va="top", color=MUTED)

def draw_ring(ax, phi, center, R):
    """The phase ring: a dot per metronome, the mean arrow of length r."""
    cx, cy = center
    ax.add_patch(plt.Circle(center, R, fill=False, color=MUTED, lw=1))
    ax.plot(cx + R * np.cos(phi), cy + R * np.sin(phi), "o", ms=4, color=INK)
    z = np.exp(1j * phi).mean()
    ax.plot([cx, cx + R * z.real], [cy, cy + R * z.imag], color=ACCENT, lw=2)
swing 34.4°, 2.3% slower than a small one; a lone 208 metronome ticks 208.0 a minute
ω₀ = 11.14 rad/s, l = 7.9 cm, β = 0.0100 for five and 0.0200 for two on 0.25 kg
Show code
t1, theta1, omega1, x1, v1 = simulate(START, seconds=60)
phi1 = phases(theta1, omega1)
r1 = order_parameter(phi1)[0]
lock1 = lock_time(t1, phi1)[0]
fig = plt.figure(figsize=(7, 4.6))
grid = fig.add_gridspec(2, 3, height_ratios=[1, 1.75], hspace=0.08, wspace=0.04, left=0.09, right=0.99, top=0.99)
for j, ts in enumerate([0, 5, 30]):
    k = np.searchsorted(t1, ts)
    draw_scene(fig.add_subplot(grid[0, j]), theta1[0, :, k], x1[0, k], phi=phi1[0, :, k], label=f"{ts} s")
ax = fig.add_subplot(grid[1, :])
ax.axhline(R_LOCK, color=MUTED, ls="--", lw=1)
ax.plot(t1, r1, color=ACCENT)
ax.annotate(f"in step after {lock1:.0f} s", (lock1, R_LOCK), xytext=(lock1 + 6, 0.55), color=ACCENT,
            arrowprops=dict(arrowstyle="->", color=MUTED, lw=1))
ax.set(xlabel="t / s", ylabel="r", xlim=(0, 60), ylim=(0, 1.05))
plt.show()
Top: the five metronomes on the board on cans at 0, 5, and 30 s, with the phase ring in the corner; the dots are scattered at 0 s, gathering at 5 s, and one bunch at 30 s. Bottom: r against time over 60 s, rising from 0.30 past the dashed 0.9 line and staying near 0.99.

Each metronome is a pendulum of length l = 7.9 cm with a bob of mass m = 15 g at angle θᵢ. Its escapement, the clockwork inside, pushes it along when the swing is small and brakes it when the swing is large, a form named after van der Pol. The board recoils against the bobs, and its acceleration ẍ shakes every pendulum on it. The model is J. Pantaleone's, with a damping term c ẋ added to the board:

\[\ddot\theta_i + \omega_i^2 \sin\theta_i + \mu\,\omega_0\left[\left(\frac{\theta_i}{\theta_0}\right)^2 - 1\right]\dot\theta_i = -\frac{\ddot x}{l}\cos\theta_i,\]
\[M\ddot x = -m\,l\sum_j \frac{d^2}{dt^2}\sin\theta_j - c\,\dot x.\]

Here ωᵢ is metronome i's own small-swing rate, ω₀ = 11.1 rad/s that of the 208, and M = 1.5 kg all that rides sideways: the 250 g board and five metronomes of 250 g. The escapement pushes while |θᵢ| < θ₀ = 0.3 rad and brakes beyond, with strength μ = 0.05; push and brake balance at a swing of twice θ₀, 34°. Each line holds the other's acceleration, so the two are solved together, and one number sets how strongly they talk: β = m/M = 0.010.

A metronome's phase φ is where it is in its swing: the angle of the point (θ, −θ̇/ω₀), which goes once around a circle per swing. Put a unit arrow at each phase and average them: the mean arrow's length, r = |Σₖ exp(iφₖ)|/N, is 1 when all point the same way and 0.4 on average from a random start. It is the Kuramoto order parameter.

The board's shake does work on each metronome, over one swing in proportion to β sin(φᵢ − φⱼ): it feeds the one ahead and drains the one behind. A pendulum's period grows with its swing, by 2.3 % at 34°, so the one ahead swings wider and slower and falls back, and the one behind catches up.

simulate integrates both lines with SciPy's solve_ivp for many starts at once. lock_time returns when r rises above 0.9 for good, if the mean rates over the last 30 s agree to 0.1 %.

print(f"first start: r = {r1[0]:.2f} at 0 s, in step after {lock1:.1f} s, r = {r1[-1]:.3f} at 60 s")
late = t1 > 30
print(f"board: rocks ±{1e3 * np.abs(x1[0, late]).max():.1f} mm, peak speed {1e2 * np.abs(v1[0, late]).max():.1f} cm/s")
t20, theta20, omega20, *_ = simulate(STARTS, seconds=120, every=0.1)
phi20 = phases(theta20, omega20)
locks20 = lock_time(t20, phi20)
print(f"20 random starts: r = {order_parameter(phi20)[:, 0].min():.2f} to {order_parameter(phi20)[:, 0].max():.2f} at 0 s, "
      f"in step after {np.median(locks20):.0f} s (median), {np.nanmin(locks20):.0f} to {np.nanmax(locks20):.0f} s")
ti, thetai, omegai, *_ = simulate(START, seconds=120, isochronous=True)
phii = phases(thetai, omegai)
lti = lock_time(ti, phii)[0]
print(f"sin θ replaced by θ: r = {order_parameter(phii)[0, -1]:.2f} at 120 s, "
      + ("never in step within 120 s" if np.isnan(lti) else f"in step after {lti:.0f} s"))
first start: r = 0.30 at 0 s, in step after 22.6 s, r = 0.995 at 60 s
board: rocks ±2.2 mm, peak speed 2.6 cm/s
20 random starts: r = 0.02 to 0.83 at 0 s, in step after 14 s (median), 3 to 39 s
sin θ replaced by θ: r = 0.19 at 120 s, never in step within 120 s

Over 20 random starts the lock takes 14 s at the median and never more than 39 s. Locked r is 0.995, not 1, because the faster metronomes lead by a few degrees. Take the period's growth away, sin θ replaced by θ in the last run, and the five never lock in 120 s.

Two metronomes can lock in opposition

Press the button 2: two metronomes at 208 a minute, at the board's thirds. On cans they fall into step. Press Damped, which lays a soft mat under the cans, and they drift apart instead, until one swings left while the other swings right and the board stands still.

Show code
starts2 = np.radians([[0, 30], [0, 90], [0, 150]])
pair = {}
for board in ["cans", "damped"]:
    t2, theta2, omega2, x2, v2 = simulate(starts2, board=board, seconds=120)
    phi2 = phases(theta2, omega2)
    pair[board] = dict(theta=theta2, x=x2, v=v2,
                       gap=np.degrees(np.abs(np.angle(np.exp(1j * (phi2[:, 1] - phi2[:, 0]))))))
fig = plt.figure(figsize=(7, 4.6))
grid = fig.add_gridspec(2, 2, height_ratios=[1, 1.1], hspace=0.12, wspace=0.25)
for j, (board, color) in enumerate([("cans", ACCENT), ("damped", SECOND)]):
    p = pair[board]
    draw_scene(fig.add_subplot(grid[0, j]), p["theta"][1, :, -1], p["x"][1, -1], board=board,
               label={"cans": "on cans, 120 s", "damped": "damped, 120 s"}[board])
    ax = fig.add_subplot(grid[1, j])
    for level in [0, 180]:
        ax.axhline(level, color=MUTED, ls="--", lw=1)
    for gap in p["gap"]:
        ax.plot(t2, gap, color=color)
    ax.set(xlabel="t / s", ylabel="phase difference / °" if j == 0 else None, xlim=(0, 120), ylim=(-5, 185),
           yticks=[0, 90, 180])
plt.show()
Two metronomes on cans (left) and damped (right). Top: the pair at 120 s, swinging together on the left, mirrored on the right. Bottom: phase difference in degrees over 120 s from starts 30°, 90°, and 150° apart; on cans all fall to 0°, damped all climb to 180°.

Split the pair's swing into two parts: the in-step part, (θ₁ + θ₂)/2, and the opposite part, (θ₁ − θ₂)/2. To a good approximation only the in-step part moves the board, because in the opposite part the bobs' pushes cancel. On a free board moving costs nothing, and the pull of the last section, which slows the one ahead, favors the in-step part. A damped board, at γ = c/(Mω₀) = 2, turns its motion into heat in the mat, so it drains the in-step part and leaves the opposite part alone. The escapement refills each pendulum to 34° whatever the other does, so the in-step part shrinks swing by swing and the opposite part grows until it is all that is left: the board stops, and nothing is lost to the mat. That is Pantaleone's explanation. Huygens's clocks hung from a beam over two chairs, a support that gives and loses energy, and he saw opposition.

first, last = t2 < 5, t2 > 115
for board in ["cans", "damped"]:
    p = pair[board]
    th = p["theta"][0]                          # the start 30° apart
    in_step, opposite = np.degrees(np.abs(th[0] + th[1]) / 2), np.degrees(np.abs(th[0] - th[1]) / 2)
    print(f"{board:6s}: from 30°, in-step part {in_step[first].max():4.1f}° to {in_step[last].max():4.1f}°, "
          f"opposite part {opposite[first].max():4.1f}° to {opposite[last].max():4.1f}°")
for board in ["cans", "damped"]:
    p = pair[board]
    final = p["gap"][:, -1]
    settle = [t2[np.flatnonzero(np.abs(g - f) > 10)[-1] + 1] for g, f in zip(p["gap"], final)]
    print(f"{board:6s}: from 30°, 90°, 150° to " + ", ".join(f"{f:5.1f}°" for f in final)
          + "   within 10° after " + ", ".join(f"{s:3.0f} s" for s in settle)
          + f"   board peak speed {1e2 * np.abs(p['v'][:, t2 > 90]).max():.2f} cm/s")
t3, theta3, omega3, *_ = simulate(np.radians([[0, 30], [0, 150]]), board="damped", gamma=1.0, seconds=180)
phi3 = phases(theta3, omega3)
gap3 = np.degrees(np.abs(np.angle(np.exp(1j * (phi3[:, 1, -1] - phi3[:, 0, -1])))))
print(f"γ = 1: from 30° to {gap3[0]:.0f}°, from 150° to {gap3[1]:.0f}°")
cans  : from 30°, in-step part 34.1° to 34.5°, opposite part  9.4° to  0.0°
damped: from 30°, in-step part 32.4° to  1.5°, opposite part 10.0° to 34.4°
cans  : from 30°, 90°, 150° to   0.0°,   0.0°,   0.0°   within 10° after   5 s,   9 s,  18 s   board peak speed 2.13 cm/s
damped: from 30°, 90°, 150° to 175.6°, 179.1°, 179.7°   within 10° after  89 s,  58 s,  27 s   board peak speed 0.13 cm/s
γ = 1: from 30° to 0°, from 150° to 179°

From 30° apart on the damped board, the in-step part falls from 32° to 1.5° in two minutes while the opposite part grows from 10° to 34°; on cans it goes the other way. On cans every start ends at 0° within 18 s, and the board keeps rocking at 2.1 cm/s. Damped, every start is within 5° of 180° after 120 s and the board moves at 0.13 cm/s, a sixteenth of that. Halve the damping to γ = 1 and both states hold: the start decides.

On a heavy board they never fall into step

Slide the board to 4 kg, on cans, from the first start. The dots drift around the ring at different speeds, r rises and falls by chance, and the clicks never merge for long. At 2 kg they lock, after a while. On the Fixed board r wanders between 0.05 and 0.73.

Show code
heavy = {}
for mass in [0.25, 4]:
    t4, theta4, omega4, x4, _ = simulate(START, board_mass=mass, seconds=300)
    heavy[mass] = dict(theta=theta4[0], x=x4[0], phi=phases(theta4, omega4))
fig = plt.figure(figsize=(7, 4.6))
grid = fig.add_gridspec(2, 2, height_ratios=[1, 1.1], hspace=0.12, wspace=0.14)
for j, (mass, color) in enumerate([(0.25, ACCENT), (4, SECOND)]):
    h, k = heavy[mass], np.searchsorted(t4, 120)
    draw_scene(fig.add_subplot(grid[0, j]), h["theta"][:, k], h["x"][k], board_mass=mass, phi=h["phi"][0, :, k],
               label=f"{mass:g} kg, 120 s")
    ax = fig.add_subplot(grid[1, j])
    ax.axhline(R_LOCK, color=MUTED, ls="--", lw=1)
    ax.plot(t4, order_parameter(h["phi"])[0], color=color, lw=1.2)
    ax.set(xlabel="t / s", ylabel="r" if j == 0 else None, xlim=(0, 300), ylim=(0, 1.05), xticks=[0, 100, 200, 300])
    if j:
        ax.tick_params(labelleft=False)
plt.show()
A 0.25 kg board (left) and a 4 kg board (right) on cans, same start. Top: the scene at 120 s; on the light board the dots on the ring form one bunch, on the heavy one they scatter. Bottom: r over 300 s; the light board stays at 0.99, the heavy one wanders between 0.14 and 0.87.

The rates differ, so each metronome drifts from the group: on the fixed board the 209 gains one swing a minute on the 207. The board pulls each back in proportion to β, since its shake is β times the bobs' swing. Keep only the phases of the two-line model, average over a swing, and what is left is Kuramoto's equation:

\[\frac{d\varphi_i}{dt} = \omega_i + K\,r\sin(\psi - \varphi_i),\]

with ωᵢ the metronome's own rate, ψ the direction of the mean arrow, r its length, and K proportional to β: the more the group agrees, the stronger the pull. The sine is at most 1, so the pull holds a metronome only if K r ≥ |ωᵢ − Ω|, with Ω the group's common rate. The value of r at the threshold is not known in advance, so the equation gives the threshold's shape, not its value: in a frame turning at Ω only the differences ωᵢ − Ω are left, and doubling them and K runs the same equation twice as fast. So the coupling needed grows in proportion to the spread, and twice the spread needs half the run time.

tf, thetaf, omegaf, *_ = simulate(START, board="fixed", seconds=300)
phif = phases(thetaf, omegaf)
rf, turns = order_parameter(phif)[0], np.unwrap(phif[0], axis=-1) / (2 * np.pi)
print(f"fixed board, 300 s: r between {rf.min():.2f} and {rf.max():.2f}; "
      f"209 gains {((turns[4, -1] - turns[0, -1]) - (turns[4, 0] - turns[0, 0])) / 5:.2f} swings a minute on 207")
for mass in [2, 4]:
    tm, thetam, omegam, *_ = simulate(START, board_mass=mass, seconds=300)
    phim = phases(thetam, omegam)
    lt, rm = lock_time(tm, phim)[0], order_parameter(phim)[0]
    print(f"{mass} kg board, β = {beta(5, mass):.4f}: r between {rm.min():.2f} and {rm.max():.2f}, "
          + ("never in step within 300 s" if np.isnan(lt) else f"in step after {lt:.0f} s"))
fixed board, 300 s: r between 0.05 and 0.73; 209 gains 1.00 swings a minute on 207
2 kg board, β = 0.0046: r between 0.28 and 0.96, in step after 40 s
4 kg board, β = 0.0029: r between 0.14 and 0.87, never in step within 300 s

The threshold \(\beta_\mathrm{c}\) is the smallest coupling at which all 20 starts lock within 300 s, by the test of lock_time, and keep locking at every coupling above it. The sweep tries 20 couplings, then four spreads of rates with ten of the starts and the run time scaled down with the spread.

betas = np.round(np.r_[np.arange(0.0025, 0.006, 0.00025), np.arange(0.006, 0.0121, 0.001)], 5)
ts, thetas, omegas, *_ = simulate(np.tile(STARTS, (len(betas), 1)), coupling=np.repeat(betas, 20), seconds=300, every=0.1)
locks = lock_time(ts, phases(thetas, omegas)).reshape(len(betas), 20)

def threshold(betas, locks):
    """The smallest β from which on every start locks."""
    ok = ~np.isnan(locks).any(axis=1)
    return betas[len(ok) - np.argmin(ok[::-1])] if not ok.all() else betas[0]

board_mass = lambda b: M_BOB / b - 5 * M_METRONOME
beta_c = threshold(betas, locks)
for b, row in zip(betas, locks):
    if b in (0.003, 0.0035, 0.00375, beta_c, 0.005, 0.0075, 0.01, 0.012):
        print(f"β = {b:.5f} ({board_mass(b):4.2f} kg): {np.sum(~np.isnan(row)):2d} of 20 lock"
              + (f", median {np.median(row):5.1f} s" if np.isfinite(row).all() else ""))
print(f"threshold: β_c = {beta_c:.5f}, a board of {board_mass(beta_c):.1f} kg; "
      f"the 0.25 kg board has {beta(5, 0.25) / beta_c:.1f} times the coupling it needs")

factors, beta_cs = [0.5, 1, 1.5, 2], []
for f in factors:
    grid_f = np.round(f * np.arange(0.0025, 0.00501, 0.00025), 6)
    rates_f = 208 + f * (RATES - 208)
    tf_, thf, omf, *_ = simulate(np.tile(STARTS[:10], (len(grid_f), 1)), rates=rates_f, coupling=np.repeat(grid_f, 10),
                                 seconds=300 / f, every=0.1)
    beta_cs.append(threshold(grid_f, lock_time(tf_, phases(thf, omf), window=30 / f).reshape(len(grid_f), 10)))
spreads = 100 * np.array(factors) * np.ptp(RATES) / 208
print("spread " + ", ".join(f"{s:.2f} %" for s in spreads) + ":  β_c " + ", ".join(f"{b:.5f}" for b in beta_cs))
print(f"{spreads[-1] / spreads[0]:.0f} times the spread needs {beta_cs[-1] / beta_cs[0]:.1f} times the coupling")
β = 0.00300 (3.75 kg):  0 of 20 lock
β = 0.00350 (3.04 kg):  0 of 20 lock
β = 0.00375 (2.75 kg): 13 of 20 lock
β = 0.00400 (2.50 kg): 20 of 20 lock, median  26.9 s
β = 0.00500 (1.75 kg): 20 of 20 lock, median  20.6 s
β = 0.01000 (0.25 kg): 20 of 20 lock, median  13.8 s
β = 0.01200 (0.00 kg): 20 of 20 lock, median  12.2 s
threshold: β_c = 0.00400, a board of 2.5 kg; the 0.25 kg board has 2.5 times the coupling it needs
spread 0.48 %, 0.96 %, 1.44 %, 1.92 %:  β_c 0.00225, 0.00400, 0.00525, 0.00650
4 times the spread needs 2.9 times the coupling
Show code
fig, (ax, ax2) = plt.subplots(1, 2, figsize=(7.6, 3.6), gridspec_kw=dict(wspace=0.32, width_ratios=[1.6, 1]))
ok = betas >= beta_c
ax.fill_between(betas[ok], np.nanmin(locks[ok], axis=1), np.nanmax(locks[ok], axis=1), color=ACCENT, alpha=0.3, lw=0)
ax.plot(betas[ok], np.nanmedian(locks[ok], axis=1), "o-", ms=4, color=ACCENT)
ax.plot(betas[~ok], np.full((~ok).sum(), 300), "o", ms=5, mfc="none", mec=MUTED)
ax.text(betas[0], 280, "never", color=MUTED, va="top")
for b, text in [(beta_c, r"$\beta_\mathrm{c}$"), (beta(5, 0.25), "0.25 kg")]:
    ax.axvline(b, color=MUTED, ls="--", lw=1)
    ax.text(b + 0.0002, 200, text, color=MUTED)
ax.set(xlabel="coupling β", ylabel="time to fall into step / s", xlim=(0.002, 0.0125), ylim=(0, 310))
top = ax.secondary_xaxis("top", functions=(lambda b: M_BOB / np.maximum(b, 1e-4) - 1.25, lambda m: M_BOB / (m + 1.25)))
top.set_xticks([5, 2, 1, 0.5, 0.25], ["5", "2", "1", "0.5", "0.25"])
top.set_xlabel("board mass / kg")
slope = beta_cs[1] / spreads[1]                        # in proportion, through the 1 % point
ax2.plot([0, 2.1], [0, 2.1 * slope], color=INK, lw=1.2)
ax2.plot(spreads, beta_cs, "o", ms=6, color=ACCENT)
ax2.text(1.55, 1.62 * slope, "in proportion", color=INK, ha="right", va="bottom")
ax2.set(xlabel="spread of rates / %", ylabel=r"threshold $\beta_\mathrm{c}$", xlim=(0, 2.1), ylim=(0, 2.2 * slope))
plt.show()
Left: time to fall into step against coupling β, median and range of 20 starts, board mass on top; it doubles from 0.010 down to the threshold at 0.004, below which no start locks in 300 s. Right: the threshold against the spread of rates, rising more slowly than in proportion.

Five metronomes spread over 0.96 % need β of at least 0.004, a board of 2.5 kg; the 0.25 kg board has 2.5 times that. The lock does not fade out slowly: the median time only doubles, from 14 s at β = 0.010 to 27 s at the threshold, and one step below, at 0.00375, 13 of the 20 starts lock within 300 s. Four times the spread needs 2.9 times the coupling: the full model follows the averaged phase equation only roughly.

How the game computes it

The state. One object holds everything that changes. theta and omega hold each metronome's angle and angular velocity, on whether it swings, x and v the board's position and speed; β follows from boardMass and n whenever it is needed:

const state = {n: N, board: "fixed", boardMass: 0.25, rates: [], theta: [], omega: [], on: [], x: 0, v: 0,
  t: 0, sound: false, running: true, vPeak: 0, tFree: 0, tAbove: null};

One frame. At every refresh of the screen, typically 60 a second, requestAnimationFrame hands control to frame. The elapsed time, capped at 50 ms, is cut into steps of 4 ms, about a hundred and fortieth of a swing. Each is one Runge-Kutta step: four slopes, at the start, twice at the middle, and at the end, averaged. The slopes come from accelerations, the notebook's function in JavaScript.

Each pendulum's acceleration needs the board's, so the board's comes first. Write fᵢ for what a pendulum's line gives θ̈ᵢ on a fixed board, so that θ̈ᵢ = fᵢ − (ẍ/l) cos θᵢ. Expand the board's line with d²(sin θ)/dt² = θ̈ cos θ − θ̇² sin θ, put θ̈ᵢ in, and ẍ turns up on both sides. Collect it, divide by M, and with γ = c/(Mω₀):

\[\ddot x\left(1 - \beta\sum_j \cos^2\theta_j\right) = \beta\, l\sum_j \left(\dot\theta_j^2\sin\theta_j - f_j\cos\theta_j\right) - \gamma\,\omega_0\,\dot x.\]

This is a below. Fixed sets a = 0, and a stopped metronome has no acceleration and no escapement:

function accelerations(s, theta, omega, v) {
  const w = s.rates.map(angular), b = beta();
  const f = theta.map((th, i) => s.on[i]
    ? -w[i] * w[i] * Math.sin(th) - MU * W0 * ((th / TH0) ** 2 - 1) * omega[i] : 0);   // own swing and escapement
  let a = 0;
  if (s.board !== "fixed") {
    let push = 0, inertia = 0;
    theta.forEach((th, i) => {
      if (!s.on[i]) return;
      push += Math.sin(th) * omega[i] ** 2 - Math.cos(th) * f[i];
      inertia += Math.cos(th) ** 2;
    });
    a = (b * L * push - gamma() * W0 * v) / (1 - b * inertia);
  }
  return {alpha: theta.map((th, i) => s.on[i] ? f[i] - (a / L) * Math.cos(th) : 0), a};
}

A tap. The game finds the metronome nearest to the touch. A swinging one stops at the bottom, a stopped one is let go from 34° at rest, and the board is set so that the total momentum is zero, as in simulate; otherwise a stopped bob would send the board on cans rolling off the screen.

r and the ticks. The phase of each swinging metronome is the angle of (θ, −θ̇/ω₀), and r is the length of the mean arrow:

let c = 0, sn = 0, k = 0;
s.theta.forEach((th, i) => { if (s.on[i]) { const p = phase(th, s.omega[i]); c += Math.cos(p); sn += Math.sin(p); k++; } });
return k ? {r: Math.hypot(c, sn) / k, psi: Math.atan2(sn, c)} : {r: 0, psi: 0};

A tick is a pass of θ through zero, and the game plays a 3 ms click through the browser's Web Audio API. Browsers let a page make sound only after a tap, so the clicks start with the first one.

From the notebook into the game. The rates, ω₀, l, μ, θ₀, the masses, the slider's board masses, γ, and the seeded start phases travel as one JSON object inside a <script type="application/json" id="data"> element, which game.js parses when it loads. The page needs no network and opens from a local file. The board is drawn 10 times its real motion, and the scene says so.

The game against the notebook. The game's step, written in NumPy, runs 60 s from the first start on cans and is compared with simulate:

def rk4_step(theta, omega, x, v, w, b, h):
    def slope(th, om, v):
        alpha, a = accelerations(th, om, v, w, b, 0.0)
        return om, alpha, v, a
    k1 = slope(theta, omega, v)
    k2 = slope(theta + h / 2 * k1[0], omega + h / 2 * k1[1], v + h / 2 * k1[3])
    k3 = slope(theta + h / 2 * k2[0], omega + h / 2 * k2[1], v + h / 2 * k2[3])
    k4 = slope(theta + h * k3[0], omega + h * k3[1], v + h * k3[3])
    new = [y + h / 6 * (a + 2 * b_ + 2 * c + d) for y, a, b_, c, d in zip((theta, omega, x, v), k1, k2, k3, k4)]
    return tuple(new)

w = 2 * np.pi * (RATES / 120) / RHO
b = np.array([beta(5, 0.25)])
theta, omega = AMP * np.cos(START)[None], -AMP * W0 * np.sin(START)[None]
x, v = -b * L * np.sin(theta).sum(axis=1), -b * L * (np.cos(theta) * omega).sum(axis=1)
r_game = []
for k in range(15_000):                         # 60 s in steps of 4 ms
    if k % 25 == 0:                             # every 0.1 s, on the notebook's time grid
        r_game.append(order_parameter(phases(theta, omega)[..., None])[0, 0])
    theta, omega, x, v = rk4_step(theta, omega, x, v, w, b, DT_GAME)
r_game.append(order_parameter(phases(theta, omega)[..., None])[0, 0])
print(f"largest difference in r over 60 s: {np.abs(np.array(r_game) - r1[::2]).max():.1e}")
largest difference in r over 60 s: 2.9e-07

The game and the notebook agree to 3 parts in ten million, far below what the ring can show.

The cell that writes the game. Rounded numbers and sorted keys give the same bytes on every run. The data escapes </ as <\/: an HTML parser takes any </script as the end of the element, JSON or not. The page itself, its controls and its light and dark colors, is a template string in the hidden cell before.

Show code
# The page around the game: the site's light and dark colors, the scene, the readouts, and the controls.
# game.js fills in every number; the notebook fills in DATA and SCRIPT.
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Five metronomes on a board on two cans</title>
<style>
:root { --bg: #ffffff; --fg: #1f2a44; --ink: #1f2a44; --accent: #c8553d; --second: #2a7f9e; --muted: #8a8f98;
  --pale: #e7e9f0; }
@media (prefers-color-scheme: dark) {
  :root { --bg: #141a2a; --fg: #e9ecf3; --ink: #c9d0e3; --accent: #e3826b; --second: #6ec1de; --muted: #8e97ab;
    --pale: #232b40; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--fg);
  font: 15px/1.3 system-ui, sans-serif; -webkit-text-size-adjust: 100%; }
body { display: grid; gap: 4px; padding: 4px 6px 6px; grid-template-columns: minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr) auto auto; grid-template-areas: "scene" "readouts" "controls"; }
#view { grid-area: scene; position: relative; min-height: 0; }
#scene { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: manipulation; }
#readouts { grid-area: readouts; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0 10px;
  font-size: 16px; font-variant-numeric: tabular-nums; } #readouts span { white-space: nowrap; }
#r { color: var(--accent); font-weight: 600; } #status { color: var(--accent); }
#controls { grid-area: controls; display: grid; gap: 4px; align-content: end; }
.row { display: flex; align-items: center; gap: 6px; }
#mass { flex: 1 1 0; min-width: 0; width: 0; margin: 0; accent-color: var(--second); }
#mass-val { min-width: 4em; text-align: right; font-variant-numeric: tabular-nums; }
button { font: inherit; color: var(--second); background: none; border: 1.5px solid var(--second); border-radius: 4px;
  padding: 0.2em 0.6em; }
button[aria-pressed="true"], #play { color: var(--bg); background: var(--second); }
button:disabled, input:disabled { opacity: 0.4; } input:disabled { accent-color: var(--muted); }
.seg { display: flex; } .seg button + button { margin-left: -1.5px; }
button.step { padding: 0; width: 1.9em; }
.push { margin-left: auto; }
button, input { touch-action: manipulation; }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } input[type=range] { height: 44px; } }
@media (min-width: 600px) {
  body { grid-template-rows: minmax(0, 1fr) auto; grid-template-areas: "scene" "controls"; gap: 6px;
    padding: 8px 10px 10px; font-size: clamp(14px, 1.6vw, 17px); }
  #readouts { grid-area: scene; justify-self: end; align-self: start; display: grid; justify-items: end;
    position: relative; z-index: 1; pointer-events: none; font-size: 1.1em; padding: 2px 4px; }
  #controls { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 18px; }
  #controls .row:nth-child(2) { flex: 1 1 240px; }
}
@media (min-width: 600px) and (min-aspect-ratio: 2/1) {
  body { grid-template-columns: minmax(0, 65fr) minmax(0, 35fr); grid-template-rows: auto minmax(0, 1fr);
    grid-template-areas: "scene readouts" "scene controls"; gap: 8px 14px; padding: 10px; }
  #readouts { grid-area: readouts; justify-self: stretch; justify-items: start; pointer-events: auto; padding: 0; }
  #controls { display: grid; align-content: start; gap: 8px; }
  .row { flex-wrap: wrap; }
}
</style>
</head>
<body>
<div id="view"><canvas id="scene" aria-label="Metronomes on a board; tap one to stop or start it"></canvas></div>
<div id="readouts"><span id="r"></span><span id="speed"></span><span id="time"></span><span id="status"></span></div>
<div id="controls">
  <div class="row"><div class="seg" role="group" aria-label="Board" title="B">
    <button id="fixed">Fixed</button><button id="cans">On cans</button><button id="damped">Damped</button></div></div>
  <div class="row"><label for="mass">Board</label><button class="step" id="lighter" aria-label="Lighter board" title="[">−</button>
    <input id="mass" type="range" min="0" max="6" step="1"><button class="step" id="heavier" aria-label="Heavier board" title="]">+</button>
    <span id="mass-val"></span></div>
  <div class="row"><div class="seg" role="group" aria-label="Metronomes" title="N"><button id="n2">2</button><button id="n5">5</button></div>
    <span>metronomes</span><button id="sound" class="push" title="S"></button></div>
  <div class="row"><button id="play" title="Space"></button><button id="restart" title="R">Restart</button></div>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
game_data = {
    "rates": RATES.tolist(), "rates2": RATES2.tolist(), "rho": round(RHO, 6), "omega0": round(W0, 6),
    "l": round(L, 6), "mu": MU, "theta0": THETA0, "amp": AMP, "m_bob": M_BOB, "m_metronome": M_METRONOME,
    "board_masses": BOARD_MASSES, "gamma_damped": GAMMA_DAMPED, "dt": DT_GAME, "draw_scale": DRAW_SCALE,
    "r_lock": R_LOCK, "start": np.round(START, 4).tolist(), "start2": np.round(START2, 4).tolist(),
}
# sorted keys and fixed rounding: two runs write the same bytes; "<\/" keeps "</" out of the script element
data = json.dumps(game_data, sort_keys=True, separators=(",", ":")).replace("</", "<\\/")
script = Path("minigame/metronomes/game.js").read_text(encoding="utf-8")
html = TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script)
Path("assets").mkdir(exist_ok=True)
Path("assets/metronomes.html").write_text(html, encoding="utf-8", newline="\n")
print(f"assets/metronomes.html: {len(data)} bytes of data, {Path('assets/metronomes.html').stat().st_size / 1024:.0f} kB")
assets/metronomes.html: 334 bytes of data, 19 kB

Variations

  • Twenty metronomes. In the data, 20 rates from 204 to 210 a minute, three times the spread, and 20 start phases; the game takes the number of metronomes from the length of rates. Twenty bobs shake the board harder than five, and on the light board they still lock, at r = 0.98.
  • A pendulum that keeps time at any swing. In accelerations, replace Math.sin(th) by th. On the light board the five no longer fall into step: the check of the first section, now playable.
  • Two different metronomes. Set rates2 to 208 and 208.5. On cans they still lock in step, 5° apart; damped they settle 139° apart instead of 180°.
  • Fireflies. Replace the pendulum equations by Kuramoto's phase equation, one line per firefly, and draw each as a dot that flashes when its phase passes 0: a tree of fireflies flashing together, the same pull as among the pacemaker cells of the heart.

Further reading

  • J. Pantaleone, "Synchronization of metronomes", American Journal of Physics 70, 992 (2002), the model and the experiment with metronomes on a board on two cans.
  • M. Bennett, M. F. Schatz, H. Rockwood, and K. Wiesenfeld, "Huygens's clocks", Proceedings of the Royal Society A 458, 563 (2002), for Huygens's letters and why his clocks swung in opposition.
  • S. H. Strogatz, Sync: The Emerging Science of Spontaneous Order (Hyperion, 2003), for fireflies, pacemaker cells, and the Kuramoto model in words, and S. H. Strogatz, "From Kuramoto to Crawford", Physica D 143, 1 (2000), for its mathematics.
  • SciPy's documentation of scipy.integrate.solve_ivp, the integrator inside simulate.
  • MDN on the Canvas API and the Web Audio API, which draw and click everything in game.js.
  • Related tutorials on this site: Draw a phase portrait of a two-variable ODE system, where one metronome's steady swing is a closed loop; Grid frequency after a power plant trips, for generators that stay in step on one grid; and Poincaré sections with solve_ivp, the next step into dynamical systems.
  • Download the notebook. It was executed with the library versions in the header.
  • Download the game, one HTML file of 19 kB that runs offline in any browser.