A while back I needed a wheel for picking a raffle winner from a list of names. My mental model going in was basically a slot machine: roll a weighted random number, figure out which name that number lands on, then animate the wheel spinning around until it visually lines up with that pre-chosen name. Reasonable assumption — that's how I'd have built it.
That's not what happens. Building the random-wheel tool, I found the actual sequence is backwards: the wheel spins to a random angle first, and only after it physically stops does the code check which segment happens to be sitting under the pointer. The winner isn't decided — it's discovered.
The wheel doesn't know who won until after it stops
The tool hands its segment list to Winwheel.js, a canvas wheel library, configured like this:
this.theWheel = new Winwheel({
outerRadius: this.canvasWdith / 2,
textFontSize: 24,
textOrientation: "vertical",
textAlignment: "outer",
numSegments: this.segments.length,
segments: this.segments,
animation: {
type: "spinToStop",
duration: 10,
spins: 3,
callbackFinished: this.alertPrize,
},
});
Nowhere does this app set animation.stopAngle. That matters, because inside the library, when stopAngle is left unset, it fills it in itself with a plain random number:
this.animation._stopAngle = (this.animation.stopAngle == null)
? Math.floor(359 * Math.random())
: 360 - this.animation.stopAngle + this.pointerAngle;
The final rotation the wheel animates to is just spins * 360 + _stopAngle — some number of full turns, plus that random leftover angle. TweenMax eases the rotationAngle property from its current value to that target over the 10-second duration, using a decelerating curve so it looks like it's slowing down naturally. Only once the tween finishes does callbackFinished fire, and only then does the library work out which segment is physically under the fixed pointer by taking the final rotation modulo 360 and comparing it against each segment's angular range. That result — whatever segment happens to be there — is what gets handed to the app's alertPrize function. There's no "chosen winner" object anywhere before that point. The randomness lives entirely in one Math.random() call buried in a third-party script tag (/Winwheel.min.js), loaded outside the normal build pipeline — which is an easy thing to miss if you only read the Vue component and assume the picking logic is in there.
"Wheel speed" doesn't change the odds — it changes how long you have to wait
The UI lets you pick slow/medium/fast before spinning:
if (this.wheelPower == 1) {
this.theWheel.animation.spins = 3;
} else if (this.wheelPower == 2) {
this.theWheel.animation.spins = 9;
} else if (this.wheelPower == 3) {
this.theWheel.animation.spins = 30;
}
More "power" just means more full 360° turns before the wheel settles, all crammed into the same fixed 10-second duration — so higher power genuinely does spin faster on screen, since the wheel has to cover more distance in the same time. But since every extra full turn is a multiple of 360, it has zero effect on which segment ends up at the top; spins * 360 disappears entirely once you take the final angle modulo 360. The random offset from getRandomForSegment-style logic is the only thing that decides the outcome. "Fast" mode is pure theater for suspense, not a different fairness setting — which took me a minute to convince myself of, because visually it really does look like a different kind of spin.
Segments are colored like a line, but the wheel is a circle
Each item becomes a wedge, colored by cycling through three colors based on index:
segments() {
let colorList = ["#7ecefc", "#fff6e6", "#ff8066"];
let newArray = this.wheelItems.map((item, index) => {
return {
fillStyle:
colorList[
index + 1 == this.wheelItems.length &&
this.wheelItems.length % 3 == 1
? 1
: index % 3
],
text: item.value,
textFontSize:
item.value.length > 5
? this.canvasWdith / 3 / item.value.length
: 24,
};
});
return newArray;
},
index % 3 alone is a fine way to alternate three colors down a flat list. But a wheel wraps around — segment 0 sits right next to the last segment, not just the next one. Cycling colors mod 3 on a list whose length is a multiple-of-3-plus-one (4, 7, 10, 13...) means the last segment's natural color ((length-1) % 3, which works out to 0) collides with the first segment's color, and since they're physically adjacent on the circle, you'd get two same-colored wedges sitting side by side. The length % 3 == 1 check catches exactly that case and forces the last segment to color index 1 instead, breaking up the collision. It's a small, easy-to-miss piece of circular-list logic that a flat-list mental model won't catch until you actually render a 4- or 7-item wheel and stare at the seam.
The font-size line next to it is solving an unrelated problem: text is drawn radiating outward (textOrientation: "vertical", textAlignment: "outer"), and a long label at the default 24px font will run off the edge of a narrow wedge, so anything over 5 characters gets shrunk proportionally to canvasWidth / 3 / length instead.
Limitations and things that bit me
-
Duplicate item text isn't handled specially, and it has a real consequence. The wheel has a "remove previous winner" button, and it filters the item list by matching text:
wheelItems.filter(item => item.value != itemText). If you type "Alice" into two different slots, spin, and win "Alice," clicking remove deletes both segments named Alice at once — because the filter has no way to know you meant one specific wedge. Duplicate labels also quietly double that label's odds without any warning in the UI. -
No dedupe, no cap. The only validation before building the wheel is
AwardSetting.Items.length < 2(must have at least two non-empty items). You can paste in a few hundred lines and it will happily render a few hundred slivers — readable-text-per-wedge just isn't something the tool checks for. -
It's a uniform pick, not a weighted one. There's dead code in the source (
rateWin, fully commented out) that computes cumulative weighted ranges from aratefield — clearly an earlier attempt at weighted odds that never shipped. As it stands today, every segment has exactly equal probability regardless of how many times a name repeats structurally (aside from the duplicate-label trick above). -
The random-number call isn't in the app's own code at all. It's inside a vendored library loaded via a plain
<script src="/Winwheel.min.js">tag, separate from the npm-managed dependencies. If you're auditing "is this actually random," reading the Vue component alone won't show you — you have to go find the library file.
I turned the working version of this into a small free tool if you want an actual raffle wheel without wiring up the animation library yourself: Online Random Wheel Generator. No sign-up, add as many items as you want.
Available in other languages
- 隨機抽獎輪盤產生器 — 繁體中文
- 在线随机抽奖轮盘生成器 — 简体中文
- Online Random Wheel Generator — English
- オンラインランダムルーレットジェネレーター — 日本語
- 온라인 랜덤 휠 생성기 — 한국어
- Générateur de roulettes aléatoires en ligne — Français
- Онлайн-генератор случайных колес — Русский
- Online Zufallsrad-Generator — Deutsch
- Pembuat Roda Acak Online — Bahasa Indonesia
- Generador de Ruleta Aleatoria en Línea — Español
- Công Cụ Tạo Máy Quay Ngẫu Nhiên Trực Tuyến — Tiếng Việt
- สร้างรถหมุนสุ่มออนไลน์ — ไทย
- Generator Losujący Ruletkę Online — Polski
- Çevrimiçi Rastgele Rulet Oluşturucu — Türkçe
- Generatore di Roulette Casuale Online — Italiano
- Gerador de Roleta Aleatória Online — Português
- Online Willekeurig Roulette Generator — Nederlands
- Онлайн Генератор Випадкової Рулетки — Українська
Top comments (0)