Methodology & Accuracy

Every wheel on SpinThatWheel is a thin interface over one small, pure, automatically tested engine. This page documents exactly how a winner is chosen, what the animation does and does not do, how eliminator mode and share links work, and what never leaves your browser — so you can judge precisely how far to trust a spin.

This page is the reference; the guides expand each part of it at a slower pace, with more worked examples. The draw itself is walked through in How the wheel picks a winner, and the checks you can run on your own screen are in Is the wheel rigged? How to check. The knockout variant has Eliminator mode, explained; the parser has Entry lists and parsing rules; the circle has Segment geometry and colours; and the address bar has Share links and privacy. Two guides are about use rather than mechanism: Using the wheel for groups covers rosters, dedupe and no-repeat picks for classrooms, meetings and households, and Wheel versus coin versus dice sets the wheel beside the two randomizers people already own. All of them are collected at /guides/. Every worked figure in the engine reference at the foot of this page — each probability, angle, count, length and error message it states — is produced at build time by the engine described here, not typed in.

The pick happens before the animation

The moment you press Spin, the engine draws the winner. Only after the winner exists does the wheel begin its roughly four-second, ease-out rotation — a rotation calculated to decelerate onto the segment that already won. The animation is presentation; the pick is cryptographically uniform and happens first. Nothing about the wheel's resting position, the timing of your click, the deceleration curve, or any previous spin influences the result. If your system asks for reduced motion, the animation is skipped entirely and the identical draw is announced immediately — which is the clearest demonstration that the spinning was never the deciding part. We state this plainly because most wheel sites do not, and a randomizer that hides its mechanism does not deserve your trust.

Cryptographic uniformity, no modulo bias

Random bytes come from crypto.getRandomValues, the browser's cryptographically secure generator. Mapping those bytes onto N segments uses true rejection sampling: the engine takes the minimal number of bits whose range covers N, masks away the excess top bits, and if the resulting value is N or larger, discards it entirely and draws again — it never takes a remainder. The naive alternative, randomValue % N, silently favors low-numbered segments whenever the byte range does not divide evenly by N; rejection sampling redraws instead, so every segment's probability is exactly 1/N. Because the mask keeps the acceptance rate above half, redraws are rare and spins stay instant. The automated tests drive every possible byte value through this mapping and assert that each outcome is hit exactly equally often, and that rejected values map to nothing at all.

Segment geometry

A wheel of N entries divides the circle into N equal segments of 360/N degrees, in list order, with the boundary angles computed as exact multiples — the tests pin start, end, and midpoint angles for wheels from 1 to 100 segments. Colors cycle through a fixed eight-color palette, with one adjustment so the last and first segments never match. The geometry exists for the eyes only: the winner is picked by index, then the wheel is rotated so that segment's midpoint lands under the pointer. A 3.6° sliver on a 100-entry wheel is exactly as likely as a half-circle on a 2-entry wheel of equals — probability comes from the draw, not the drawing.

Eliminator mode: last one standing

Eliminator mode inverts the usual reading of a spin: each spin's pick is removed from the wheel, and the overall winner is the final entry left after every other has been eliminated. Each elimination is a fresh uniform draw over the entries still remaining — first spin 1/N, next 1/(N−1), and so on — which makes the survivor exactly as likely to be any entry: every entry ends up with probability 1/N of winning, provably. Elimination states are immutable values in the engine; the tests replay full scripted sequences and assert every intermediate state, the elimination order, and the survivor. Editing the list mid-elimination restarts the elimination on the new list.

Entry lists and parsing

Lists accept one entry per line; a single line containing commas is treated as a comma-separated list instead, so both pasting styles work — and commas inside multi-line entries are preserved. Entries are trimmed, blank lines dropped, and an optional switch removes duplicates case-insensitively (first occurrence wins). Wheels cap at 100 entries and 80 characters per entry; hitting a cap produces an error saying exactly what happened — nothing is ever silently truncated or dropped.

