TECH DETAILS

Retro Pinball - Day 29

Advanced Level

Table of Contents

01 / Ball Physics & Gravity

The pinball simulation uses a simplified 2D physics model where a steel ball moves under constant gravitational acceleration. Each frame, gravity pulls the ball downward while friction slightly dampens velocity, replicating the feel of a ball rolling on a tilted table surface.

// Physics constants tuned for arcade feel const GRAVITY = 0.15; // Downward acceleration per frame const FRICTION = 0.998; // Velocity damping (close to 1 = slippery) const BOUNCE_DAMPING = 0.6; // Energy lost on each bounce const MAX_SPEED = 20; // Velocity clamp to prevent tunneling // Per-frame update ball.vy += GRAVITY; // Apply gravity ball.vx *= FRICTION; // Apply friction ball.vy *= FRICTION; // Clamp velocity to prevent ball from going too fast const speed = Math.sqrt(ball.vx**2 + ball.vy**2); if (speed > MAX_SPEED) { ball.vx = (ball.vx / speed) * MAX_SPEED; ball.vy = (ball.vy / speed) * MAX_SPEED; }

Why Velocity Clamping Matters

Without a max speed, the ball can move so many pixels per frame that it "tunnels" through thin objects like flippers and walls. By clamping to 20 px/frame at 60 fps, we ensure the ball always intersects with collision geometry. The bounce damping factor of 0.6 means the ball retains 60% of its speed on each collision, preventing infinite bouncing while keeping gameplay lively.

Pro Tip

A gravity value of 0.15 on a 640px-tall table produces a natural-feeling drop. Higher values make the game feel "heavy" and punishing; lower values make it float. Tune this alongside flipper force for the best arcade feel.

02 / Collision Detection System

Pinball requires two distinct collision types: circle-vs-circle (for round bumpers) and circle-vs-line-segment (for walls, flippers, and slingshots). Both use separation and reflection to resolve overlaps in a single frame.

Circle vs Circle (Bumpers)

When the ball approaches a bumper, we compute the distance between their centers. If it's less than the sum of their radii, a collision occurs. The resolution pushes the ball out along the normal vector and reflects velocity.

