How do I make a spin wheel in Roblox?
Pick the prize first and animate second. The server rolls a weighted number, decides which slice won, and sends that index to the client; the client spins the wheel to stop on it. Animating first and reading the angle afterwards is how a wheel ends up payable from the client.
In short
- Weights, not equal slices: a 1-in-200 prize is a weight of 1 against 199.
- The server picks. The spin is an animation of a decision already made.
- Always land the wheel on the slice the server chose, never the reverse.
Every spin wheel that gets exploited was built in the wrong order: spin the wheel, see where it stopped, pay that out. Build it the other way and there is nothing to exploit — the client is playing back a result it cannot change.
Steps
1. Write the prizes as weights
A weight is "how many tickets this prize has in the hat". It reads better than percentages because adding a prize does not force you to recompute every other number.
local PRIZES = {
{ name = "50 Coins", weight = 400, coins = 50 },
{ name = "150 Coins", weight = 250, coins = 150 },
{ name = "500 Coins", weight = 100, coins = 500 },
{ name = "2,000 Coins", weight = 40, coins = 2000 },
{ name = "Rare Pet", weight = 9, pet = "Rare" },
{ name = "Mythic Pet", weight = 1, pet = "Mythic" },
}
local TOTAL = 0
for _, p in PRIZES do
TOTAL += p.weight
end
2. Roll on the server
One Random object for the whole server, not a new one per spin: a fresh Random.new() seeded from the clock inside a loop can hand two players in the same tick the same prize.
local rng = Random.new()
local function roll(): number
local ticket = rng:NextInteger(1, TOTAL)
local running = 0
for i, p in PRIZES do
running += p.weight
if ticket <= running then
return i
end
end
return #PRIZES
end
3. Pay out, then tell the client where to stop
The index travels, not the prize: the client already has the list, and sending the payout invites somebody to change it on the way back.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local spin = Instance.new("RemoteEvent")
spin.Name = "SpinWheel"
spin.Parent = ReplicatedStorage
local cooldown: { [number]: number } = {}
spin.OnServerEvent:Connect(function(player: Player)
-- The one rate limit that matters: the client can fire a RemoteEvent as
-- fast as it likes, so the spin has to be cheap to refuse.
local now = os.clock()
if cooldown[player.UserId] and now - cooldown[player.UserId] < 3 then return end
cooldown[player.UserId] = now
local index = roll()
local prize = PRIZES[index]
if prize.coins then
local stats = player:FindFirstChild("leaderstats")
local coins = stats and stats:FindFirstChild("Coins")
if coins then
coins.Value = coins.Value + prize.coins
end
end
spin:FireClient(player, index)
end)
Players.PlayerRemoving:Connect(function(player)
cooldown[player.UserId] = nil
end)
4. Spin the wheel onto that slice
The angle is arithmetic: full turns for the show, plus the offset of the winning slice. TweenService with a long ease-out does the rest.
local TweenService = game:GetService("TweenService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local wheel = script.Parent -- an ImageLabel with the slices drawn on it
local SLICES = 6
local spin = ReplicatedStorage:WaitForChild("SpinWheel")
spin.OnClientEvent:Connect(function(index: number)
local per = 360 / SLICES
-- Four full turns, then stop with the winning slice at the top.
local target = 360 * 4 + (360 - (index - 1) * per)
local tween = TweenService:Create(
wheel,
TweenInfo.new(4, Enum.EasingStyle.Quint, Enum.EasingDirection.Out),
{ Rotation = target }
)
tween:Play()
tween.Completed:Wait()
wheel.Rotation = target % 360 -- keep the number small for the next spin
end)
Where to decide the prize
| Where the roll happens | Can a player change it | Feels instant | Verdict |
|---|---|---|---|
| Server, before the animation | no | yes, the spin covers the wait | What to build |
| Client, server just pays | yes — entirely | yes | Never: the payout is a client variable |
| Server, read from the final angle | no, but fragile | no | Avoid: one rounding error pays the wrong slice |
| Server with a per-player cooldown | no | yes | The same as the first row, plus a floor on spam |
FAQ
How do I make a prize exactly 0.5%?
Give it a weight of 1 and make the others add up to 199. Percentages are the thing you print on the UI; weights are the thing the code rolls. Keeping them separate means a designer can add a prize without re-deriving six numbers.
Does the wheel have to have equal slices?
Visually, no, and it is better if it does not — a slice drawn as a sliver reads as rare. What matters is that the slice at the top when the tween stops is the one the server chose, so the drawn geometry and the index order have to match.
Can two players get the mythic at the same moment?
Yes, and that is correct: each spin is its own roll. If you want a prize to exist once in the whole experience, that is not a wheel problem — it is a stock counter in a DataStore, decremented with UpdateAsync before the prize is announced.
Why does my wheel jump before it spins?
Because Rotation was left at a number like 1,448 from the last spin and the new tween starts from there. Normalise it with % 360 when the tween completes, as above.
Updated: Tested in Studio: Roblox Studio, October 2026Author: GustavoLicense: Code in this guide may be used in any Roblox experience, including a paid one
Related pack
