Director’s commentary · Track & Field
Thumbs as a joystick
Turning thumb taps into a joystick waggle for Disk 4's 100-metre sprint: cadence physics, the alternation rule, shake mode on iOS, the ruler cheat, and a leaderboard that marks it.
The game was Track & Field, and in our house joysticks didn't wear out, they died sprinting. Disk 4 turns that into an original 100-metre sprint powered by your thumbs. The hard part wasn't the running animation. It was making taps on a sheet of glass feel like wrenching a stick from side to side, and then running a global leaderboard when a cheat from the era, wedging a ruler under the stick and twanging it, is built into the game on purpose.
Speed comes from rhythm, not from taps
The obvious version, where every tap pushes you forward a bit, feels terrible. It rewards frantic mashing and punishes a steady rhythm, which is the opposite of waggling. So the sprint doesn't count taps, it measures cadence: how many accepted taps landed in the last 0.8 seconds. That cadence maps to a target speed on a curve that rewards modest effort (seven taps a second gets you a respectable run) and tops out at 12 metres a second at fifteen. Your athlete then chases that target speed, speeding up at 5 m/s² and slowing down slightly faster, so if you stop waggling you fade quickly, the way you would.
/** Cadence → the speed the athlete is trying to reach. */
export function targetSpeed(rate: number): number {
if (!(rate > 0)) return 0;
return VMAX * Math.min(1, (rate / RATE_FOR_MAX) ** RATE_CURVE);
}
// …
/** Advance the race by dt seconds. Records the interpolated finish time when the line is crossed. */
export function step(s: RaceState, dt: number): void {
const prevT = s.t;
const prevD = s.d;
s.t += dt;
// Drop taps that have left the window (they're in time order).
const cutoff = s.t - RATE_WINDOW;
let drop = 0;
while (drop < s.recent.length && s.recent[drop] <= cutoff) drop++;
if (drop) s.recent.splice(0, drop);
const target = targetSpeed(raceRate(s));
s.v = approach(s.v, target, (s.v < target ? ACCEL : DECEL) * dt);
s.d += s.v * dt;
if (s.finishedAt === null && prevD < TRACK_M && s.d >= TRACK_M) {
s.finishedAt = prevT + (dt * (TRACK_M - prevD)) / (s.d - prevD);
}
}The race runs on a fixed 120 Hz step, whatever the display is doing, and the finish time is interpolated inside the step that crosses the line, so two runs can't tie just because they finished in the same frame. At a steady sixteen taps a second the 100 metres takes about 9.6 seconds, and at a steady seven just under 15. The tests pin the shape of it: every human cadence finishes between 9 and 16 seconds, faster waggling is always faster, and even infinite waggling can't beat 9. The ruler, as it happens, is no faster than a very good human. It's just tireless.
Left, right, left
A joystick only moves if you go the other way. So there's a big pad under each thumb, or two keys on a keyboard, and a tap only counts if it's on the other side from the last one. Two lefts in a row are refused and counted, the pad flashes, and every so often the game tells you why. Held-down keys that auto-repeat are ignored, because a key that repeats on its own is a ruler you didn't have to buy.
/** Alternation rule: the first tap counts, then each tap must be on the other side. */
export function isAlternate(last: Side | null, side: Side): boolean {
return last !== side;
}
/**
* Register a tap at the race's current time. Returns true if it counted.
* Taps after the finish never count; a same-side tap is counted as rejected.
*/
export function tap(s: RaceState, side: Side, source: TapSource = "player"): boolean {
if (s.finishedAt !== null) return false;
if (!isAlternate(s.lastSide, side)) {
s.rejected++;
return false;
}
s.lastSide = side;
s.recent.push(s.t);
s.taps++;
if (source === "ruler") {
s.rulerTaps++;
s.cheat = true;
}
return true;
}On a phone there's also shake mode, which uses the motion sensor so you can waggle the phone itself. On iOS that needs permission, and Safari will only ask if the request comes straight from a tap, so the Shake mode button asks there and then. If permission is refused, or no motion arrives within two seconds, it switches itself off and says so, rather than leaving you shaking a phone at nothing.
if (typeof DME.requestPermission === "function") {
// iOS: must be called straight from the tap, and may be refused.
let res = "denied";
try {
res = await DME.requestPermission();
} catch {
res = "denied";
}
if (this.destroyed) return;
if (res !== "granted") {
this.shakeRefused(COPY.shakeDenied);
return;
}
}The motion itself goes through a small detector: a high-pass filter strips out gravity, the axis with the most recent energy is taken as the waggle axis, and a swing past a threshold one way and then the other counts as a tap each way, with a minimum gap so one flick can't count twice.
The ruler, and the asterisk
Somebody in our house had a technique involving a ruler, and the homemade auto-waggle was a real thing in the era, so the game has one. The ruler is a steady sixteen taps a second, always on the opposite side from the last tap so it never trips the alternation rule. The moment it makes a single tap, the run is marked as a cheat, even if you pull it out straight away. Times set with it go on the board with an asterisk next to them, and the game tells you so when you put it in.
export function stepRuler(s: RaceState, dt: number, on: boolean): number {
if (!on || s.finishedAt !== null) {
s.rulerAcc = 0;
return 0;
}
s.rulerAcc += dt * RULER_RATE;
let n = 0;
while (s.rulerAcc >= 1 - 1e-9) {
s.rulerAcc -= 1;
tap(s, s.lastSide ? other(s.lastSide) : "L", "ruler");
n++;
}
return n;
}
// …
/** A board row as text: "DAV 10.84*" (asterisk for ruler times). */
export function entryLabel(e: Pick<ScoreEntry, "initials" | "score" | "cheat">): string {
return `${e.initials} ${formatTime(e.score)}${e.cheat ? "*" : ""}`;
}The server checks every submitted time against bounds that are generous on purpose. An honest run can't be faster than 5.5 seconds or average more than 22 taps a second, and it needs at least 40 taps. A ruler run is allowed to be superhuman within reason. Cheat runs are stored on their own boards and merged in beside the clean top ten, marked, rather than competing with it.
export const WAGGLE = {
minMs: 5500,
maxMs: 5 * 60 * 1000,
maxTapsPerSecond: 22,
/** A 100 m run can't take fewer taps than this, or more than maxTaps. */
minTaps: 40,
maxTaps: 2000,
/** The auto-waggle cheat (the ruler) is allowed to be superhuman, within reason. */
cheatMinMs: 1000,
cheatMaxTapsPerSecond: 100,
/** summary.durationMs may run longer than the race time (countdown, finish). */
durationSlackMs: 15_000,
};
// …
export function checkWaggle(ms: number, s: Record<string, unknown>, cheat: boolean): string | null {
const { taps, durationMs } = s;
if (!int(taps, 0, WAGGLE.maxTaps) || !num(durationMs, 0, WAGGLE.maxMs + WAGGLE.durationSlackMs)) return "bad-summary";
if (ms > WAGGLE.maxMs || ms < (cheat ? WAGGLE.cheatMinMs : WAGGLE.minMs)) return "implausible-time";
if (durationMs < ms - 250 || durationMs > ms + WAGGLE.durationSlackMs) return "implausible-time";
if (taps / (ms / 1000) > (cheat ? WAGGLE.cheatMaxTapsPerSecond : WAGGLE.maxTapsPerSecond)) return "implausible-taps";
if (!cheat && taps < WAGGLE.minTaps) return "implausible-taps";
return null;
}An entry is three initials and a time, and nothing else. The initials go through a profanity filter, submissions are rate-limited without storing IP addresses, and there are no accounts. It's a personal site, and the aim is to keep the board fun rather than to stop a determined cheat.
The chapter is loud, so it's also careful. The crowd noise rises with your cadence, the crowd bobs at under three times a second, and the camera flashes in the stands are rate-limited to two in any one second, so nothing on screen flashes faster than the three-a-second limit.
What I'd do differently
The server trusts the summary the client sends: the time, the tap count and how long the run took. That's enough to stop nonsense, not enough to stop someone patient. If anyone ever does go after the board, the fix is already half-built: the race physics is pure and deterministic given a list of tap times, so the client could send its taps and the server could simply run the race again and see if it agrees. Until someone makes that necessary, generous bounds are the right amount of effort for a board of three-letter initials.