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

271 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 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.
```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."
}
}
]
}
```