← Blog
PhysicsCanvasGame Development

Pinball Physics Simulation — Implementing Flippers, Bumpers, and Gravity with Canvas API

Updated May 27, 202618 min read

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.

View topic page

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 = 8

Constants 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 * dt

This 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.67

Math.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 * segDy

t 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.5

LAUNCHER_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:

  1. Different bumper rebound model: physics.ts is impulse-based (depends on incident velocity), but bumpers need constant-velocity rebound
  2. Flippers are rotating bodies: The Body type lacks rotation angle and angular velocity, making it unable to express flipper line-segment collision
  3. 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."