155 lines
11 KiB
Markdown
155 lines
11 KiB
Markdown
# Neon Pinball
|
||
|
||
A browser pinball game served by a tiny, zero-dependency Node.js server. The table is
|
||
modelled at real-world scale (millimetres), the physics runs at a fixed 1000 Hz with
|
||
interpolated 60/120 Hz rendering, and every sound is synthesised with the Web Audio API.
|
||
|
||
Play as long as you can keep the ball alive — the score keeps building until you lose
|
||
your third ball.
|
||
|
||
## Run it
|
||
|
||
Requires Node.js 20 or newer. There is nothing to install.
|
||
|
||
```bash
|
||
npm start
|
||
```
|
||
|
||
Then open <http://localhost:3000>. Use `PORT=8080 npm start` to pick another port.
|
||
|
||
> Opening `public/index.html` directly from disk will not work: browsers block ES module
|
||
> scripts on `file://` URLs, so the game has to be served over HTTP.
|
||
|
||
## Controls
|
||
|
||
| Key | Action |
|
||
| --- | --- |
|
||
| `A` or `←` | Left flipper |
|
||
| `D` or `→` | Right flipper |
|
||
| `Space` | Start a game; hold to pull the plunger back, release to launch |
|
||
| `P` / `Esc` | Pause |
|
||
| `M` | Sound on/off |
|
||
|
||
## Rules
|
||
|
||
- **3 balls per game.** When the third ball drains, it's game over. Your best score is saved in the browser.
|
||
- **Plunger:** a short hold (below the white tick on the power meter) is a soft plunge that drops the
|
||
ball into the top lanes; a long hold sends it all the way round the orbit.
|
||
- **Skill shot (25,000):** one top lane blinks at launch. Soft-plunge the ball into the lanes and steer
|
||
the blinking lane under it with the flippers.
|
||
- **Top lanes:** light all three to raise the end-of-ball bonus multiplier (2X–5X). The flipper buttons shift the lit lanes left and right.
|
||
- **Drop targets (left):** knock all three down for 10,000, 20 seconds of Super Bumpers (1,000 per hit), and to relight the kickback.
|
||
- **Stand-up targets (right):** light all three for 15,000.
|
||
- **Kickback:** lit at the start of the game; when a ball goes down the left outlane it's fired back into play.
|
||
- **Ramps:** a hard flipper shot up either outer lane climbs a ramp and loops back over the pop bumpers
|
||
to feed the same flipper again — too weak a shot and it rolls back out of the entrance mouth.
|
||
- **Lock & multiball:** shoot the center scoop (threaded between the two upper pop bumpers) to lock a
|
||
ball — it doesn't cost you a turn, a fresh ball is served immediately. Lock two, and the next scoop
|
||
shot starts 3-ball multiball with a jackpot lit on both ramps.
|
||
- **Jackpots:** during multiball, clearing a lit ramp scores its jackpot (climbing in value each time)
|
||
and unlights it; collecting both lights the Super Jackpot at the scoop.
|
||
- **Bonus:** targets and lanes build a bonus that is multiplied and paid when each ball drains.
|
||
|
||
## Tests
|
||
|
||
```bash
|
||
npm test
|
||
```
|
||
|
||
The physics and rules have no DOM dependencies, so the test suite (Node's built-in
|
||
`node:test`) drives the real game headlessly. It covers the flipper collision shape against
|
||
a brute-force reference, cradling and flipping, plunger power, drains and game over, the
|
||
scoring features, ramp riding (a track sampled against a brute-force distance check, a weak
|
||
shot rolling back vs. a hard shot cresting it), locking, multiball, jackpots and the super
|
||
jackpot, and a three-minute random-play run — now driving up to three simultaneous balls —
|
||
that checks no ball ever tunnels through a wall or escapes the table.
|
||
|
||
## Project layout
|
||
|
||
```
|
||
server.js static file server (node:http, correct MIME types, no path traversal)
|
||
public/index.html page and backbox HUD
|
||
public/style.css
|
||
public/js/physics.js ball, walls, posts, tapered flippers, collision response
|
||
public/js/table.js table geometry at real-world scale
|
||
public/js/game.js rules, scoring, fixed-timestep simulation
|
||
public/js/render.js canvas renderer (static layer cached, dynamic parts per frame)
|
||
public/js/audio.js synthesised sound effects
|
||
public/js/main.js game loop, keyboard input, HUD
|
||
test/ node:test suites
|
||
```
|
||
|
||
## How it works, and where the numbers come from
|
||
|
||
- **Scale.** The playfield is a standard 20.25" × 42" (514 × 1067 mm) with a 1-1/16" (27 mm) ball.
|
||
The flippers are 3" bats (3.25" with rubber) whose pivots are 7" apart, resting 31° below
|
||
horizontal and swinging to 20° above (Visual Pinball's defaults of 121° and 70°,
|
||
measured clockwise from 12 o'clock — about the 52° swing of a real flipper).
|
||
- **Gravity.** A real table is tilted 6.5°, giving 9.81 × sin 6.5° ≈ 1.11 m/s² along the playfield.
|
||
The game uses 1.5 m/s² for a snappier, arcade feel (tuned by play-testing).
|
||
- **Collisions** follow the "Ten Minute Physics" pinball approach: push the ball out along the
|
||
contact normal, then correct its normal velocity. On top of that there is speed-dependent
|
||
restitution (flipper elasticity 0.8, from Visual Pinball's defaults) and moving-surface
|
||
contacts for the flippers. The flipper is an exact tapered capsule (Inigo Quilez's uneven-capsule
|
||
distance function), so what you see is exactly what the ball hits. Multiball uses the same
|
||
tutorial's equal-mass ball-vs-ball collision for balls bumping into each other.
|
||
- **Ramps and the wireform return.** A ramp is a Catmull-Rom spline through hand-placed 3D control
|
||
points (x, y, and height above the playfield); the ball rides it like a bead on a wire, gaining or
|
||
losing speed to gravity along the climb (scaled down from the real 9.81 m/s² for an arcade feel) plus
|
||
rolling friction. Too weak a shot loses all its speed partway up and rolls back out of the entrance,
|
||
exactly as on a real ramp. The entrance itself sits well past the flipper, not right at its tip: an
|
||
early version put the mouth exactly where a hard flip's tip ends up, which turned out to catch nearly
|
||
*every* flip regardless of aim — there's no aiming to a mouth that every shot already passes through.
|
||
Simulating a cradled-ball flip across a wide sweep of hold times and searching the resulting
|
||
trajectories for a spot only a specific, contiguous band of hold times actually reaches (not a guess)
|
||
found a real one: holding the flip for roughly a beat past the instinctive snap sends the ball on a
|
||
different, later-diverging arc that the mouth sits on. Each ramp climbs from there over the pop bumpers
|
||
and loops back down to the natural post-flip point above the same flipper — no aim needed for the
|
||
return, only the entrance demands it.
|
||
- **Lock, multiball and jackpots** follow the standard pattern on games like Medieval Madness and Attack
|
||
from Mars: a captive-ball scoop below the pop bumpers builds a lock without costing a turn, then kicks
|
||
off real 3-ball multiball; ramp shots score an escalating jackpot while it's lit, and clearing both
|
||
lights a Super Jackpot back at the scoop. The scoop's plain "bonus, kicked back out" case ejects the
|
||
ball with a randomised sideways component and a short cooldown — an earlier straight-down, dead-centre
|
||
eject could fall onto a pop bumper and bounce straight back up into the scoop over and over, a
|
||
perfectly symmetric loop that trapped a ball indefinitely.
|
||
- **The stuck-ball rescue** doesn't watch instantaneous speed — a ball can be stuck while moving fast the
|
||
whole time, cycling energetically around a loop through several colliders (bumper → bumper → bumper →
|
||
repeat) that never actually goes anywhere. It watches the bounding box the ball has visited over a
|
||
rolling multi-second window instead: a real loop can't escape a modest box no matter how many laps it
|
||
runs, so once that window elapses without the box growing, it forces a hard rescue kick. This is what
|
||
caught a real game-breaking bug (found through extended simulation, not by inspection): a ball could
|
||
settle into a stable circuit around the pop bumpers and rack up score indefinitely without ever
|
||
draining.
|
||
- **The 3D tilt** is a real CSS 3D transform (`perspective` + `rotateX`) on the rendered table, not a
|
||
change to the game itself: physics and input stay in the flat, straight-down coordinate system the ball
|
||
actually moves in, and the browser tilts that finished picture in 3D space for display, anchored at the
|
||
bottom (flipper) edge so the far end recedes correctly.
|
||
- **Ramp and wireform rendering** is the classic 2D "cylinder" trick also used for the lane guides
|
||
(`metalRail`): several strokes of shrinking width and lightening colour nested on the same centreline
|
||
read as a round chrome tube, with a coloured LED-style accent stripe down one side that brightens when
|
||
that ramp's jackpot is lit.
|
||
- **Layout.** The overall arrangement — a decorative structure at the top of the arch, two big tube
|
||
ramps crossing over a pop-bumper triangle, bold arrow-shaped target banks flanking the bumpers, a
|
||
captive lock/spinner feature just below them, and lightning-bolt slingshots by the flippers — is
|
||
patterned after a user-supplied reference photo of a real cabinet, with none of its branding, colours,
|
||
or characters carried over.
|
||
- **Game loop.** A fixed-timestep accumulator with render interpolation ("Fix Your Timestep!"),
|
||
with the frame time clamped to 0.25 s to avoid a spiral of death after a stall.
|
||
|
||
### Sources
|
||
|
||
- Playfield size and slope: [Dimensions.com – Pinball Machines](https://www.dimensions.com/element/pinball-machines), [VPForums – Playfield sizes](https://www.vpforums.org/index.php?showtopic=2762)
|
||
- Ball size: [Marco Specialties – 1-1/16" ball](https://www.marcospecialties.com/pinball-parts/PB116), [Pinball Life – standard pinball](https://www.pinballlife.com/1-116-pinball-standard-size.html)
|
||
- Flipper spacing and length: [Pinside – distance between flippers](https://pinside.com/pinball/forum/topic/what-is-the-regular-distance-between-flippers)
|
||
- Flipper swing arc: [VPForums – flipper angles](https://www.vpforums.org/index.php?showtopic=39652)
|
||
- Visual Pinball flipper defaults and angle convention: [vpinball `flipper.cpp`](https://github.com/freezy/vpinball/blob/master/flipper.cpp), [VP10 physics notes](https://github.com/c-f-h/vpinball/wiki/VP10-Physics)
|
||
- Collision approach (including ball-vs-ball): [Ten Minute Physics – pinball](https://github.com/matthias-research/pages/blob/master/tenMinutePhysics/04-pinball.html)
|
||
- Tapered capsule SDF: [Inigo Quilez – 2D distance functions](https://iquilezles.org/articles/distfunctions2d/)
|
||
- Playfield layout, flow, orbits and ramps feeding a flipper: [Mission Pinball Framework – Layout considerations](https://docs.missionpinball.org/en/latest/physical_building/layout_considerations.html)
|
||
- Ramps, wireforms, scoops/VUKs and locks: [Pinball Makers wiki – Design](https://pinballmakers.com/wiki/index.php?title=Design), [Wikipedia – Pinball (playfield components, ball save, tilt)](https://en.wikipedia.org/wiki/Pinball)
|
||
- Real lock/multiball/jackpot examples referenced for the design: [Wikipedia – Twilight Zone (pinball)](https://en.wikipedia.org/wiki/Twilight_Zone_(pinball))
|
||
- Game loop: [Gaffer on Games – Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/)
|
||
- Browser APIs (MDN): [KeyboardEvent.code values](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values), [devicePixelRatio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio), [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame), [JavaScript modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules), [Web Audio autoplay](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Best_practices), [User activation](https://developer.mozilla.org/en-US/docs/Web/Security/User_activation)
|
||
- CSS 3D transforms (MDN): [perspective()](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/perspective), [rotateX()](https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/rotateX)
|