A tennis mechanic with a single input and no aim stick: when you swing relative to the ball’s arrival is the entire decision. Swing on the ideal instant and the return goes back safe and central, easy to read, hard to miss. Swing early or late and the same swing peels the ball off toward one line or the other — further off-time, more angle, more pace, and more chance it’s a clean winner the rally can’t answer. Push past a cutoff on either side and there’s no shot at all, just a mistimed swing into the net. Every rally speeds up the longer it runs, so the safe, central window gets less forgiving in real time even though it never moves in the abstract.
Timed to the US Open, whose qualifying rounds are underway in Flushing this week ahead of the main draw on August 30.
Reuse
The mechanic lives in src/match-point.js as a framework-free ES module:
createRally(opts)— returns a state object; callupdate(dt)each frame andswing()on input. No aim or power parameters — the only decision is whenswing()is called relative to the ball’s arrival.opts.randoverrides the RNG (unused by the core resolution, reserved for future opponent variance).- State exposes
phase(incoming|outgoing|result),t(the timing clock —1is the ideal contact instant),ball(x,y, court-normalized),cfg.cleanWindow/cfg.missWindow(the safe and total-cutoff|t - 1|radii, for drawing a timing gauge),lastOutcome({ won, reason },reasonone ofwinner|rally-cap|mishit|missed), andscore(player/ai/winners/unforced/rally/bestRally/streak/bestStreak). Rendering, input, and any timing-gauge UI are entirely the caller’s job — the demo drives it with spacebar or click and draws the gauge itself.
Port notes for DragonRuby: update() and resolveShot() have no DOM
dependencies; the court-to-screen mapping and the timing gauge belong to the
renderer, same split as the demo.
Gotchas
swing()is a no-op outsidephase === 'incoming'— the module advances itself throughoutgoingandresulton its own, no caller-side polling needed beyond callingupdate(dt)every frame.- Direction and pace both derive from the same
offset = t - 1; there’s no separate aim axis. A shot insidecleanWindowalways hasangle === 0— the risk zone (betweencleanWindowandmissWindow) is where angle and pace both ramp up together, which is what makes going for a line a strictly riskier decision than staying central rather than just a cosmetic one. rallyPace(and with it the ball’s real-time speed) grows every exchange within a point and resets only when a new point starts — a mid-rally screenshot will show a visibly faster gauge sweep than the serve.- The demo in
demo/bundles its own copy of the module (self-contained by contract). If you touchsrc/, re-copy it intodemo/.