Pinball Physics Simulation — Implementing Flippers, Bumpers, and Gravity with Canvas API
Go deeper on this topic
Browser Game Development
Game loop design, collision detection, pathfinding, and tooling for browser games
Article 7 of 7 in this series.
Next reads
Browser Game Developer Setup 2026 — The Toolchain Behind 203 Components
A tour of the development environment for building 203 browser games and interactions. Claude Code-centric workflow, bun + Biome + Vite, and parallel development with Git Worktree.
Building a Breakout Game in the Browser — Mastering Reflection Angles and Level Design with Canvas API
Implementing a Breakout game using Canvas API and requestAnimationFrame. Covers paddle reflection angle calculation, block layout level design, and score management through component implementation.
Comparing Maze Generation Algorithms — Structural Differences Between Recursive Backtracking, Kruskal, and Prim
Comparing the characteristics of multiple maze generation algorithms including recursive backtracking, Kruskal's, and Prim's, and the structural differences in the mazes they produce.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Physical Elements of a Pinball Machine
Pinball looks simple but is a game where multiple distinct physics models coexist. The ball experiences gravity and damping, bumpers provide constant-velocity rebound, and flippers act as rotating bodies that launch the ball. Walls serve as boundary constraints, and the drain triggers game over. All of this is packed into a 320x480 logical pixel space.
const W = 320
const H = 480
const BALL_R = 6
const GRAVITY = 0.15
const DAMPING = 0.98
const BUMPER_BOUNCE = 8
const FLIPPER_LEN = 50
const FLIPPER_W = 8Constants are defined in logical pixels, and a scale value obtained via ResizeObserver is applied during Canvas API rendering. This design keeps physics calculations independent of device resolution.
Ball Motion: Gravity, Damping, and Integration
The ball update is four lines per frame:
ball.vy += GRAVITY * dt
ball.vx *= DAMPING
ball.vy *= DAMPING
ball.x += ball.vx * dt
ball.y += ball.vy * dtThis follows the same structure as the symplectic Euler method in physics.ts, but Pinball applies damping directly to velocity (DAMPING = 0.98). Rather than explicitly modeling air resistance, reducing velocity by 2% each frame is cheaper to compute and looks natural enough.
The gravity constant of 0.15 is small because dt is normalized as a frame ratio (rawDt / 16.67) rather than being millisecond-based. At 60fps, dt ≈ 1.0, so GRAVITY * dt directly becomes the per-frame acceleration.
const rawDt = time - lastTimeRef.current
const dt = Math.min(rawDt, 32) / 16.67Math.min(rawDt, 32) clamps the large dt that occurs when a tab was inactive to 32ms. Without this, the ball tunnels through walls when returning to the tab.
Bumper Collision Detection and Rebound
All bumpers are circles, so they're handled with circle-circle collision detection:
for (const bumper of bumpers) {
const dx = ball.x - bumper.x
const dy = ball.y - bumper.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minDist = BALL_R + bumper.r
if (dist < minDist && dist > 0) {
const nx = dx / dist
const ny = dy / dist
// Separate positions
ball.x = bumper.x + nx * minDist
ball.y = bumper.y + ny * minDist
// Set velocity to fixed value in normal direction
ball.vx = nx * BUMPER_BOUNCE
ball.vy = ny * BUMPER_BOUNCE
}
}There's a reason I didn't use the impulse-based response from physics.ts here. Bumpers are devices that "bounce the ball back at a constant velocity upon contact," giving a velocity of BUMPER_BOUNCE = 8 regardless of incident velocity. Real pinball bumpers contain solenoids that detect contact and fire electromagnetically, so a constant-velocity rebound model is more accurate than a coefficient-of-restitution model.
Position separation uses ball.x = bumper.x + nx * minDist, placing the ball flush against the bumper surface. Instead of impulse-based separation (partitioned by mass ratio), the bumper is a static object so the ball is moved 100%.
Flipper Rotation and Ball Launch
Flippers are modeled as rotating line segments. They extend from a pivot point to a length of FLIPPER_LEN.
Flipper Angle Control
const FLIPPER_REST_ANGLE = 0.45 // lowered state (radians)
const FLIPPER_UP_ANGLE = -0.55 // raised state
const FLIPPER_SPEED = 0.25
// Per-frame angle update
for (const f of [flipL, flipR]) {
const diff = f.targetAngle - f.angle
if (Math.abs(diff) > 0.01) {
f.angle += diff * FLIPPER_SPEED * dt * 3
} else {
f.angle = f.targetAngle
}
}targetAngle switches immediately on key input, but the actual angle follows at a fixed fraction of the difference (exponential decay). This reproduces the snappy "whack" motion when a key is pressed. The coefficient of 3 in FLIPPER_SPEED * dt * 3 was tuned by feel -- too fast and the physical "wind-up" disappears, too slow and it can't hit the ball.
Flipper-Ball Collision
Since flippers are line segments with width, collision detection with the ball requires finding the "closest point on the line segment to a point":
const cosA = Math.cos(f.angle)
const sinA = Math.sin(f.angle)
const dir = f.side === 'left' ? 1 : -1
const endX = f.pivotX + cosA * FLIPPER_LEN * dir
const endY = f.pivotY + sinA * FLIPPER_LEN * dir
// Project ball onto line segment
const segDx = endX - f.pivotX
const segDy = endY - f.pivotY
const segLen = Math.sqrt(segDx * segDx + segDy * segDy)
const t = Math.max(0, Math.min(1,
((ball.x - f.pivotX) * segDx + (ball.y - f.pivotY) * segDy)
/ (segLen * segLen)
))
const closestX = f.pivotX + t * segDx
const closestY = f.pivotY + t * segDyt is the parameter for the closest point on the line segment, where 0 is the pivot and 1 is the tip. Clamping with Math.max(0, Math.min(1, ...)) ensures no collision occurs along the segment's extension.
If the distance from the closest point to the ball center is less than BALL_R + FLIPPER_W / 2, it's a collision:
if (cDist < hitDist && cDist > 0) {
const nx = cdx / cDist
const ny = cdy / cDist
ball.x = closestX + nx * hitDist
ball.y = closestY + ny * hitDist
// Extra boost if flipper is rotating
const isFlipping = Math.abs(f.targetAngle - f.angle) > 0.1
const flipBoost = isFlipping ? 6 : 0
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy)
const bounceSpeed = Math.max(speed * 0.7, 3) + flipBoost
ball.vx = nx * bounceSpeed
ball.vy = ny * bounceSpeed
}This is the most important design decision in the implementation. To accurately calculate the energy transferred from the flipper's angular velocity to the ball, you'd need to find the tangential velocity at the collision point and compute the impulse. However, that level of accuracy doesn't contribute to the gameplay experience. Instead, I check isFlipping (whether the flipper is rotating) and add a discrete flipBoost = 6 during rotation. It's sufficient as long as the player feels "hitting with good timing makes it fly harder."
Guide Walls and Reflection
The lower part of the pinball table has angled guide walls that direct the ball toward the flippers. These also use line-segment-to-ball collision detection:
const guideCollision = (
gx1: number, gy1: number,
gx2: number, gy2: number,
) => {
// Projection onto line segment (same algorithm as flippers)
// ...
if (gDist < BALL_R + 3 && gDist > 0) {
const gnx = gcDx / gDist
const gny = gcDy / gDist
ball.x = gcx + gnx * (BALL_R + 3)
ball.y = gcy + gny * (BALL_R + 3)
// Velocity reflection
const dot = ball.vx * gnx + ball.vy * gny
ball.vx -= 2 * dot * gnx * 0.6
ball.vy -= 2 * dot * gny * 0.6
}
}The difference from flippers is in velocity calculation. Guide walls are stationary, so a reflection model that inverts the normal velocity component (v' = v - 2(v · n)n) is used. The 0.6 is an energy loss coefficient, preventing the ball from bouncing forever (which would happen with perfect reflection at 1.0).
Launcher Charging and Firing
A charge-based launcher was implemented for the initial ball launch:
// Charging (while space key is held)
if (chargingRef.current && currentPhase === 'launching') {
chargeRef.current = Math.min(
chargeRef.current + LAUNCHER_CHARGE_RATE * dt,
LAUNCHER_MAX,
)
}
// Firing (when space key is released)
const power = Math.max(LAUNCHER_MIN, chargeRef.current)
ballRef.current.vy = -power
ballRef.current.vx = -0.5LAUNCHER_MIN = 6 is the minimum launch velocity. It guarantees the ball reaches the bumpers even with a brief key tap. The slight horizontal velocity of vx = -0.5 is a design touch that helps the ball naturally exit the launcher lane into the playing field.
Wall Boundary Constraints
Wall collision is a simple AABB constraint:
// Left wall
if (ball.x - BALL_R < WALL_THICK) {
ball.x = WALL_THICK + BALL_R
ball.vx = Math.abs(ball.vx) * 0.8
}
// Right wall
if (ball.x + BALL_R > cw - WALL_THICK) {
ball.x = cw - WALL_THICK - BALL_R
ball.vx = -Math.abs(ball.vx) * 0.8
}The 0.8 damping corresponds to the wall material's coefficient of restitution. While physics.ts's constrainToBounds uses body.restitution, I used inline implementation for Pinball because I wanted different values for different walls.
The bottom (drain) has no wall. ball.y > ch + BALL_R * 2 detects that the ball has left the screen, triggering the ball-loss logic.
Canvas Rendering Scaling
Physics calculations use a 320x480 logical space, with scaling applied at render time:
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect
const scale = width / W
scaleRef.current = scale
const h = H * scale
resizeCanvas(canvas, width, h)
}
})
// Render loop
ctx.save()
ctx.scale(dpr * scale, dpr * scale)
// ... draw in 320x480 coordinate system
ctx.restore()resizeCanvas is a utility that sets canvas.width = width * dpr. ctx.scale(dpr * scale, ...) applies both device pixel ratio and CSS scale at once. Since physics coordinates can be passed directly to drawing functions, coordinate transformation bugs are less likely to creep in.
Why physics.ts Wasn't Used
sakimytocom has a general-purpose physics.ts, but I deliberately didn't use it for Pinball. Three reasons:
- Different bumper rebound model:
physics.tsis impulse-based (depends on incident velocity), but bumpers need constant-velocity rebound - Flippers are rotating bodies: The
Bodytype lacks rotation angle and angular velocity, making it unable to express flipper line-segment collision - Custom collision responses: Guide walls (reflection + damping), flippers (rotation boost), and bumpers (constant-velocity rebound) each need different responses
Rather than forcing extensions to a general-purpose engine, writing game-specific physics inline provides better clarity. As emphasized in The Nature of Code, physics simulation should aim for "convincingness" rather than "accuracy." The goal is to achieve behavior that feels good to the player with minimal code.
Summary: Technologies and Tools Used for Pinball Physics
Three books supported the design decisions for game physics. Game Programming in C++ provided collision response implementation patterns, Mathematics and Physics for Game Development solidified vector calculation fundamentals, and The Nature of Code taught the concept of "convincingness in simulation."