Files
entscheidomat/posts/08-building-lightweight-wheel-of-fortune-canvas.md
2026-08-05 19:33:11 +02:00

9.9 KiB
Raw Blame History

title, description, tags, canonical_url, target_keywords
title description tags canonical_url target_keywords
Building a 60 FPS Interactive Wheel of Fortune in HTML5 Canvas & GSAP: Friction, Physics & Sound Triggering 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.
javascript
webdev
canvas
frontend
https://entscheidomat.com/ratgeber/gluecksrad-online-drehen
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, 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 810 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.

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.

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.


FAQ (Schema Structured Data)

{
  "@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."
      }
    }
  ]
}