Share links carry the list, and only the list

Copy share link percent-encodes each entry and joins them into the page address as a #items= fragment — unicode, emoji, commas, and even the separator character itself survive the round trip, which the tests verify. Browsers do not include URL fragments in web requests, so opening a new share link rebuilds the wheel locally without sending the list to the hosting server. Legacy ?items= links are still accepted for compatibility. Links are length-capped with an honest error, and a damaged link is reported as damaged rather than decoded into a garbled wheel.

Privacy: what stays in the browser

Everything, unless you export it yourself. Lists you edit are saved to your browser's local storage so they survive a reload — on your device, under your control, removable with the Reset button or by clearing site data. Spins, results, and history exist only on the page and die with the tab. The only path a list ever takes off your machine is a share link you yourself copy and send. You can verify the claim: open your browser's network tab and spin — no request carries your entries.

Honest limits

This site's randomness is cryptographically strong, but the site is not a certified gambling or raffle system: there is no auditor, no seed escrow, and no protection against the operator of a screen simply re-spinning until they like the result. For casual fairness among people who trust each other's screens it is excellent; for money-staked drawings it is the wrong tool, and we say so rather than sell it.

Tested against reference behavior

The engine ships with an automated suite well past a hundred tests: exhaustive byte-mapping uniformity checks, exact segment-angle assertions, scripted eliminator sequences, share-link round trips including unicode, parser edge cases, and pinned preset lists (exactly 50 states, exactly 26 letters, twelve signs). Worked figures in page copy are computed at build time by the same engine that runs the wheels, so documentation cannot drift from implementation. If you believe a spin or a number is wrong, that is exactly the report we want — see the contact page; confirmed issues are fixed in the engine and locked in with a new test.

The engine, function by function

The sections above describe the mechanism in prose. What follows is the same mechanism read off the code: each function of the engine that a spin, an elimination or a share link passes through, what it accepts, where it stops, the exact error message it returns when it stops, and one worked example. Every function that can fail — parseEntries, computeSegments, pickIndex, spin, createEliminator, eliminateSpin, encodeEntries, decodeEntries and buildShareUrl — returns a typed result that is either a value or an error with a plain-text message; extractShareParam returns the raw value or nothing; uniformInt is the one function that throws, and every caller validates before reaching it — so nothing throws into the page, and nothing returns a half-built wheel. The examples that involve a draw feed the engine a scripted byte source, the device the test suite uses to pin the byte-to-index mapping; the bytes are chosen to show a rejection happening, and they are stated so you can trace them by hand. A live spin draws from crypto.getRandomValues instead, and everything else about the call is identical. If any figure below ever disagreed with the engine, this page would fail to build.

uniformInt(span, source)

Inputs. A span — the number of outcomes, so that the result is a whole number from 0 up to but not including the span — and a byte source. Accepted range. The span must be a whole number within the range the error message names; anything else is a programming error rather than a user error, so this is the one function that throws instead of returning a result, with the message "uniformInt requires an integer span from 1 to 2^32". A span of 1 returns 0 without consulting the byte source at all — there is nothing to decide. Mechanism. For every other span the function works out the smallest number of bits whose range covers the span, asks the source for just enough bytes to hold them, masks the excess top bits off the first byte, and compares the candidate against the span: below it, the candidate is the answer; at or above it, the candidate is discarded and the source is asked again. The table gives the shape of that draw for several spans. The bit, byte and mask columns are the function's own arithmetic; the acceptance column is observed rather than derived. At build time every one of the 256 values a byte can hold is fed to the function on a tape holding only that byte, so an accepted byte returns an index and a rejected byte — which would ask for a second — comes back as a refusal, and the column counts the accepted ones. The build then holds the observation against the arithmetic, for every span in the table and every span up to 100: each accepted byte must come back masked exactly as the mask column says, each outcome must be hit the same number of times, and the observed acceptance must agree with the bit width. For every wheel this site allows, up to 100 entries, 7 bits suffice and the draw is a single byte.

