Cosmic Pinball — A Space Arcade Born from MS Pinball Nostalgia
A vertical pinball combining nostalgia for Windows Space Cadet with this sites cosmic concept. Rotating collider tunneling, shadowBlur cost, combat UFO enemies — a technical retrospective of the build
The Beginning — Why Pinball, Again?
Do you remember 3D Pinball Space Cadet that came bundled with Windows in the early 2000s? Derived from Full Tilt! Pinball, it had a way of stopping offices at lunchtime. Three-color wormholes, a central Gravity Well, a rank-up system, Attack Bumpers — a densely packed distillation of maximum fun per minute invested in one small window.
This web page has kept a space concept throughout. Arcade titles like Galaga, Asteroids, and Cosmic Barrage piled up, but something felt missing — an interactive "table". A machine sitting atop a space station deck that keeps bouncing, keeps counting points. That was how Cosmic Pinball started.
Goals:
- Homage to Space Cadet core elements (wormholes / Gravity Well / rank)
- Natural fit with the sites cosmic backdrop
- Playable on mobile portrait
- Pure Web Audio synthesis — no external samples
Part 1 — Physics
Rotating Colliders and Tunneling
The hardest problem in pinball was the flippers. The ball is fast, and flippers rotate even faster. My first implementation updated flipper angle once per frame while running ball physics at 6 substeps — and that created a critical bug.
// ❌ First pass — angle updated once per frame
updateFlipperAngle(flipper, state.particles); // 1 frame = full 0.85 rad move
for (let i = 0; i < SUBSTEPS; i++) {
resolveCollisions(state); // ball substeps — flipper already teleported
}
The flipper tip moves 64px in one frame, but to the ball substep the flipper had teleported. Right when the flipper should have struck the ball upward, the flipper had jumped to its final position first, leaving the ball suspended. To the player it felt like "the ball was clearly on the flipper but just passed through".
The fix was to update the angle incrementally inside the substep loop.
// ✅ Fixed — increment angle within substeps
for (let i = 0; i < SUBSTEPS; i++) {
stepFlipperAngle(state.flippers.left); // 0.142 rad per substep
stepFlipperAngle(state.flippers.right);
// ... ball physics step ...
resolveCollisions(state);
}
The important detail: angularVelocity stays as a per-frame value. The tangent impulse formula (ω × r) needs to remain scale-consistent.
"Bouncing Off Thin Air" — Tapered Collider
Another flipper problem. The flippers render as trapezoids (wider at pivot, narrower at tip), but collision used a uniform capsule radius of 19px. The result:
| Location | Visual half-width | Collision radius | Gap |
|---|---|---|---|
| pivot end | 9px | 19px | 10px |
| tip end | 6.4px | 19px | 12.6px |
The ball would bounce off what looked like empty space near the tip — a subtle uncanny feel. The fix was to taper the collision radius to match the visual shape.
// t = 0(pivot) ~ 1(tip) linear interpolation
const flipperHalfW = halfBig * (1 - t) + halfSmall * t;
const r = BALL_R + flipperHalfW;
// Also: shorten the segment to just before the visible tip
const SEG_SCALE = 0.96;
SUBSTEPS × MAX_SPEED < BALL_R Invariant
Ball occasionally disappeared through walls at high speed. Cause was clear:
MAX_BALL_SPEED = 64,SUBSTEPS = 6→ 10.67px per substepBALL_R = 10— wall collision only detects when center is within BALL_R
The ball moved further than its own radius per substep, so a ball at -9px from a wall could land at +1px without tripping the collision check. Bumping SUBSTEPS to 8 so 64/8 = 8 < 10 solved it.
As a backup against NaN / edge escapes, I added a clampBallSafety() net. If the ball leaves the canvas, it gets restored to the launch position without costing a life — losing the ball is a bug, not the players fault.
Launch Lane Re-entry — One-Way Gate
The ball, after launch, would sometimes re-enter the launch lane while bouncing around. Physically plausible, but it broke game flow. Real pinball tables use a one-way flap gate for this.
Implementing it in Canvas 2D just means adding a velocity-direction filter to the Wall type.
export interface Wall {
x1: number;
y1: number;
x2: number;
y2: number;
oneWay?: 'blockDown' | 'blockUp';
}
// In the collision loop:
for (const w of walls) {
if (w.oneWay === 'blockDown' && ball.vy <= 0) continue; // upward motion passes through
collideCircleSegment(ball, w.x1, w.y1, w.x2, w.y2, BALL_R, WALL_RESTITUTION);
}
Launch ball (vy<0) passes through, falling ball (vy>0) bounces off. Placed at the arc extension endpoint near the top, this blocks balls from re-entering the launch lane through the narrow gap.
Part 2 — Turning UFOs Into "Enemies"
Static bumpers felt like they were missing something. Since the theme is cosmic, I built a system where UFOs actively attack. Three phases.
Phase 1 — HP and Destruction
Each UFO has 3 HP. Ball hit → HP--, reaching 0 triggers destruction + particle burst + 1,000 point bonus, respawns after 3 seconds.
Phase 2 — Laser AI
if (ball within 220px && cooldown expired) {
charge for 30 frames (0.5s) → fire
projectile: 5.5px/frame, 1.5s lifetime
}
On hit, ball velocity ×0.55 + direction perturbation. No life lost. "The laser is a threat, not an execution" was the core balancing principle.
Phase 3 — Clockwise Slot Rotation
Fixed UFO positions become predictable. So 5 UFOs cycle clockwise through 5 slots, smooth-lerping to the next slot every 5 seconds.
const UFO_SLOTS = [
{ x: 200, y: 160 },
{ x: 340, y: 160 },
{ x: 380, y: 250 },
{ x: 270, y: 350 },
{ x: 160, y: 250 },
];
// Each UFO increments its slot index by floor(frame / 300),
// last 1 second lerps smoothly (ease-in-out)
4 static seconds for firing lasers, 1 dynamic second for relocating. Not a stationary enemy — a constantly shifting hostile formation.
Part 3 — Performance
Pretty Neon Is Slow
The initial wall rendering was a 3-layer neon composite. 50+ wall segments × 3 layers = 150 strokes per frame, each with shadowBlur 20/12. Result: noticeable stutter.
Two-step optimization: batching + caching.
Step 1 — Stroke batching:
// ❌ Per-segment stroke
for (const w of walls) {
ctx.beginPath(); ctx.moveTo(...); ctx.lineTo(...); ctx.stroke(); // N shadowBlur apps
}
// ✅ Single stroke, N subpaths (shadowBlur once)
ctx.beginPath();
for (const w of walls) {
ctx.moveTo(w.x1, w.y1); ctx.lineTo(w.x2, w.y2);
}
ctx.stroke();
Step 2 — OffscreenCanvas caching. Walls are static, so render once and drawImage every frame.
let _wallsCache: OffscreenCanvas | null = null;
// First: buildWallsCache() → draw all wall layers
// Every frame: ctx.drawImage(_wallsCache, 0, 0)
The Hidden Cost of Particles
The shared drawParticle utility was applying shadowBlur = 4 by default. With MAX_PARTICLES=80, worst case meant 80 GPU blur operations per frame. Removing shadowBlur + minimizing save/restore was the biggest single frame-time win.
Cache Catalog
In the end I cached the following into OffscreenCanvas:
- Walls (1 cache) — static
- Bumper body (kind × r, 4 caches) — only spin windows / hit pulse stay dynamic
- Drop target (per id, 3 caches) — breathe pulse via
ctx.scale()only - Wormhole base (color × r, 3 caches) — rotating rings stay dynamic
- Slingshot (per id, 3 caches) — hit flash overlaid via
globalCompositeOperation: 'lighter' - Ring bumper (per r, 1 cache) — 4 rotating rivets stay dynamic
Result: shadowBlur calls per frame dropped ~110 → ~18-25 (roughly 80% reduction).
Part 4 — Audio
Pinball sound splits into SFX and BGM.
SFX — Pure Web Audio Synthesis
Bumper "pop", slingshot snap, wormhole whoosh, black-hole sub-bass drone, UFO laser zap, destruction boom — all built with OscillatorNode + BiquadFilter. No mp3 samples.
While the black hole is open (10 seconds), a sustained ambient layer (40Hz sub + 80Hz octave + LFO filter sweep) runs continuously to preserve the "something is open" tension.
The flippers "clunk" uses two tones. Press: 110→55Hz punch. Release: 92→48Hz softer return. Rapid alternation produces a natural "clunk-clunk-clunk" rhythm.
BGM — A Minor 8-Bar Loop
I remembered Space Cadets music having a dark, tense C-minor-ish progression, so I recreated a similar mood in A minor across 8 bars.
Am | Am | F | G | C | Am | F | E7 →
The final E dominant creates peak tension before resolving back to Am — a classic minor-key V→i. 112 BPM, 17-second loop. A square-wave arpeggiated melody paired with triangle-wave bass pulse.
The mute button toggles BGM only. SFX always plays — UFO laser warnings and black-hole drones being inaudible would make the game feel wrong.
Part 5 — A Universe That Lives Behind the Table
I wanted the empty canvas background to have something alive happening. So I added an ambient system.
- 3 small starships drifting across the screen, occasionally exchanging laser fire + explosions
- Occasional bursts of cosmic lightning (quick zigzag flash)
- Slowly drifting neon orbs
- Yellow bolts radiating from the ring bumpers (spawns at 1.8% probability per frame)
All of this has zero effect on game logic, physics, or scoring. Purely visual, but the presence-vs-absence difference is dramatic. "Something is always happening behind the table" raises immersion to another level.
Wrap-Up — Pinball Is Ultimately About Feel
Many parts couldnt be solved by math. Flipper tip bounce strength, slingshot elasticity, ball deceleration when hit by a UFO laser — all were answered by balancing and feel. Translating "the ball should move like this in this moment" into code.
Being able to bring back that feeling of keeping Space Cadet open, waiting for the Rank Promoted sign, onto a web canvas was a satisfying project. Hold down the space bar to max the plunger, land a third wormhole hit, watch BLACK HOLE OPENED! appear — those brief moments remind you why pinball is still fun.
Give it a play. 🛸
Guestbook
Leave a short note about this post
Loading...