TECH DETAILS

Neon Pinball Arcade - Day 30

Advanced Level

Table of Contents

01 / Physics Engine & Ball Dynamics

The neon pinball arcade runs a fixed-timestep physics simulation where the ball moves under constant gravitational acceleration on a tilted playfield. Friction dampens velocity each frame to replicate the drag of a steel ball rolling across a polished surface, while velocity clamping acts as a safety net against tunneling artifacts at high speeds.

// Core physics constants const GRAVITY = 0.18; // Downward acceleration per frame const FRICTION = 0.997; // Velocity damping (1.0 = frictionless) const BOUNCE_DAMPING = 0.55; // Energy retained after wall bounce const MAX_SPEED = 22; // Absolute velocity cap (px/frame) const BALL_RADIUS = 8; // Ball size in pixels // Per-frame physics update for each active ball function updateBallPhysics(ball) { ball.vy += GRAVITY; // Gravity pulls downward ball.vx *= FRICTION; // Horizontal drag ball.vy *= FRICTION; // Vertical drag // Clamp velocity magnitude to prevent tunneling const speed = Math.sqrt(ball.vx ** 2 + ball.vy ** 2); if (speed > MAX_SPEED) { const scale = MAX_SPEED / speed; ball.vx *= scale; ball.vy *= scale; } // Integrate position ball.x += ball.vx; ball.y += ball.vy; }

Why Velocity Clamping Prevents Tunneling

At 60 fps, a ball moving at 22 px/frame traverses at most 22 pixels between collision checks. Since the thinnest game objects (flipper edges, wall segments) are at least 4 pixels wide, the ball can never skip entirely past them in a single frame. Without this cap, a ball accelerated by multiple bumper boosts could reach 50+ px/frame and phase through geometry like a ghost. The bounce damping of 0.55 ensures the ball loses just enough energy per wall hit to keep rallies exciting without infinite bouncing.

Pro Tip

Gravity at 0.18 on a 700px-tall table produces a fast, responsive drop. If you increase gravity, you must also boost flipper force proportionally or the ball will feel too heavy to control. The sweet spot is a 1:50 ratio between gravity and maximum flipper launch force.

02 / Collision Detection System

The neon pinball table relies on two fundamental collision primitives: circle-vs-circle for round bumpers and spinners, and circle-vs-line-segment for angled walls, ramps, and flipper surfaces. Each collision resolves overlap in a single frame by pushing the ball out along the separation normal and reflecting its velocity vector.

Circle vs Circle (Bumpers)

When the distance between the ball center and a bumper center is less than the sum of their radii, we have a collision. The overlap is resolved by pushing the ball outward along the normal vector connecting the two centers. Velocity is then reflected across this normal, and bumpers inject extra energy to keep the ball moving.