Span Bits drawn Bytes asked for Mask on the first byte Candidates accepted
2 1 1 1 100%
3 2 1 3 75%
6 3 1 7 75%
10 4 1 15 62.5%
26 5 1 31 81.25%
50 6 1 63 78.13%
100 7 1 127 78.13%

The acceptance column is why redraws are rare: because the bit width is minimal, a masked candidate has better than an even chance of landing below the span on every attempt. Across every span that draws at all, from 2 to 100, each probed the same way, the lowest acceptance is 50.78%, at a span of 65 — the size just past a power of two, where the mask has to open up a whole extra bit.

Worked example: a span of 26, the letter wheel's size. The engine needs 5 bits, so it masks each byte to 31. Feed it the scripted bytes 29 then 4. The first byte, masked, is still 29, which is not below 26, so it is discarded and the source is asked again — the source was consulted 2 times. The second byte, masked, is 4, which is below 26 and becomes the answer: 4, the letter E. The naive remainder would have kept the first byte and returned 3, the letter D — the low outcome that modulo bias quietly favours.

parseEntries(raw, options)

Inputs. The raw text of the entry box and an optional dedupe switch. Rules, in the order they run. The text is split on line breaks, each line is trimmed, and blank lines are dropped. If exactly one non-blank line remains and it contains a comma, that line is split on commas instead, with each piece trimmed and empty pieces dropped; if more than one line remains, commas are left alone. Every piece is then checked against the 80-character limit. Only after that does dedupe run, comparing entries case-insensitively and keeping the first occurrence, and only after dedupe is the 100-entry cap applied — so a list that is over the cap only because of duplicates passes once dedupe is on. Errors. Input that is not text at all returns "Enter a list of entries." An over-long entry returns "Entries are limited to 80 characters — "xxxxxxxxxxxxxxxxxxxx…" is too long to fit on a segment." — the message quotes the start of the offending entry so you can find it. Too many entries returns "A wheel holds at most 100 entries — this list has 101. Remove some entries; nothing was silently dropped."

Worked example: the comma rule. Pasting Dishes, Laundry, Vacuum, Bins as one line gives 4 entries, because the single line contains commas. Pasting Walk, then coffee ⏎ Coffee, then walk on two lines gives 2 entries, the first of which is "Walk, then coffee" with its comma intact. The 5 lines of Dishes ⏎ ⏎ ⏎ Laundry ⏎ — stray spaces and blank lines included — parse to 2 entries: "Dishes" and "Laundry".

Dedupe. The four lines Alex, alex, ALEX, Bailey parse to 4 entries with dedupe off and 2 with it on; the survivor keeps the spelling of the first occurrence, "Alex". The caps. An entry of exactly 80 characters is accepted; one character more and the parser answers "Entries are limited to 80 characters — "xxxxxxxxxxxxxxxxxxxx…" is too long to fit on a segment." Exactly 100 lines are accepted; the numbers 1 through 101 on 101 lines are refused with "A wheel holds at most 100 entries — this list has 101. Remove some entries; nothing was silently dropped." And 101 lines that repeat one number are refused with dedupe off — "A wheel holds at most 100 entries — this list has 101. Remove some entries; nothing was silently dropped." — but collapse to 100 entries and pass with dedupe on.

computeSegments(entries)

Inputs. A parsed list of entries. Accepted range. From 1 to 100 entries. An empty list returns "Add at least one entry to build a wheel." and a list over the cap returns "A wheel holds at most 100 entries." Output. One segment per entry, in list order, each carrying its index, its label, a start angle and an end angle measured clockwise from the pointer, the midpoint between them — the angle the wheel is rotated to when that segment wins — and a fill colour. The angles are exact multiples of 360 divided by the entry count. Colours are taken from a fixed palette of 8 colours in cycling order, with one adjustment: when the cycle would hand the last segment the same colour as the first, the last segment takes the second palette colour instead, so no two neighbouring segments ever match. On wheels of up to 100 entries that adjustment fires at exactly 12 sizes — 9, 17, 25, 33, 41, 49, 57, 65, 73, 81, 89, 97 — every size that is one past a multiple of 8.

