How do I make an egg hatching system in Roblox?
An egg is three things: a price, a weighted table of pets, and an animation. The server takes the coins, rolls the table, writes the pet to the player's inventory and sends back which pet it was. The client plays the crack and reveals that pet. Nothing is decided on the client.
In short
- Charge, roll and save in that order, all on the server.
- One weighted table per egg, so a new egg is data and not code.
- The reveal animation reads the result; it never produces it.
A hatch is a purchase with a surprise attached. The surprise is the fun part and the purchase is the part that has to be right: coins leave the account once, the pet arrives once, and a player who closes the game mid-animation still owns the pet when they come back.
Steps
1. Describe the egg as data
One table per egg, with its price and its pets. Adding the Volcano Egg should be eight lines of data, not a new script.
local EGGS = {
basic = {
price = 250,
pets = {
{ id = "dog", weight = 500 },
{ id = "cat", weight = 300 },
{ id = "bunny", weight = 150 },
{ id = "dragon", weight = 49 },
{ id = "void", weight = 1 },
},
},
volcano = {
price = 5000,
pets = {
{ id = "magma", weight = 600 },
{ id = "obsidian", weight = 300 },
{ id = "phoenix", weight = 95 },
{ id = "void", weight = 5 },
},
},
}
2. Roll the table
The same weighted roll a spin wheel uses. Keep it in one place: an egg and a wheel disagreeing about odds is a bug nobody finds.
local rng = Random.new()
local function rollPet(pets): string
local total = 0
for _, p in pets do
total += p.weight
end
local ticket = rng:NextInteger(1, total)
local running = 0
for _, p in pets do
running += p.weight
if ticket <= running then
return p.id
end
end
return pets[#pets].id
end
3. Charge, then save, then answer
The order is the whole design. Take the coins before rolling, so a player cannot hatch for free by firing the remote twice; save before answering, so the pet exists before the player sees it.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStoreService = game:GetService("DataStoreService")
local pets = DataStoreService:GetDataStore("Pets_v1")
local hatch = Instance.new("RemoteEvent")
hatch.Name = "HatchEgg"
hatch.Parent = ReplicatedStorage
local busy: { [number]: boolean } = {}
hatch.OnServerEvent:Connect(function(player: Player, eggId: unknown)
-- Never trust the egg id: it came from the client.
local egg = type(eggId) == "string" and EGGS[eggId] or nil
if not egg then return end
-- One hatch at a time per player: two remotes in the same tick would both
-- read the same coin balance and both pass.
if busy[player.UserId] then return end
busy[player.UserId] = true
local ok = pcall(function()
local stats = player:FindFirstChild("leaderstats")
local coins = stats and stats:FindFirstChild("Coins")
if not coins or coins.Value < egg.price then
error("not enough")
end
coins.Value = coins.Value - egg.price
local petId = rollPet(egg.pets)
pets:UpdateAsync("u_" .. player.UserId, function(old)
local owned = type(old) == "table" and old or {}
owned[petId] = (owned[petId] or 0) + 1
return owned
end)
hatch:FireClient(player, eggId, petId)
end)
busy[player.UserId] = nil
if not ok then
hatch:FireClient(player, eggId, nil)
end
end)
Players.PlayerRemoving:Connect(function(player)
busy[player.UserId] = nil
end)
4. Open the egg on the client
The animation is pure show. It takes the pet id it was given and reveals that model — if the player leaves halfway, the pet is already theirs.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")
local hatch = ReplicatedStorage:WaitForChild("HatchEgg")
local models = ReplicatedStorage:WaitForChild("PetModels")
hatch.OnClientEvent:Connect(function(_eggId: string, petId: string?)
if not petId then return end -- refused: not enough coins
local model = models:FindFirstChild(petId)
if not model then return end
local shown = model:Clone()
shown.Parent = workspace
shown:PivotTo(workspace.CurrentCamera.CFrame * CFrame.new(0, 0, -6))
for _, part in shown:GetDescendants() do
if part:IsA("BasePart") then
part.Anchored = true
part.CanCollide = false
part.Transparency = 1
TweenService:Create(part, TweenInfo.new(0.6), { Transparency = 0 }):Play()
end
end
task.delay(3, function()
shown:Destroy()
end)
end)
Where to keep the pet a player owns
| Where | Survives a rejoin | Safe from the client | Use it when |
|---|---|---|---|
| DataStore, counted per pet id | yes | yes | The default: a table of { petId = howMany } |
| A folder of values under the player | no | yes | Only as the live mirror of the saved table |
| A client-side table | no | no | Never |
| An ordered DataStore | yes | yes | Only for a leaderboard of how many were hatched |
FAQ
Should the pet be rolled on hatch or when the egg is bought?
On hatch, which in this design is the same moment. What must not happen is rolling it when the egg appears in the shop: a result that exists before the player presses the button is a result something on the client can read.
Why is my player sometimes charged with no pet?
Because the charge and the save are two separate writes and one of them failed. The pcall above puts them in one block and refuses the whole hatch, so a failed save gives the coins back by never having committed the roll. If you split them, you own that bug.
How do I show the odds?
Print them from the same table the roll uses — weight / total as a percentage, rounded to one decimal. Typing the percentages into the UI by hand is how a 1% pet quietly becomes a 0.2% pet after somebody adds a sixth pet.
Can I use one egg script for ten eggs?
That is the point of step 1. The script takes an egg id, looks it up and rolls. Ten eggs means ten entries in EGGS, and a new egg cannot introduce a new bug because there is no new code.
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
