A penalty-kick mechanic built around a deception trick rather than raw power: the keeper doesn’t know your target, it reads the ball’s position and heading at one reaction instant partway through flight and extrapolates a straight line from there. A curved shot bends after that read — so curling the ball away from its own early trajectory is what beats a keeper who has otherwise “solved” your aim. Straight, hard, and dead-center is honest and easy to save; a bent shot toward a post the ball didn’t look like it was going to reaches goal even against a sharp keeper.
Timed to the 2026 World Cup final having just been played down the road at MetLife Stadium.
Reuse
The mechanic lives in src/penalty-kick.js as a framework-free ES module:
createShootout(opts)— returns a state object; callupdate(dt)each frame andshoot(aimX, aimZ, power, curve)to take a shot.opts:keeperSkill(0..1, how closely the keeper’s guess tracks the true target vs. the naive straight-line read),keeperReactionT(0..1, fraction of flight before the keeper commits to a dive).- Field units are normalized:
aimXspans-1..1across the goal mouth,aimZspans0..H(cfg.H, the crossbar height); aiming aboveHskies it automatically.powertrades shot speed for keeper reaction time — a faster shot leaves the keeper less time to react and dive, a slower lofted one gives it more.curve(-1..1) bends the ball’s path, not its final landing spot; only the keeper’s early read is fooled. - State exposes
phase(aiming|flight|result),ball(x,z),keeper(x,committed),t,lastOutcome, andscore(attempts/goals/saves/misses/streak/bestStreak). Rendering, input, and the charge-power UI are entirely the caller’s job — the demo drives it with mouse-hold-and-drag (hold to charge power, drag sideways while held to add curve).
Port notes for DragonRuby: update() and the trajectory math have no DOM
dependencies; ballAt(t, shot) and keeperGuess(t, shot) translate directly
into a tick method, with the goal-to-screen mapping owned by the renderer.
Gotchas
shoot()is a no-op outsidephase === 'aiming'— the module resets itself automaticallyRESULT_HOLDseconds after a result, no caller-side bookkeeping needed.- The ball’s
x(t)/z(t)formulas both return exactlyaimX/aimZatt = 1regardless of curve or loft — the bend and arc are pure mid-flight bulges (sin(pi·t)terms), so the final target is never affected by how showy the shot looks. - The demo in
demo/bundles its own copy of the module (self-contained by contract). If you touchsrc/, re-copy it intodemo/.