Prize Wheel — Spin, Decelerate, Land & Celebrate
- TypeGPU
- WebGPU
- Reanimated
- Haptics
A prize wheel that decides the result first and then drives to it mechanically. Working backwards from the answer is what lets it stop cleanly on a wedge, and the pointer flapper is simulated rather than keyframed so it reacts to the wheel's actual speed.
Purchases are not open yet.
The winner is chosen before the wheel turns
Odds come from the prize itself: a wedge's weight is 100 divided by the square root of its value, so a 5,000 lands far less often than a 50 without anyone writing a probability table. The target is then measured from wherever the wheel is now — the angle is never reset, so pressing again mid-spin continues from the interrupted pose instead of snapping.
function weightFor(prize: number): number { return 100 / Math.sqrt(prize);}// …const safeJitter = Math.max(-0.4, Math.min(0.4, jitter)) * wedge.sweep;const targetPhase = positiveModulo(POINTER_ANGLE - wedge.centerAngle + safeJitter, TWO_PI);const startPhase = positiveModulo(startRotation, TWO_PI);const remainingArc = positiveModulo(targetPhase - startPhase, TWO_PI);return startRotation + Math.max(0, Math.floor(fullTurns)) * TWO_PI + remainingArc;A short drive, then a long friction tail
Speed climbs linearly for the first 14% of the spin so the press gets an immediate answer, then decays on a quadratic tail for the rest. The two areas under that velocity curve are computed and normalised together, which is what makes position and speed continuous at the join and guarantees the wheel arrives at exactly its target rather than near it.
const accelerationArea = ACCELERATION_FRACTION / 2;const decelerationArea = (1 - ACCELERATION_FRACTION) / (DECELERATION_POWER + 1);const peak = 1 / (accelerationArea + decelerationArea);if (unit <= ACCELERATION_FRACTION) { return peak * unit * unit / (2 * ACCELERATION_FRACTION);}const remaining = (1 - unit) / (1 - ACCELERATION_FRACTION);The flapper is a spring, not a keyframe
The pointer runs as a damped harmonic oscillator integrated in closed form each frame. Its rest position is loaded by the square of the wheel's speed and saturates, so a fast wheel holds the flapper deflected without one peg ever throwing it to a silly angle. A damping ratio of 0.65 leaves it underdamped, which is what preserves the last rebound after the final peg.
const omega = FLAPPER_NATURAL_FREQUENCY;const damping = FLAPPER_DAMPING_RATIO * omega;const dampedFrequency = omega * Math.sqrt(1 - FLAPPER_DAMPING_RATIO ** 2);// …const speedSquared = Math.max(0, angularSpeed) ** 2;return FLAPPER_MAX_SPEED_EQUILIBRIUM * speedSquared / (speedSquared + FLAPPER_EQUILIBRIUM_HALF_SPEED_SQUARED);<SpinWheelPrize params={{ segments: 8, spinPower: 10, accent: "#FF3B6B" }} fireSignal={0} />