Worked example: the landing page's sample list. The 6 names Alex, Bailey, Carmen, Devon, Elena, Farrah produce 6 segments of 60° each, at 16.67% apiece. The table is the function's output, angle for angle.

Index Label Start End Midpoint Colour
0 Alex 0° 60° 30° #7e22ce
1 Bailey 60° 120° 90° #2563eb
2 Carmen 120° 180° 150° #047857
3 Devon 180° 240° 210° #b45309
4 Elena 240° 300° 270° #dc2626
5 Farrah 300° 360° 330° #0e7490

The edge cases. A single entry is a valid wheel: it gets one segment from 0° to 360° with its midpoint at 180°. A wheel of exactly 8 entries uses every palette colour once, ending on #4d7c0f, and needs no adjustment. A wheel of 9 entries is where the plain cycle would collide: the last segment would come round to #7e22ce, the same as the first, so the function gives it #2563eb instead.

pickIndex(count, source) and spin(entries, source)

Inputs. pickIndex takes an entry count and a byte source and returns a uniform index; spin takes the entry list itself, builds the segments, picks an index, and returns the winning index, its label, and the angle the presentation layer must decelerate onto. Accepted range. A count or list of 1 to 100. pickIndex answers a count of zero with "Add at least one entry to spin." and a count over the cap with "A wheel holds at most 100 entries." spin validates the list through computeSegments first, so an empty list gets that function's message, "Add at least one entry to build a wheel." — and no bytes are drawn for a list that cannot spin. Where the draw ends. The value spin returns is the draw. The client then rotates the wheel so the winning segment's midpoint rests under the pointer, and if reduced motion is requested it skips the rotation and announces the same value at once. Nothing downstream can change it.

Worked example: a scripted spin of Yes / No / Maybe. A span of 3 needs 2 bits and a mask of 3. Feed the spin the bytes 3 then 1: the first, masked, is 3 and is rejected; the second is 1 and is accepted. The source was consulted 2 times, and the spin returns index 1, the label No, and a landing angle of 180° — the midpoint of a segment running from 120° to 240°. A one-entry list spins without touching the byte source at all: the result is index 0, "Only entry", landing at 180°.

How to read the result, and how not to. The label the result line shows is the label returned by spin; the segment the pointer settles on is the same segment, by construction, because the rotation was computed from the answer. Three readings are therefore wrong. The wheel did not "nearly" stop on the neighbouring segment — no other segment was ever in contention. A long run without an entry does not make it due — every spin is a fresh draw at exactly 1/N. And a thin segment does not win less than a wide one — the geometry never enters the draw.

createEliminator(entries) and eliminateSpin(state, source)

Inputs. createEliminator takes the entry list and returns the starting state: every entry still remaining, nothing eliminated, not done, no winner. eliminateSpin takes a state and a byte source and returns the next state along with the entry it picked and where that entry sat among the entries still remaining before the spin. Accepted range. From 2 to 100 entries: a single entry returns "Eliminator mode needs at least two entries." because the winner would be settled before the first spin, and a list over the cap returns "A wheel holds at most 100 entries." Mechanism. Each call draws a uniform index over the entries still remaining — the same uniformInt as an ordinary spin, over a shrinking span — removes that entry, appends it to the elimination order, and when exactly one entry is left marks the state done with that entry as the winner. States are never modified: every call returns a fresh value, which is what makes a sequence replayable, and what makes editing the list mid-way a restart rather than a patch. Where it stops. Spinning a finished state returns "This elimination is finished — reset to run it again." A state with fewer than two entries remaining that is somehow not marked done is refused with "Eliminator mode needs at least two remaining entries." — a guard the normal flow never reaches, since the previous spin would have marked it done.

