Neon Pinball Arcade - Day 30
Advanced LevelThe 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;
}
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.
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.
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.
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;
}
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;
}
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.
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;
}
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
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.
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.
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);
}
}
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;
}
}
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
}
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.
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.
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();
}
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);
}
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();
}
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;
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.
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.
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);
}
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
}
}
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 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';
}
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.
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.