---
title: "Building a 60 FPS Interactive Wheel of Fortune in HTML5 Canvas & GSAP: Friction, Physics & Sound Triggering"
description: "How to build a high-performance 60 FPS wheel of fortune using HTML5 2D Canvas, GSAP custom easing, physics damping, and Web Audio tick synchronization."
tags: ["javascript", "webdev", "canvas", "frontend"]
canonical_url: "https://entscheidomat.com/ratgeber/gluecksrad-online-drehen"
target_keywords: ["glücksrad online", "glücksrad drehen", "entscheidungsrad online", "canvas wheel of fortune", "gsap wheel animation"]
---

# Building a 60 FPS Interactive Wheel of Fortune in HTML5 Canvas & GSAP: Friction, Physics & Sound Triggering

Interactive spinners and wheels of fortune are among the most engaging UI components on the web. From marketing giveaways to decision utilities like a [Glücksrad online](https://entscheidomat.com/gluecksrad), a well-designed wheel needs to look smooth, feel physically grounded, and land accurately on its selected segment without visual stuttering.

However, naive implementations using CSS rotations or DOM elements (`<div>` slices rotated around a pivot) quickly suffer from performance degradation, text blurriness, and dynamic segment layout bugs when scaling beyond 8–10 items.

In this article, we will build a production-ready, 60 FPS interactive **HTML5 2D Canvas Wheel of Fortune** integrated with **GSAP (GreenSock)**, custom physics deceleration, and real-time Web Audio tick sound effects.

---

## 1. The Physics of Rotational Friction & Segment Indexing

To make a digital wheel feel tangible, its deceleration must mimic physical rotational friction.

### Rotational Physics Equations
When a force spins a wheel, it acquires an initial angular velocity $\omega_0$ (radians per second). Under constant angular friction $\alpha$, its angular displacement $\theta(t)$ over time $t$ is:

$$\theta(t) = \omega_0 t - \frac{1}{2} \alpha t^2$$

In GSAP, we can model this friction curve smoothly using `power4.out` or `cubic-bezier(0.25, 1, 0.5, 1)` easing.

### Calculating Segment Index from Final Angle
Suppose a wheel has $N$ segments, each occupying an arc angle of $\Delta \theta = \frac{2\pi}{N}$ radians ($360^\circ / N$).

If the wheel settles at a total cumulative rotation angle $\theta_{\text{total}}$ (in degrees), and the pointer is located at the top ($270^\circ$ or $90^\circ$ offset depending on canvas coordinate space), the winning segment index $I_{\text{win}}$ is calculated as:

$$I_{\text{win}} = \left\lfloor \frac{(360 - (\theta_{\text{total}} \bmod 360) + \text{offset}) \bmod 360}{360 / N} \right\rfloor$$

---

## 2. Drawing Responsive Canvas Arcs in TypeScript

Below is the core HTML5 2D Canvas renderer. It handles dynamic segment counts, vibrant color palettes, crisp text rendering, and high-DPI (Retina) display scaling.

```typescript
export interface WheelSegment {
  label: string;
  color: string;
}

export class CanvasWheelRenderer {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private segments: WheelSegment[];

  constructor(canvas: HTMLCanvasElement, segments: WheelSegment[]) {
    this.canvas = canvas;
    const context = canvas.getContext("2d");
    if (!context) throw new Error("Could not get 2D context");
    this.ctx = context;
    this.segments = segments;
    this.setupHighDPI();
  }

  private setupHighDPI(): void {
    const dpr = window.devicePixelRatio || 1;
    const rect = this.canvas.getBoundingClientRect();
    this.canvas.width = rect.width * dpr;
    this.canvas.height = rect.height * dpr;
    this.ctx.scale(dpr, dpr);
  }

  /**
   * Renders the wheel at a given rotation angle (in degrees).
   */
  public draw(rotationAngleDeg: number): void {
    const rect = this.canvas.getBoundingClientRect();
    const width = rect.width;
    const height = rect.height;
    const centerX = width / 2;
    const centerY = height / 2;
    const radius = Math.min(centerX, centerY) - 10;

    const numSegments = this.segments.length;
    const arcAngle = (2 * Math.PI) / numSegments;
    const rotationRad = (rotationAngleDeg * Math.PI) / 180;

    this.ctx.clearRect(0, 0, width, height);
    this.ctx.save();
    this.ctx.translate(centerX, centerY);
    this.ctx.rotate(rotationRad);

    // 1. Draw Segments
    for (let i = 0; i < numSegments; i++) {
      const startAngle = i * arcAngle;
      const endAngle = startAngle + arcAngle;

      this.ctx.beginPath();
      this.ctx.moveTo(0, 0);
      this.ctx.arc(0, 0, radius, startAngle, endAngle);
      this.ctx.closePath();

      this.ctx.fillStyle = this.segments[i].color;
      this.ctx.fill();
      this.ctx.lineWidth = 2;
      this.ctx.strokeStyle = "#ffffff";
      this.ctx.stroke();

      // 2. Draw Text Labels
      this.ctx.save();
      this.ctx.rotate(startAngle + arcAngle / 2);
      this.ctx.textAlign = "right";
      this.ctx.fillStyle = "#ffffff";
      this.ctx.font = "bold 14px sans-serif";
      this.ctx.shadowColor = "rgba(0,0,0,0.5)";
      this.ctx.shadowBlur = 4;
      this.ctx.fillText(this.segments[i].label, radius - 20, 5);
      this.ctx.restore();
    }

    this.ctx.restore();

    // 3. Draw Fixed Top Pointer (Indicator)
    this.drawPointer(centerX, centerY - radius);
  }

  private drawPointer(x: number, y: number): void {
    this.ctx.save();
    this.ctx.beginPath();
    this.ctx.moveTo(x - 12, y - 10);
    this.ctx.lineTo(x + 12, y - 10);
    this.ctx.lineTo(x, y + 15);
    this.ctx.closePath();
    this.ctx.fillStyle = "#ef4444";
    this.ctx.fill();
    this.ctx.strokeStyle = "#ffffff";
    this.ctx.lineWidth = 2;
    this.ctx.stroke();
    this.ctx.restore();
  }
}
```

---

## 3. Integrating GSAP Animation & Web Audio Ticks

To achieve 60 FPS animation with dynamic audio feedback, we hook GSAP's `gsap.to()` tween to our Canvas renderer's `draw()` method. Every time the rotation crosses a segment boundary, we trigger a short Web Audio tick sound.

```typescript
import gsap from "gsap";

export class InteractiveWheelController {
  private renderer: CanvasWheelRenderer;
  private currentRotation: number = 0;
  private numSegments: number;
  private lastTickSegment: number = -1;
  private audioCtx?: AudioContext;

  constructor(renderer: CanvasWheelRenderer, numSegments: number) {
    this.renderer = renderer;
    this.numSegments = numSegments;
  }

  private playTickSound(): void {
    if (!this.audioCtx) {
      this.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
    }
    if (this.audioCtx.state === "suspended") {
      this.audioCtx.resume();
    }

    const osc = this.audioCtx.createOscillator();
    const gain = this.audioCtx.createGain();

    osc.type = "triangle";
    osc.frequency.setValueAtTime(600, this.audioCtx.currentTime);
    osc.frequency.exponentialRampToValueAtTime(150, this.audioCtx.currentTime + 0.03);

    gain.gain.setValueAtTime(0.3, this.audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + 0.03);

    osc.connect(gain);
    gain.connect(this.audioCtx.destination);

    osc.start();
    osc.stop(this.audioCtx.currentTime + 0.03);
  }

  public spinToSegment(winningIndex: number, onComplete?: () => void): void {
    const segmentAngle = 360 / this.numSegments;
    
    // Target angle calculation: Full spins (5 rotations) + segment offset
    const fullSpins = 5 * 360;
    // Align winning segment to top pointer (270 degrees)
    const targetSegmentOffset = 270 - (winningIndex * segmentAngle + segmentAngle / 2);
    
    // Normalize target angle
    const targetRotation = this.currentRotation + fullSpins + (targetSegmentOffset - (this.currentRotation % 360));

    gsap.to(this, {
      currentRotation: targetRotation,
      duration: 4.5,
      ease: "power4.out",
      onUpdate: () => {
        // Redraw Canvas
        this.renderer.draw(this.currentRotation);

        // Calculate tick boundaries
        const currentSegment = Math.floor((this.currentRotation % 360) / segmentAngle);
        if (currentSegment !== this.lastTickSegment) {
          this.playTickSound();
          this.lastTickSegment = currentSegment;
        }
      },
      onComplete: () => {
        if (onComplete) onComplete();
      }
    });
  }
}
```

---

## 4. Performance Optimization Checklist

| Optimization | Method | Impact |
| :--- | :--- | :--- |
| **High-DPI Retina Displays** | `canvas.width = width * devicePixelRatio` | Prevents blurry text on iPhones/Macs |
| **Procedural Audio** | Web Audio Oscillators instead of MP3 files | 0ms audio latency, zero network requests |
| **Single Canvas Pipeline** | Direct 2D context drawing over DOM elements | Constant 60 FPS performance regardless of segment count |
| **GSAP Power4.out** | Realistic friction deceleration curve | Natural physical wheel feel |

---

## Summary & Live Demo

1. HTML5 Canvas 2D is significantly faster and cleaner than rotating DOM elements for wheels with dynamic segment counts.
2. Combine **GSAP `power4.out` easing** with **Web Audio API procedural sound ticks** for maximum user delight.
3. Calculate winning segment indices mathematically beforehand to guarantee deterministic UI outcomes.

Test an interactive decision wheel live on [Entscheidomat Glücksrad Online](https://entscheidomat.com/gluecksrad).

---

## FAQ (Schema Structured Data)

```json
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Why use HTML5 Canvas instead of CSS for a Wheel of Fortune?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "HTML5 Canvas renders all segments and text in a single 60 FPS draw call, eliminating DOM bloat, layout shifts, and blurry text rendering on high-DPI displays."
      }
    },
    {
      "@type": "Question",
      "name": "How do you trigger tick sound effects on a digital wheel?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "By monitoring the rotation angle during animation updates and triggering a short Web Audio API oscillator burst whenever the angle crosses a segment boundary angle."
      }
    }
  ]
}
```