function ballVsCircle(ball, cx, cy, cr, boostForce) { const dx = ball.x - cx; const dy = ball.y - cy; const dist = Math.sqrt(dx * dx + dy * dy); const minDist = BALL_RADIUS + cr; if (dist < minDist) { // Compute unit normal from bumper to ball const nx = dx / dist; const ny = dy / dist; // Resolve overlap: push ball outside bumper const overlap = minDist - dist; ball.x += nx * overlap; ball.y += ny * overlap; // Reflect velocity across the normal const dot = ball.vx * nx + ball.vy * ny; ball.vx -= 2 * dot * nx; ball.vy -= 2 * dot * ny; // Bumpers inject energy: push ball away ball.vx += nx * boostForce; ball.vy += ny * boostForce; return true; } return false; }

Circle vs Line Segment (Walls & Flippers)

For walls and flippers, we project the ball's center onto the line segment defined by two endpoints. The projection parameter t tells us where along the segment the closest point falls. If t is within [0, 1] and the distance from the ball to that closest point is less than the ball radius, a collision has occurred. The normal is perpendicular to the segment surface.

function ballVsLine(ball, x1, y1, x2, y2, damping) { const segDx = x2 - x1; const segDy = y2 - y1; const lenSq = segDx * segDx + segDy * segDy; // Project ball center onto segment (t in [0,1]) let t = ((ball.x - x1) * segDx + (ball.y - y1) * segDy) / lenSq; t = Math.max(0, Math.min(1, t)); // Closest point on the segment const closestX = x1 + t * segDx; const closestY = y1 + t * segDy; // Distance from ball to closest point const dx = ball.x - closestX; const dy = ball.y - closestY; const dist = Math.hypot(dx, dy); if (dist < BALL_RADIUS) { // Normal points from wall surface to ball const nx = dx / dist; const ny = dy / dist; // Push ball out of wall ball.x = closestX + nx * BALL_RADIUS; ball.y = closestY + ny * BALL_RADIUS; // Reflect velocity and apply damping const dot = ball.vx * nx + ball.vy * ny; ball.vx -= (1 + damping) * dot * nx; ball.vy -= (1 + damping) * dot * ny; return true; } return false; }
Watch Out

The t clamping to [0, 1] is critical. Without it, the ball would collide with the infinite mathematical line extending beyond the segment's endpoints, causing ghost collisions with invisible walls far from the actual geometry. The Math.max(0, Math.min(1, t)) pattern ensures the closest point is always on the actual segment, not its extension.

03 / Flipper Mechanics & Spring Animation

Flippers are the player's only direct control over the ball. Each flipper is defined by a pivot point, a length, a rest angle, and an active angle. When the player presses a key, the flipper swings from rest to active using spring-like linear interpolation, giving a satisfying snap with organic deceleration.

const flippers = [ { x: 95, y: 620, // Pivot position length: 70, // Flipper arm length angle: 0.4, // Current angle (radians) restAngle: 0.4, // Idle angle: slightly drooping activeAngle: -0.6, // Pressed angle: swings upward dir: 1, // 1 = extends right (left flipper) active: false, color: '#00f0ff' // Neon cyan glow }, { x: 305, y: 620, length: 70, angle: Math.PI - 0.4, restAngle: Math.PI - 0.4, activeAngle: Math.PI + 0.6, dir: -1, // -1 = extends left (right flipper) active: false, color: '#ff00ff' // Neon magenta glow } ]; // Spring-like flipper animation each frame function updateFlipper(f) { 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 Affecting Launch Force

Where the ball strikes along the flipper arm determines the outgoing force. A hit near the tip (parameter close to 1.0) imparts maximum energy, simulating the higher angular velocity at the end of a rotating lever. A hit near the pivot (parameter close to 0.0) gives minimal force. This rewards precise timing and creates skill-based shot-making.

// Compute hit position along flipper (0 = pivot, 1 = tip) const cos = Math.cos(f.angle); const sin = Math.sin(f.angle); const hitPos = Math.max(0, Math.min(1, ((ball.x - f.x) * cos * f.dir + (ball.y - f.y) * sin * f.dir) / f.length )); // More force at the tip, less at the pivot const flipForce = 9 + hitPos * 7; // Range: 9-16 ball.vy -= flipForce; // Launch upward ball.vx += (f.dir === 1 ? 2.5 : -2.5) // Angle slightly outward * (1 + hitPos); // Tip hits go wider
Design Choice

The asymmetric lerp rates (0.35 up vs 0.15 down) create a snappy "hit" feel when the player presses the flipper, followed by a lazy, gravity-assisted return to rest. This 2.3:1 ratio is key to satisfying flipper controls. Making both rates equal produces a mushy, unresponsive feel that kills the arcade sensation.

04 / Multiball & Spinner Mechanics

The neon pinball arcade features a drop target bank that, when fully cleared, activates multiball mode. Multiple balls are spawned simultaneously, and the game must manage independent physics and collision detection for each ball in the update loop. A spinning disc obstacle adds another layer of physics with angular velocity decay.

Drop Targets & Multiball Activation

Five drop targets sit in a row near the top of the table. Each hit knocks one down and increments a counter. When all five are cleared, multiball activates: two additional balls launch from the plunger lane, and a score multiplier engages. The targets then reset for the next cycle.

const dropTargets = [ { x: 120, y: 180, active: true, width: 30, height: 8 }, { x: 160, y: 180, active: true, width: 30, height: 8 }, { x: 200, y: 180, active: true, width: 30, height: 8 }, { x: 240, y: 180, active: true, width: 30, height: 8 }, { x: 280, y: 180, active: true, width: 30, height: 8 } ]; function checkMultiball() { if (dropTargets.every(t => !t.active)) { // All targets cleared: spawn 2 extra balls for (let i = 0; i < 2; i++) { balls.push({ x: 370, y: 550 - i * 30, vx: -3 + Math.random() * 2, vy: -10 - Math.random() * 3, alive: true }); } multiballActive = true; scoreMultiplier = 3; addPopup(W / 2, 100, 'MULTIBALL x3!'); playMultiballSound(); // Reset targets for the next cycle dropTargets.forEach(t => t.active = true); } }

Multiple Ball Management

During multiball, the main update loop iterates over all active balls. Each ball gets independent physics, collision detection, and rendering. When a ball drains, it is marked as dead. Multiball ends when only one ball remains, and the multiplier resets to 1.

// Main update loop handles all active balls function updateGame() { balls.forEach(ball => { if (!ball.alive) return; updateBallPhysics(ball); checkWallCollisions(ball); checkBumperCollisions(ball); checkFlipperCollisions(ball); checkSpinnerCollision(ball); checkDropTargets(ball); // Ball drained below table if (ball.y > H + 20) { ball.alive = false; } }); // Remove dead balls balls = balls.filter(b => b.alive); // Multiball ends when only one ball left if (multiballActive && balls.length <= 1) { multiballActive = false; scoreMultiplier = 1; } }

Spinner Physics

The spinner is a disc that rotates when the ball passes through it. Angular velocity is applied based on the ball's horizontal speed, and friction decays it over time. The spinner awards points proportional to its rotation speed.

const spinner = { x: 200, y: 250, angle: 0, angularVel: 0, decay: 0.97 // Angular friction per frame }; function checkSpinnerCollision(ball) { const dist = Math.hypot(ball.x - spinner.x, ball.y - spinner.y); if (dist < BALL_RADIUS + 20) { // Transfer ball's horizontal momentum to spin spinner.angularVel += ball.vx * 0.15; const pts = Math.floor(Math.abs(spinner.angularVel) * 10); addScore(pts * scoreMultiplier); playSpinnerSound(spinner.angularVel); } } // Decay spinner each frame spinner.angularVel *= spinner.decay; spinner.angle += spinner.angularVel; if (Math.abs(spinner.angularVel) < 0.01) { spinner.angularVel = 0; // Dead zone to prevent creep }
Pro Tip

The spinner's decay rate of 0.97 means it retains 97% of its angular velocity each frame, taking roughly 100 frames (~1.7 seconds at 60fps) to wind down from a fast spin. Tuning this value controls how long the spinner "rings" after a hit, and a value too close to 1.0 makes it feel like it never stops.

05 / Neon Visual Effects & Particle System

The neon aesthetic is achieved entirely through Canvas 2D rendering techniques. By leveraging shadowBlur and shadowColor, every element emits a soft glow that creates the illusion of neon lighting on a dark playfield. The particle system adds explosive visual feedback, and ball trails leave glowing afterimages.

Canvas Neon Glow Effect

The core neon look comes from the Canvas shadow API. Setting a large shadowBlur and a bright shadowColor before drawing any shape wraps it in a diffuse glow. Multiple draw passes at different blur levels create a layered, realistic neon tube appearance.

function drawNeonCircle(x, y, radius, color) { ctx.save(); // Outer glow layer (wide, dim) ctx.shadowBlur = 25; ctx.shadowColor = color; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); // Inner core (bright white center) ctx.shadowBlur = 10; ctx.shadowColor = '#ffffff'; ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; ctx.beginPath(); ctx.arc(x, y, radius * 0.5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } // Draw neon line (for walls, flippers) function drawNeonLine(x1, y1, x2, y2, color, width) { ctx.save(); ctx.shadowBlur = 15; ctx.shadowColor = color; ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.restore(); }

Particle Burst System

Every bumper hit and target break spawns a burst of particles that fan out in all directions, arc under gravity, and fade out. Particles carry their own velocity, color, size, and life counter. Each frame they are updated and drawn as small glowing circles.

function spawnParticles(x, y, color, count = 12) { for (let i = 0; i < count; i++) { const angle = (Math.PI * 2 / count) * i + Math.random() * 0.5; const speed = 2 + Math.random() * 4; particles.push({ x: x, y: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1.0, // Fades 1.0 → 0 color: color, radius: 2 + Math.random() * 3 }); } } // Update particles: gravity, fade, and draw function updateParticles() { particles.forEach(p => { p.x += p.vx; p.y += p.vy; p.vy += 0.06; // Mini gravity for arcing p.life -= 0.025; // ~40 frames of life }); particles = particles.filter(p => p.life > 0); }

Ball Trail Rendering

A trailing afterimage follows each ball, creating a glowing comet effect. The trail stores the last N positions and renders them as progressively smaller, more transparent circles, producing a motion blur without any post-processing.

const TRAIL_LENGTH = 8; function drawBallTrail(ball) { for (let i = 0; i < ball.trail.length; i++) { const pos = ball.trail[i]; const alpha = (i / ball.trail.length) * 0.4; const r = BALL_RADIUS * (i / ball.trail.length); ctx.save(); ctx.globalAlpha = alpha; ctx.shadowBlur = 10; ctx.shadowColor = '#00f0ff'; ctx.fillStyle = '#00f0ff'; ctx.beginPath(); ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } } // Update trail: push current position, trim to max length ball.trail.push({ x: ball.x, y: ball.y }); if (ball.trail.length > TRAIL_LENGTH) { ball.trail.shift(); }

Animated Neon Decorations

Static table decorations pulse by oscillating their shadowBlur and alpha using a sine wave tied to the frame count. This creates a gentle breathing effect across the entire table that reinforces the neon atmosphere without any extra draw calls.

// Pulsing glow intensity for neon elements const pulse = 0.7 + Math.sin(frameCount * 0.05) * 0.3; ctx.shadowBlur = 20 * pulse; ctx.globalAlpha = 0.6 + pulse * 0.4;
Performance Note

Using Array.filter() each frame to cull dead particles works for modest counts (under ~150). For heavier effects, a fixed-size pool with an active index avoids garbage collection pressure. Similarly, shadowBlur is expensive on some GPUs; capping it at 25px and batching neon elements by color reduces draw state changes.

06 / Web Audio Synthesis & Score State

The neon pinball arcade generates all sound effects in real-time using the Web Audio API. Each game event triggers a short synthesized tone via an OscillatorNode, producing a unique soundscape without loading any audio files. The game state machine manages transitions between modes, and high scores persist across sessions with localStorage.

OscillatorNode Sound Synthesis

Different oscillator waveform types create distinct timbres for each game event. Square waves produce sharp, retro bumper pings. Triangle waves give soft flipper thuds. Sawtooth waves create aggressive launch growls. Sine waves are used for gentle notification tones.

function playSound(freq, duration, type = 'square', vol = 0.08) { if (!audioCtx) return; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = type; // 'square'|'triangle'|'sawtooth'|'sine' osc.frequency.value = freq; // Pitch in Hz // Amplitude envelope: attack at vol, decay to silence gain.gain.setValueAtTime(vol, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime( 0.001, audioCtx.currentTime + duration ); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + duration); } // Sound map for game events function playBumperSound(idx) { const freqs = [660, 550, 730, 440, 880]; playSound(freqs[idx % freqs.length], 0.15, 'square'); } function playFlipperSound() { playSound(200, 0.05, 'triangle'); // Short thud } function playLaunchSound() { playSound(150, 0.3, 'sawtooth'); // Rising growl } function playDrainSound() { playSound(100, 0.5, 'triangle'); // Low sad tone } function playMultiballSound() { playSound(440, 0.1, 'square'); setTimeout(() => playSound(660, 0.1, 'square'), 100); setTimeout(() => playSound(880, 0.2, 'square'), 200); }

AudioContext Lazy Initialization

Modern browsers require a user gesture before allowing audio playback. The AudioContext is created lazily on the first player interaction (launch or flipper press), satisfying autoplay policies on all platforms including iOS Safari.

let audioCtx = null; function initAudio() { if (audioCtx) return; audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } // Called on first user gesture function onFirstInteraction() { initAudio(); if (audioCtx.state === 'suspended') { audioCtx.resume(); // Required on some browsers } }

Game State Machine

The game uses a three-state machine: ready (waiting for launch), playing (ball active, physics running), and gameover (all balls lost, showing final score). Each state controls what input is accepted and what gets rendered.

let gameState = 'ready'; let ballsRemaining = 3; let score = 0; let scoreMultiplier = 1; // State transitions: // ready → playing: Space released (ball launched) // playing → ready: ball drains, ballsRemaining > 0 // playing → gameover: ball drains, ballsRemaining === 0 // gameover → ready: player presses Space (new game) function onBallDrain() { ballsRemaining--; if (ballsRemaining > 0) { gameState = 'ready'; resetBallToPlunger(); } else { gameState = 'gameover'; saveHighScore(); playDrainSound(); } }

High Score Persistence & Multiplier

High scores are saved to localStorage using a namespaced key. The scoring system applies a multiplier during multiball mode, tripling all points while multiple balls are in play. This creates a high-risk, high-reward dynamic since keeping multiple balls alive is harder but scores compound quickly.

// Load persisted high score let highScore = parseInt( localStorage.getItem('neon_pinball_high') || '0' ); function addScore(pts) { // Apply multiball multiplier const earned = pts * scoreMultiplier; score += earned; // Check and persist high score if (score > highScore) { highScore = score; localStorage.setItem( 'neon_pinball_high', highScore.toString() ); } return earned; } function saveHighScore() { localStorage.setItem( 'neon_pinball_high', Math.max(highScore, score).toString() ); } // Reset for new game function newGame() { score = 0; ballsRemaining = 3; scoreMultiplier = 1; multiballActive = false; gameState = 'ready'; }
Browser Requirement

AudioContext must be initialized after a user gesture (click, touch, or keypress). The game defers creation to the first launch or flipper press via initAudio(), satisfying autoplay policies on all modern browsers including mobile Safari. If the context enters a suspended state, resume() is called to reactivate it.

Why localStorage

High scores persist across browser sessions with zero backend infrastructure. The neon_pinball_high key is namespaced to avoid collisions with other games on the same domain. For a global leaderboard you would need a server, but for single-player arcade games, localStorage is the ideal lightweight persistence layer.