Worked example: 4 chores, scripted bytes 2, 3, 0, 1. Every row is the engine's own output.

Spin Still on the wheel Chance each Removed Left after Bytes asked for
1 Dishes, Laundry, Vacuum, Bins 1/4 (25%) Vacuum Dishes, Laundry, Bins 1
2 Dishes, Laundry, Bins 1/3 (33.33%) Dishes Laundry, Bins 2
3 Laundry, Bins 1/2 (50%) Bins Laundry 1

Elimination order Vacuum, Dishes, Bins; survivor Laundry. Spin 2 needed 2 bytes because a scripted byte fell at or beyond the shrinking span and was rejected — the redraw rule holds at every step. Because each spin is uniform over what remains, each of the 4 chores survives with probability exactly 1/4, or 25%: the order of elimination is random, and so is the survivor, but neither is skewed. Asking for one more spin after the survivor is known returns "This elimination is finished — reset to run it again."

encodeEntries(entries) and decodeEntries(param)

Inputs. encodeEntries takes the entry list and returns the value of the items parameter; buildShareUrl wraps it into a full address with a #items= fragment; extractShareParam pulls the raw value back out of either the fragment or the legacy ?items= query, without decoding it; and decodeEntries turns the value back into a list, applying the same caps as typing the list by hand. Accepted range. Encoding takes 1 to 100 entries of up to 80 characters, none blank, and the encoded value must fit in 1800 characters, a cap chosen to keep full addresses comfortably under the limit older browsers and some chat apps enforce. Mechanism. Each entry is percent-encoded on its own and the results are joined with |, which is why a | inside an entry cannot be mistaken for a separator: it leaves the encoder as an escape sequence and only becomes a bar again after the split. Decoding splits on |, decodes each piece, trims it, skips pieces that are empty, and re-checks the entry length and the entry cap. Errors on encoding. An empty list returns "Add at least one entry before creating a share link." A blank entry returns "Share links cannot contain blank entries." An over-long entry returns "Entries are limited to 80 characters." A list whose encoded form would exceed the cap returns "This list is too long to fit in a share link — shorten some entries or remove a few. Nothing was truncated." Errors on decoding. An empty value returns "The share link carries no entries." A value longer than the cap returns "The share link is longer than this site ever produces." A value with broken percent-encoding returns "The share link is damaged — part of it is not valid encoding."

Worked example: a list with an ampersand, a comma, accents and a bar. The 4 entries "Tea & biscuits", "Fish, chips", "Crème brûlée", "Either|Or" encode to Tea%20%26%20biscuits|Fish%2C%20chips|Cr%C3%A8me%20br%C3%BBl%C3%A9e|Either%7COr, 78 characters, and the full address for the custom wheel is https://spinthatwheel.net/#items=Tea%20%26%20biscuits|Fish%2C%20chips|Cr%C3%A8me%20br%C3%BBl%C3%A9e|Either%7COr. Splitting the encoded value on | gives exactly 4 pieces, because "Either|Or" left the encoder as Either%7COr. Decoding returns the original 4 entries, character for character, and extractShareParam recovers the identical value from both the fragment and the legacy query form.

The caps and the damage check. 23 entries of 80 characters each would encode to 1862 characters, over the 1800-character cap, so the encoder refuses with "This list is too long to fit in a share link — shorten some entries or remove a few. Nothing was truncated." Decoding Alex|%ZZ — a valid entry followed by a broken escape — returns "The share link is damaged — part of it is not valid encoding." rather than a wheel with a garbled second entry. Decoding Alex||Bailey, with an empty piece between two bars, quietly skips the gap and returns 2 entries, "Alex" and "Bailey".

For fun and tie-breaks: the wheel decides fairly, but it cannot know your situation — do not delegate consequential decisions to it. Everything runs locally in your browser; this is the methodology page, and the checks you can run yourself are in Is the wheel rigged? How to check.

Last reviewed 26 September 2026.