Retro Pinball - Day 29
Advanced LevelThe 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;
}
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.
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.
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.
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;
}
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;
}
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.
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;
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);
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.
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);
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;
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.
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
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.
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.
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());
}
}
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
}
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.