function ballVsCircle(cx, cy, cr) { const dx = ball.x - cx; const dy = ball.y - cy; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < BALL_RADIUS + cr) { // Normal vector from bumper center to ball const nx = dx / dist; const ny = dy / dist; // Push ball out of overlap const overlap = BALL_RADIUS + cr - dist; ball.x += nx * overlap; ball.y += ny * overlap; // Reflect velocity along normal const dot = ball.vx * nx + ball.vy * ny; ball.vx -= 2 * dot * nx; ball.vy -= 2 * dot * ny; // Bumpers add energy: boost speed + push outward ball.vx += nx * 2; ball.vy += ny * 2; return true; } return false; }

Circle vs Line Segment (Walls & Slingshots)

For line-based obstacles, we project the ball center onto the line segment to find the closest point, then check if the distance is less than the ball radius. This handles walls of any angle.

function ballVsLine(x1, y1, x2, y2) { const dx = x2 - x1, dy = y2 - y1; const len = Math.sqrt(dx * dx + dy * dy); // Project ball onto line (parameter t ∈ [0,1]) const t = ((ball.x - x1) * dx + (ball.y - y1) * dy) / (len * len); if (t < 0 || t > 1) return false; // Closest point on segment const closestX = x1 + t * dx; const closestY = y1 + t * dy; // Check distance to ball center const dist = Math.hypot(ball.x - closestX, ball.y - closestY); if (dist < BALL_RADIUS) { // Separate and reflect // ... (resolve overlap + reflect velocity) return true; } return false; }
Watch Out

The t clamping to [0, 1] is critical. Without it, the ball would collide with the infinite line extending beyond the segment endpoints, causing ghost collisions with invisible walls.

03 / Flipper Mechanics

Flippers are the player's only control. Each flipper is defined by a pivot point and an angle that swings between a rest position and an active position. The rotation uses spring-like interpolation for a smooth, satisfying snap.

const flippers = { left: { x: 80, y: 570, angle: 0.4, // Current angle (radians) restAngle: 0.4, // Angle when idle (slightly down) activeAngle: -0.6, // Angle when pressed (swings up) dir: 1, // Extends to the right active: false }, right: { x: 300, y: 570, angle: Math.PI - 0.4, restAngle: Math.PI - 0.4, activeAngle: Math.PI + 0.6, dir: -1, // Extends to the left active: false } }; // Spring-like flipper animation const target = f.active ? f.activeAngle : f.restAngle; const lerp = f.active ? 0.35 : 0.15; // Fast up, slow return f.angle += (target - f.angle) * lerp;

Hit Position Affects Launch Angle

When the ball collides with an active flipper, where along the flipper it hits determines the launch force. A hit near the tip produces maximum velocity, while a hit near the pivot gives less force. This mimics real pinball physics and rewards precise timing.

// Calculate where along the flipper (0=pivot, 1=tip) the ball hit const hitPos = ((ball.x - f.x) * cos * f.dir + (ball.y - f.y) * sin * f.dir) / FLIPPER_LEN; // More force at the tip (hitPos closer to 1) const flipForce = 8 + hitPos * 6; // Range: 8-14 ball.vy -= flipForce; ball.vx += (side === 'left' ? 2 : -2) * (1 + hitPos);
Design Choice

The asymmetric lerp rates (0.35 up vs 0.15 down) create a snappy "hit" feel when pressing the flipper, with a lazy gravity-assisted return. This 2:1 ratio is key to satisfying flipper controls.

04 / Particle Effects & Visual Feedback

Every bumper hit and target break spawns burst particles and a floating score popup. Particles are stored in a flat array and updated each frame with simple physics. Their alpha fades over time, and they're removed when life reaches zero.

function spawnParticles(x, y, color, count = 8) { for (let i = 0; i < count; i++) { // Evenly spread angles with slight randomness const angle = (Math.PI * 2 / count) * i + Math.random() * 0.5; const speed = 1.5 + Math.random() * 3; particles.push({ x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1, // Fades from 1 → 0 color, r: 2 + Math.random() * 3 // Varied sizes }); } } // Update: gravity + fade particles.forEach(p => { p.x += p.vx; p.y += p.vy; p.vy += 0.05; // Mini gravity for arcing effect p.life -= 0.03; // ~33 frames of life }); particles = particles.filter(p => p.life > 0);

Score Popups

Floating score text rises from collision points and fades out. This provides immediate visual feedback for points scored without cluttering the HUD. The same life/alpha pattern is used for simplicity.

// Draw with fading alpha ctx.globalAlpha = popup.life; ctx.fillStyle = '#feca57'; ctx.font = '600 8px "Press Start 2P"'; ctx.fillText(popup.text, popup.x, popup.y); ctx.globalAlpha = 1;
Performance Note

Using Array.filter() each frame to remove dead particles is fine for small counts (~50-100). For thousands of particles, a pool-based approach with an active count would avoid GC pressure.

05 / Retro Sound with Web Audio API

Each game event triggers a short synthesized sound using the Web Audio API. By choosing different oscillator types (square, triangle, sawtooth) and frequencies, we create a distinct retro arcade soundscape without loading any audio files.

function playSound(freq, dur, type = 'square') { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = type; // square, triangle, sawtooth, sine osc.frequency.value = freq; // Hz // Envelope: start at volume, fade to silence gain.gain.setValueAtTime(0.08, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime( 0.001, audioCtx.currentTime + dur ); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + dur); } // Sound mapping for game events playBumperSound: freq=[660,550,730,440,880], 0.15s, 'square' playFlipperSound: freq=200, 0.05s, 'triangle' // Short thud playLaunchSound: freq=150, 0.3s, 'sawtooth' // Rising growl playDrainSound: freq=100, 0.5s, 'triangle' // Low sad tone

Bumper Pitch Variation

Each bumper has a unique frequency from a pentatonic-like set [660, 550, 730, 440, 880] Hz. When the ball bounces between bumpers rapidly, this creates a melodic sequence — an emergent musical feature that makes multi-bumper combos satisfying.

Browser Requirement

AudioContext must be initialized after a user gesture (click/touch). The game defers creation to the first launch or flipper press via initAudio(), satisfying autoplay policies on all modern browsers.

06 / Game State & Score Persistence

The game uses a simple state machine with four states: ready, playing, launching, and gameover. State transitions control what the player can do and what gets rendered on screen.

// State machine // ready → Player can charge the launcher // playing → Ball is active, physics running // gameover → Score displayed, waiting for restart let gameState = 'ready'; // State transitions // ready → playing: ball launched (Space release) // playing → ready: ball drains, balls remaining > 0 // playing → gameover: ball drains, balls remaining = 0 // gameover → ready: player presses Space/Launch // High score persistence with localStorage let highScore = parseInt( localStorage.getItem('pinball_high') || '0' ); function addScore(pts) { score += pts; if (score > highScore) { highScore = score; localStorage.setItem('pinball_high', highScore.toString()); } }

Target Reset Bonus

When all five drop targets are cleared, the player earns a 1000-point bonus and all targets reset. This creates a risk/reward loop: skilled players aim for the top of the table to clear targets, risking a drain for big points.

// Check if all targets cleared if (targets.every(t => !t.active)) { addScore(1000); addPopup(W / 2, 120, 'BONUS +1000!'); targets.forEach(t => t.active = true); // Reset all }
Why localStorage

High scores persist across browser sessions with zero backend. The pinball_high key is namespaced to avoid conflicts. For a leaderboard, you'd need a server, but for single-player arcade games, localStorage is the perfect lightweight solution.