Your first Roblox game · Windows · Roblox Studio 2026
DON'T PRESS THE BUTTON!
A step-by-step build of a small multiplayer party game. One giant red button, 20 seconds of temptation, then chaos: floods, vanishing floors and a very large ball. Survivors get coins and unlock trails.
By the end you can press Play and run full rounds with friends in Studio. Tick each step off as you go; your ticks are remembered in this browser.
Step 1
Open Roblox Studio and learn the screen
Go to create.roblox.com, download Roblox Studio for Windows (Windows 10 or 11), install it and sign in with your Roblox account.
On the start screen, choose the Baseplate template. You get a big grey floor and a sky. (The exact wording on the start screen changes now and then; look for “Baseplate”.)
Save straight away with Ctrl+S and call it DON'T PRESS THE BUTTON! Save often.
The five things you will use most
Top bar tabs
Home, Model, Avatar, UI, Script, Plugins. Almost everything in Level 1 is on Home: Part, Move/Scale/Rotate, Color, Material, Anchor, Group.
Play controls
In the top-left of the window (not on a tab). A dropdown picks the mode (Test, Test Here, Run, Server & Clients) and the Play button starts it.
Explorer
A list of every object in your game, in folders. You will add scripts here.
Properties
The settings of whatever you selected: size, colour, position…
Output
Messages and errors from your scripts. Keep it open. Red text means something broke, and it tells you the script and line.
Can't see Explorer, Properties or Output? Open them from the Window menu (Output is also on the Script tab). If the toolbar shows only icons, right-click an empty part of the toolbar and turn on Show labels.
Moving the camera
Hold the right mouse button and drag to look around. WASD fly, Q/E go down/up, the scroll wheel zooms, and F jumps to whatever you selected.
Step 2
The arena (and practising with Parts)
The arena is a 10 × 10 grid of floor tiles floating 50 studs up in the sky, with lava underneath and four walls around it. A script called ArenaBuilder builds it from the numbers in GameConfig when the game starts, so the events always line up with the floor. Building 100 tiles by hand would take ages, and one wrong tile would break the vanishing-floor event.
Arena
Floor top at height 50, 80 × 80 studs, walls 40 tall, lava 25 below.
Lobby
A yellow platform on the ground around 0, 1, 110. Everyone waits and respawns here. The Baseplate's own grey SpawnLocation sits under the arena, so the script switches it off for you.
Practise the tools by decorating the lobby
Home → Part adds a block in front of the camera.
Use Move (drag the arrows), Scale (drag the balls) and Rotate (drag the rings). Or type exact numbers into Position, Size and Orientation in Properties. That's what the next step does.
Change Color and Material on the Home tab.
Click Anchor. Anchored parts never fall or get pushed. Anchor everything you build.
Build something near 0, 0, 110 on the Baseplate: an archway, a bench, a “WAIT HERE” sign. Keep the area right around 0, 1, 110 clear, because the lobby platform and spawn go there.
Rename things in Explorer by double-clicking the name (or select it and press F2). Names matter: scripts look for objects by their exact name, capital letters included.
Step 3
Build the giant red button and the sign
This is the star of the game, so you build it yourself. Insert a Part for each piece and copy the Properties below.
a. The pedestal (yellow base)
Name
Pedestal
Shape
Cylinder
Size
3, 14, 14
Orientation
0, 0, 90
Position
0, 51.5, 0
Color
245, 200, 40
Material
SmoothPlastic
Anchored
✓ ticked
Roblox cylinders lie on their side, and the first Size number is their height. Orientation 0, 0, 90 stands it upright.
b. The cap (the part you press)
Name
Cap (exactly. The script looks for this name.)
Shape / Orientation
Cylinder · 0, 0, 90
Size
3, 8, 8
Position
0, 54.5, 0
Color
235, 40, 40
Anchored
✓ ticked
c. Group them into one Model
In Explorer, click Pedestal, then hold Ctrl and click Cap.
Press Ctrl+G (or Home → Group). Rename the new Model to TheButton.
With TheButton selected, find PrimaryPart in Properties, click it, then click Pedestal in Explorer.
Hover over Cap in Explorer, click the + and add a ProximityPrompt. That's the “Press E” pop-up (players tap it on phones). The script sets its text for you.
d. The “DO NOT PRESS” sign
Insert a Part: Name Sign, Size 30, 12, 1, Position 0, 66, -39, Color 250, 245, 230, Anchored ✓. It floats against the north wall, above the floor.
Click + on Sign → SurfaceGui. Set Face = Back (the side that faces into the arena), SizingMode = PixelsPerStud, LightInfluence = 0.
Click + on the SurfaceGui → TextLabel. Set Size = {1, 0},{1, 0} (fills the sign), BackgroundTransparency = 1, Text = DO NOT PRESS, TextScaled ✓, Font = FredokaOne, TextColor3 = 200, 25, 25.
Optional: + on the TextLabel → UIStroke, Thickness 3, for a bold outline.
You can't see the walls yet because they appear when you press Play. If the text faces the wrong way in Play mode, change Face to Front. If you skip the sign, ArenaBuilder puts up a plain one in the same spot.
Step 4
The countdown and round system (scripts, part 1)
Now the brain. There are three kinds of script, and putting them in the right place is most of the battle:
Script
Runs on the server, the one computer that runs the game for everyone. Rules, timers and coins live here. Put it in ServerScriptService.
LocalScript
Runs on each player's device. Only for what that player sees (the on-screen text). Put it in StarterPlayer → StarterPlayerScripts.
ModuleScript
A box of code that other scripts require. It does nothing on its own.
Hover the service in Explorer (for example ReplicatedStorage), click +, and choose the script type from the table above.
Rename it to the exact name shown (double-click the name).
Double-click the script to open it. Press Ctrl+A to select the “Hello world” line, then paste the code with Ctrl+V. Each block below has a Copy button.
Don't press Play until the end of Step 6. RoundManager waits for every other script. If one is missing, Output shows Infinite yield possible on …WaitForChild("CoinManager"). That's normal halfway through.
-- GameConfig (ModuleScript in ReplicatedStorage)
-- Every number you might want to tweak lives here. Change a value, press Play, see what happens.
-- You never need to touch the other scripts just to rebalance the game.
local GameConfig = {}
---------------------------------------------------------------------
-- ROUND TIMING (seconds)
---------------------------------------------------------------------
GameConfig.MinPlayers = 1 -- 1 lets you test alone. Use 2+ when you publish.
GameConfig.IntermissionTime = 10 -- lobby countdown before each round
GameConfig.ButtonPhaseTime = 20 -- how long players have to decide
GameConfig.ResultsTime = 5 -- how long the results screen stays up
GameConfig.AutoPressWhenTimeUp = true -- if nobody presses, the button presses itself
---------------------------------------------------------------------
-- DIFFICULTY
-- "Escalating" = starts Chill, becomes Normal, then Chaos as rounds go on.
-- Or lock it to one level: "Chill", "Normal" or "Chaos".
---------------------------------------------------------------------
GameConfig.DifficultyMode = "Escalating"
GameConfig.EscalationRounds = {
Normal = 4, -- from round 4
Chaos = 9, -- from round 9
}
GameConfig.Difficulties = {
Chill = {
Order = 1,
EventTime = 20,
RewardMultiplier = 1,
Flood = { RiseTime = 18, MaxHeight = 16, DamagePerSecond = 15 },
Floor = { TilesPerWave = 3, WaveInterval = 2, WarnTime = 1.5, KeepTiles = 30 },
Ball = { Size = 14, Speed = 22, Count = 1 },
},
Normal = {
Order = 2,
EventTime = 25,
RewardMultiplier = 1.5,
Flood = { RiseTime = 15, MaxHeight = 21, DamagePerSecond = 25 },
Floor = { TilesPerWave = 5, WaveInterval = 1.5, WarnTime = 1, KeepTiles = 14 },
Ball = { Size = 20, Speed = 30, Count = 1 },
},
Chaos = {
Order = 3,
EventTime = 30,
RewardMultiplier = 2.5,
Flood = { RiseTime = 11, MaxHeight = 26, DamagePerSecond = 40 },
Floor = { TilesPerWave = 8, WaveInterval = 1, WarnTime = 0.6, KeepTiles = 6 },
Ball = { Size = 26, Speed = 40, Count = 2 },
},
}
---------------------------------------------------------------------
-- EVENTS
-- Name must match a ModuleScript inside ServerScriptService > Events.
-- Settings: which table in each difficulty (Flood / Floor / Ball) the event reads.
-- UnlockRound: the event can't be picked before this round number.
-- Weight: bigger = picked more often.
---------------------------------------------------------------------
GameConfig.Events = {
{ Name = "DisappearingFloor", Settings = "Floor", DisplayName = "THE FLOOR IS LEAVING!", UnlockRound = 1, Weight = 1 },
{ Name = "RisingFlood", Settings = "Flood", DisplayName = "FLOOD! CLIMB!", UnlockRound = 1, Weight = 1 },
{ Name = "RollingBall", Settings = "Ball", DisplayName = "GIANT BALL INCOMING!", UnlockRound = 3, Weight = 1 },
}
---------------------------------------------------------------------
-- SOUNDS
-- Paste a Roblox audio asset ID (just the number) between the quotes. Empty = silent, which is fine.
-- Find free audio in the Creator Store (create.roblox.com/store/audio, or Toolbox > Audio in Studio),
-- open a sound, and copy the number from its page address or its "Copy Asset ID" button.
-- Only use audio that is marked free to use. No IDs are included, so the game is silent until you add some.
---------------------------------------------------------------------
GameConfig.Sounds = {
ButtonPress = "", -- a click or buzzer when somebody presses the button
EventStart = "", -- an alarm when the chaos event begins
Survived = "", -- a happy sound for survivors
Volume = 0.6, -- 0 (silent) to 1 (loud)
}
---------------------------------------------------------------------
-- REWARDS (coins, before the difficulty multiplier)
---------------------------------------------------------------------
GameConfig.Rewards = {
Participation = 1, -- everyone who was in the arena
Survive = 10, -- still alive when the event ends
PresserBonus = 5, -- pressed the button AND survived
}
-- Cosmetic trails unlock automatically when your coin total reaches the number.
GameConfig.Cosmetics = {
{ Name = "Bronze Trail", Coins = 50, Color = Color3.fromRGB(205, 127, 50) },
{ Name = "Silver Trail", Coins = 150, Color = Color3.fromRGB(200, 210, 225) },
{ Name = "Gold Trail", Coins = 400, Color = Color3.fromRGB(255, 200, 40) },
{ Name = "Rainbow Trail", Coins = 1000, Rainbow = true },
}
---------------------------------------------------------------------
-- SAVING
-- Saving only works in Studio after you publish, then: File > Experience Settings >
-- Security > "Enable Studio Access to API Services". The game still runs without it.
-- Careful: once it's on, coins you earn while testing in Studio go into the REAL saves.
---------------------------------------------------------------------
GameConfig.SaveData = true
GameConfig.DataStoreName = "DontPressTheButton_v1"
---------------------------------------------------------------------
-- ARENA (built by ArenaBuilder, so events always line up)
---------------------------------------------------------------------
GameConfig.Arena = {
Center = Vector3.new(0, 50, 0), -- floats in the sky; lava waits below
TileSize = 8,
TilesPerSide = 10, -- 10 x 10 tiles = 80 x 80 studs
WallHeight = 40,
LobbySpawn = Vector3.new(0, 1, 110),
ApplyLighting = true, -- set false if you want to design the Lighting yourself
}
---------------------------------------------------------------------
-- COLOURS (cartoony "toy diorama": calm colours everywhere, RED means danger)
---------------------------------------------------------------------
GameConfig.Colors = {
ButtonCap = Color3.fromRGB(235, 40, 40),
Pedestal = Color3.fromRGB(245, 200, 40),
SignBoard = Color3.fromRGB(250, 245, 230),
SignText = Color3.fromRGB(200, 25, 25),
TileA = Color3.fromRGB(120, 205, 190),
TileB = Color3.fromRGB(150, 225, 210),
TileWarning = Color3.fromRGB(255, 160, 40),
Wall = Color3.fromRGB(255, 255, 255),
Water = Color3.fromRGB(40, 150, 240),
Ball = Color3.fromRGB(255, 120, 30),
Lava = Color3.fromRGB(255, 90, 20),
LobbyFloor = Color3.fromRGB(255, 214, 120),
Steps = Color3.fromRGB(80, 120, 240),
}
return GameConfig
Every number you might want to change lives here: timers, difficulty, events, rewards, colours. In Level 2 you'll play with it.
-- RoundLogic (ModuleScript in ReplicatedStorage)
-- Small "thinking" functions with no parts or players in them.
-- Keeping them separate means they are easy to read and easy to test.
local RoundLogic = {}
-- Which difficulty should this round use?
function RoundLogic.GetDifficultyName(config, roundNumber)
local mode = config.DifficultyMode
if mode ~= "Escalating" then
return mode
end
if roundNumber >= config.EscalationRounds.Chaos then
return "Chaos"
elseif roundNumber >= config.EscalationRounds.Normal then
return "Normal"
end
return "Chill"
end
-- Events that are unlocked by this round.
function RoundLogic.GetAvailableEvents(config, roundNumber)
local available = {}
for _, event in ipairs(config.Events) do
if roundNumber >= event.UnlockRound then
table.insert(available, event)
end
end
return available
end
-- Pick a random unlocked event, avoiding a repeat of last round when possible.
-- randomNumber is a function returning a number from 0 to 1 (math.random works).
function RoundLogic.PickEvent(config, roundNumber, lastEventName, randomNumber)
randomNumber = randomNumber or math.random
local choices = {}
for _, event in ipairs(RoundLogic.GetAvailableEvents(config, roundNumber)) do
if event.Name ~= lastEventName then
table.insert(choices, event)
end
end
if #choices == 0 then
choices = RoundLogic.GetAvailableEvents(config, roundNumber)
end
if #choices == 0 then
return nil
end
local totalWeight = 0
for _, event in ipairs(choices) do
totalWeight = totalWeight + event.Weight
end
local roll = randomNumber() * totalWeight
for _, event in ipairs(choices) do
roll = roll - event.Weight
if roll < 0 then
return event
end
end
return choices[#choices]
end
-- How many coins does one player get this round?
function RoundLogic.CalculateReward(config, difficultyName, survived, pressedButton)
local rewards = config.Rewards
local coins = rewards.Participation
if survived then
coins = coins + rewards.Survive
if pressedButton then
coins = coins + rewards.PresserBonus
end
end
local multiplier = config.Difficulties[difficultyName].RewardMultiplier
return math.floor(coins * multiplier + 0.5)
end
-- The best cosmetic a coin total has unlocked (or nil).
function RoundLogic.GetBestCosmetic(config, coins)
local best = nil
for _, cosmetic in ipairs(config.Cosmetics) do
if coins >= cosmetic.Coins and (best == nil or cosmetic.Coins > best.Coins) then
best = cosmetic
end
end
return best
end
-- Checks the config for typos and impossible values. Returns a list of problems.
function RoundLogic.ValidateConfig(config)
local problems = {}
local function check(condition, message)
if not condition then
table.insert(problems, message)
end
end
check(config.MinPlayers >= 1, "MinPlayers must be at least 1")
check(config.IntermissionTime > 0, "IntermissionTime must be above 0")
check(config.ButtonPhaseTime > 0, "ButtonPhaseTime must be above 0")
check(config.ResultsTime >= 0, "ResultsTime can't be negative")
local mode = config.DifficultyMode
check(mode == "Escalating" or config.Difficulties[mode] ~= nil, "Unknown DifficultyMode: " .. tostring(mode))
if mode == "Escalating" then
check(config.EscalationRounds.Normal < config.EscalationRounds.Chaos, "EscalationRounds.Normal must come before Chaos")
check(config.Difficulties.Chill and config.Difficulties.Normal and config.Difficulties.Chaos,
"Escalating mode needs Chill, Normal and Chaos difficulties")
end
for name, difficulty in pairs(config.Difficulties) do
check(difficulty.EventTime > 0, name .. ".EventTime must be above 0")
check(difficulty.RewardMultiplier > 0, name .. ".RewardMultiplier must be above 0")
check(difficulty.Flood.RiseTime > 0, name .. ".Flood.RiseTime must be above 0")
check(difficulty.Flood.MaxHeight > 0 and difficulty.Flood.MaxHeight < config.Arena.WallHeight,
name .. ".Flood.MaxHeight must be between 0 and the wall height")
check(difficulty.Floor.WaveInterval > difficulty.Floor.WarnTime,
name .. ".Floor.WaveInterval must be longer than WarnTime")
check(difficulty.Floor.KeepTiles >= 1, name .. ".Floor.KeepTiles must be at least 1")
check(difficulty.Ball.Size > 0 and difficulty.Ball.Size < config.Arena.TileSize * config.Arena.TilesPerSide / 2,
name .. ".Ball.Size is too big for the arena")
check(difficulty.Ball.Count >= 1, name .. ".Ball.Count must be at least 1")
end
local seen = {}
local hasRoundOneEvent = false
for _, event in ipairs(config.Events) do
check(not seen[event.Name], "Event listed twice: " .. tostring(event.Name))
seen[event.Name] = true
check(event.Weight > 0, event.Name .. " needs a Weight above 0")
check(event.UnlockRound >= 1, event.Name .. " UnlockRound must be at least 1")
for difficultyName, difficulty in pairs(config.Difficulties) do
check(difficulty[event.Settings] ~= nil,
event.Name .. " reads settings '" .. tostring(event.Settings) .. "' but " .. difficultyName .. " has none")
end
if event.UnlockRound <= 1 then
hasRoundOneEvent = true
end
end
check(hasRoundOneEvent, "At least one event must unlock on round 1")
for i, cosmetic in ipairs(config.Cosmetics) do
check(cosmetic.Coins > 0, cosmetic.Name .. " needs a coin price above 0")
check(cosmetic.Rainbow or cosmetic.Color, cosmetic.Name .. " needs a Color (or Rainbow = true)")
if i > 1 then
check(cosmetic.Coins > config.Cosmetics[i - 1].Coins, "Cosmetics must be listed cheapest first")
end
end
if config.Sounds then
for key, value in pairs(config.Sounds) do
if key == "Volume" then
check(type(value) == "number" and value >= 0 and value <= 1, "Sounds.Volume must be a number from 0 to 1")
else
check(type(value) == "string" and (value == "" or value:match("^%d+$") ~= nil),
"Sounds." .. tostring(key) .. " must be empty or just the number of an audio asset ID")
end
end
end
return problems
end
return RoundLogic
Small “thinking” functions: which difficulty, which event, how many coins. It also checks GameConfig for typos and prints a clear warning.
-- RoundManager (Script in ServerScriptService)
-- The "brain" of the game. It runs this loop forever:
-- lobby countdown -> teleport into arena -> 20s "don't press" -> random event -> rewards -> back to lobby
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local TweenService = game:GetService("TweenService")
local GameConfig = require(ReplicatedStorage:WaitForChild("GameConfig"))
local RoundLogic = require(ReplicatedStorage:WaitForChild("RoundLogic"))
local ArenaBuilder = require(ServerScriptService:WaitForChild("ArenaBuilder"))
local CoinManager = require(ServerScriptService:WaitForChild("CoinManager"))
local Cosmetics = require(ServerScriptService:WaitForChild("Cosmetics"))
local SoundPlayer = require(ServerScriptService:WaitForChild("SoundPlayer"))
local eventsFolder = ServerScriptService:WaitForChild("Events")
-- Catch config typos right away, with a clear message in the Output window.
local problems = RoundLogic.ValidateConfig(GameConfig)
if #problems > 0 then
for _, problem in ipairs(problems) do
warn("[GameConfig] " .. problem)
end
error("GameConfig has " .. #problems .. " problem(s). Fix them (see the warnings above) and press Play again.")
end
-- The server uses this RemoteEvent to tell every player's screen to shake, flash or show a banner.
local fxRemote = ReplicatedStorage:FindFirstChild("GameFX")
if not fxRemote then
fxRemote = Instance.new("RemoteEvent")
fxRemote.Name = "GameFX"
fxRemote.Parent = ReplicatedStorage
end
local button = ArenaBuilder.Build()
local cap = button:FindFirstChild("Cap")
local prompt = cap:FindFirstChildOfClass("ProximityPrompt")
CoinManager.Setup()
Cosmetics.Setup(fxRemote)
local FALL_DEPTH = 15 -- this far below the floor = you fell, you're out
local roundNumber = 0
local lastEventName = nil
local participants = {} -- [player] = true while they are alive in the arena
local diedConnections = {}
local presser = nil
local buttonArmed = false
local stopEvent = nil -- the running event's stop function (endRound always calls it)
local PRESS_DISTANCE = 15 -- studs; stops cheaters pressing from across the arena
---------------------------------------------------------------------
-- Helpers
---------------------------------------------------------------------
-- Players read these attributes to update their screen (see the HUD LocalScript).
local function setStatus(phase, text, timeLeft)
ReplicatedStorage:SetAttribute("Phase", phase)
ReplicatedStorage:SetAttribute("Status", text)
ReplicatedStorage:SetAttribute("TimeLeft", timeLeft or 0)
end
local function anyoneLeft()
return next(participants) ~= nil
end
local function eliminate(player)
if participants[player] then
participants[player] = nil
if player.Parent then
fxRemote:FireClient(player, "Banner", "ELIMINATED!")
end
end
end
-- Everyone still alive in the arena, with the parts events usually need.
local function getAlive()
local alive = {}
for player in pairs(participants) do
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
local root = character and character:FindFirstChild("HumanoidRootPart")
if humanoid and root and humanoid.Health > 0 then
table.insert(alive, { Player = player, Character = character, Humanoid = humanoid, Root = root })
end
end
return alive
end
-- Counts down to a fixed finish time (so it never drifts). Stops early (and returns true) if stopWhen() becomes true.
-- makeText(timeLeft) returns the status text, e.g. "Next round in 5..."
local function countdown(seconds, phase, makeText, stopWhen)
local finish = os.clock() + seconds
local shown = nil
while true do
local timeLeft = math.ceil(finish - os.clock())
if timeLeft <= 0 then
break
end
if timeLeft ~= shown then
shown = timeLeft
setStatus(phase, makeText(timeLeft), timeLeft)
end
if stopWhen and stopWhen() then
return true
end
task.wait(0.1)
end
return stopWhen ~= nil and stopWhen() == true
end
local function playPressAnimation()
local home = cap.CFrame
local down = TweenService:Create(cap, TweenInfo.new(0.12, Enum.EasingStyle.Quad, Enum.EasingDirection.In), {
CFrame = home - Vector3.new(0, 1.8, 0),
})
down:Play()
task.delay(0.4, function()
TweenService:Create(cap, TweenInfo.new(0.3, Enum.EasingStyle.Back, Enum.EasingDirection.Out), { CFrame = home }):Play()
end)
local sound = cap:FindFirstChildOfClass("Sound") -- add one from the Toolbox if you like
if sound then
sound:Play()
end
SoundPlayer.Play("ButtonPress", cap)
end
local function endRound()
if stopEvent then
pcall(stopEvent)
stopEvent = nil
end
buttonArmed = false
prompt.Enabled = false
for _, connection in ipairs(diedConnections) do
connection:Disconnect()
end
diedConnections = {}
participants = {}
presser = nil
ArenaBuilder.Reset()
end
---------------------------------------------------------------------
-- Things that can happen at any moment
---------------------------------------------------------------------
prompt.Triggered:Connect(function(player)
local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
local closeEnough = root and (root.Position - cap.Position).Magnitude <= PRESS_DISTANCE
if buttonArmed and participants[player] and closeEnough then
buttonArmed = false
prompt.Enabled = false
presser = player
end
end)
Players.PlayerRemoving:Connect(function(player)
participants[player] = nil
end)
-- Anyone who falls off the arena is out. (The lava kills too, but this check usually gets there first.)
task.spawn(function()
while true do
task.wait(0.25)
for _, info in ipairs(getAlive()) do
if info.Root.Position.Y < ArenaBuilder.FloorY - FALL_DEPTH then
info.Humanoid.Health = 0
eliminate(info.Player)
end
end
end
end)
---------------------------------------------------------------------
-- One full round
---------------------------------------------------------------------
local function runRound()
while #Players:GetPlayers() < GameConfig.MinPlayers do
setStatus("Waiting", "Waiting for players (" .. #Players:GetPlayers() .. "/" .. GameConfig.MinPlayers .. ")", 0)
task.wait(1)
end
countdown(GameConfig.IntermissionTime, "Intermission", function(timeLeft)
return "Next round in " .. timeLeft .. "..."
end)
-- 1. Teleport everyone with a living character into the arena
ArenaBuilder.Reset()
local roundPlayers = {}
for _, player in ipairs(Players:GetPlayers()) do
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if humanoid and humanoid.Health > 0 and character:FindFirstChild("HumanoidRootPart") then
character:PivotTo(CFrame.new(ArenaBuilder.GetRandomSpot(3)))
participants[player] = true
table.insert(roundPlayers, player)
table.insert(diedConnections, humanoid.Died:Connect(function()
eliminate(player)
end))
end
end
if not anyoneLeft() then
return
end
roundNumber = roundNumber + 1 -- only rounds somebody actually played count
local difficultyName = RoundLogic.GetDifficultyName(GameConfig, roundNumber)
local difficulty = GameConfig.Difficulties[difficultyName]
ReplicatedStorage:SetAttribute("Round", roundNumber)
ReplicatedStorage:SetAttribute("Difficulty", difficultyName)
-- 2. The button phase: 20 seconds of temptation
buttonArmed = true
prompt.Enabled = true
countdown(GameConfig.ButtonPhaseTime, "Button", function(timeLeft)
return "DON'T PRESS THE BUTTON! " .. timeLeft
end, function()
return presser ~= nil or not anyoneLeft()
end)
buttonArmed = false
prompt.Enabled = false
if not anyoneLeft() then
for _, player in ipairs(roundPlayers) do
if player.Parent then
CoinManager.AddCoins(player, GameConfig.Rewards.Participation)
end
end
return
end
if presser == nil and not GameConfig.AutoPressWhenTimeUp then
setStatus("Results", "Nobody pressed it. Boring... but safe.", 0)
for _, player in ipairs(roundPlayers) do
if player.Parent then
CoinManager.AddCoins(player, GameConfig.Rewards.Participation)
end
end
task.wait(GameConfig.ResultsTime)
return
end
-- 3. Somebody pressed it (or the button got impatient)
local presserName = presser and presser.Name
playPressAnimation()
fxRemote:FireAllClients("Flash")
fxRemote:FireAllClients("Shake", 0.8, 0.6)
local event = RoundLogic.PickEvent(GameConfig, roundNumber, lastEventName)
lastEventName = event.Name
if presserName then
setStatus("Pressed", presserName .. " PRESSED THE BUTTON!", 0)
else
setStatus("Pressed", "Nobody pressed it... so it pressed ITSELF!", 0)
end
task.wait(2)
fxRemote:FireAllClients("Banner", event.DisplayName)
SoundPlayer.Play("EventStart", cap)
ArenaBuilder.LiftButton(30)
-- 4. Run the event
local eventModule = eventsFolder:FindFirstChild(event.Name)
if not eventModule then
error("No ModuleScript named '" .. event.Name .. "' inside ServerScriptService > Events")
end
local context = {
Settings = difficulty[event.Settings],
Arena = ArenaBuilder,
Folder = ArenaBuilder.GetEventFolder(),
GetAlive = getAlive,
FX = function(...)
fxRemote:FireAllClients(...)
end,
}
local ok, result = pcall(function()
return require(eventModule).Start(context)
end)
if ok then
stopEvent = result
else
warn("[RoundManager] " .. event.Name .. " crashed when starting: " .. tostring(result))
end
countdown(difficulty.EventTime, "Event", function(timeLeft)
return event.DisplayName .. " " .. timeLeft
end, function()
return not anyoneLeft()
end)
if stopEvent then
pcall(stopEvent)
stopEvent = nil
end
-- 5. Rewards
local survivorCount = 0
for _, player in ipairs(roundPlayers) do
if player.Parent then
local survived = participants[player] == true
local coins = RoundLogic.CalculateReward(GameConfig, difficultyName, survived, player == presser)
CoinManager.AddCoins(player, coins)
if survived then
survivorCount = survivorCount + 1
CoinManager.AddSurvived(player)
fxRemote:FireClient(player, "Banner", "YOU SURVIVED! +" .. coins .. " coins")
else
fxRemote:FireClient(player, "Banner", "Better luck next time. +" .. coins .. " coins")
end
end
end
setStatus("Results", survivorCount .. " of " .. #roundPlayers .. " survived!", 0)
if survivorCount > 0 then
SoundPlayer.Play("Survived", cap)
end
task.wait(GameConfig.ResultsTime)
-- 6. Survivors go back to the lobby (eliminated players already respawned there)
for player in pairs(participants) do
if player.Character then
player.Character:PivotTo(CFrame.new(ArenaBuilder.GetLobbySpot()))
end
end
end
---------------------------------------------------------------------
-- Run rounds forever. If a round ever crashes, log it and start a fresh one.
---------------------------------------------------------------------
while true do
local ok, err = pcall(runRound)
if not ok then
warn("[RoundManager] Round crashed: " .. tostring(err))
setStatus("Waiting", "Oops! Restarting the round...", 0)
task.wait(2)
end
endRound()
end
The game loop: lobby countdown → teleport into the arena → 20 seconds of “don't press” → random event → rewards → back to the lobby. If nobody presses, the button gets impatient and presses itself.
-- ArenaBuilder (ModuleScript in ServerScriptService)
-- Builds the arena floor, walls, lava and lobby from GameConfig, so the events always line up.
-- If you built your own button ("TheButton") or lobby ("Lobby"), it keeps yours.
local Lighting = game:GetService("Lighting")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GameConfig = require(ReplicatedStorage:WaitForChild("GameConfig"))
local ArenaBuilder = {}
local settings = GameConfig.Arena
local colors = GameConfig.Colors
local arenaWidth = settings.TileSize * settings.TilesPerSide
-- Handy numbers other scripts use
ArenaBuilder.Center = settings.Center
ArenaBuilder.FloorY = settings.Center.Y -- the top of the floor tiles
ArenaBuilder.Width = arenaWidth
local arenaFolder, tilesFolder, eventFolder
local tiles = {}
local buttonHomePivot
local liftCount = 0 -- goes up on every Reset so an old lift stops moving the button
local function makePart(properties)
local part = Instance.new("Part")
part.Anchored = true
part.TopSurface = Enum.SurfaceType.Smooth
part.BottomSurface = Enum.SurfaceType.Smooth
part.Material = Enum.Material.SmoothPlastic
for key, value in pairs(properties) do
if key ~= "Parent" then
part[key] = value
end
end
part.Parent = properties.Parent -- set Parent last (faster and avoids flicker)
return part
end
local function getOrMakeFolder(parent, name)
local folder = parent:FindFirstChild(name)
if not folder then
folder = Instance.new("Folder")
folder.Name = name
folder.Parent = parent
end
return folder
end
local function buildFloor()
local half = arenaWidth / 2
local size = settings.TileSize
for row = 1, settings.TilesPerSide do
for column = 1, settings.TilesPerSide do
local x = settings.Center.X - half + (column - 0.5) * size
local z = settings.Center.Z - half + (row - 0.5) * size
local isEven = (row + column) % 2 == 0
local tile = makePart({
Name = "Tile_" .. row .. "_" .. column,
Size = Vector3.new(size, 2, size),
Position = Vector3.new(x, ArenaBuilder.FloorY - 1, z),
Color = isEven and colors.TileA or colors.TileB,
CastShadow = false, -- cheaper on phones
Parent = tilesFolder,
})
tile:SetAttribute("HomeColor", tile.Color)
table.insert(tiles, tile)
end
end
end
local function buildWalls()
local wallsFolder = getOrMakeFolder(arenaFolder, "Walls")
local half = arenaWidth / 2
local height = settings.WallHeight
local y = ArenaBuilder.FloorY + height / 2 - 2
local c = settings.Center
local wallData = {
{ Vector3.new(c.X, y, c.Z - half - 1), Vector3.new(arenaWidth + 4, height, 2) },
{ Vector3.new(c.X, y, c.Z + half + 1), Vector3.new(arenaWidth + 4, height, 2) },
{ Vector3.new(c.X - half - 1, y, c.Z), Vector3.new(2, height, arenaWidth) },
{ Vector3.new(c.X + half + 1, y, c.Z), Vector3.new(2, height, arenaWidth) },
}
for i, data in ipairs(wallData) do
makePart({
Name = "Wall" .. i,
Position = data[1],
Size = data[2],
Color = colors.Wall,
Transparency = 0.15,
Parent = wallsFolder,
})
end
end
local function buildLava()
local lava = makePart({
Name = "Lava",
Size = Vector3.new(arenaWidth + 60, 2, arenaWidth + 60),
Position = Vector3.new(settings.Center.X, ArenaBuilder.FloorY - 25, settings.Center.Z),
Color = colors.Lava,
Material = Enum.Material.Neon,
Parent = arenaFolder,
})
lava.Touched:Connect(function(hit)
local humanoid = hit.Parent and hit.Parent:FindFirstChildOfClass("Humanoid")
if humanoid then
humanoid.Health = 0
end
end)
end
local function buildLobbyIfMissing()
if workspace:FindFirstChild("Lobby") then
return
end
local lobby = Instance.new("Folder")
lobby.Name = "Lobby"
makePart({
Name = "LobbyFloor",
Size = Vector3.new(60, 1, 60),
Position = settings.LobbySpawn - Vector3.new(0, 1, 0),
Color = colors.LobbyFloor,
Parent = lobby,
})
local lobbySpawn = Instance.new("SpawnLocation")
lobbySpawn.Name = "LobbySpawn"
lobbySpawn.Anchored = true
lobbySpawn.Size = Vector3.new(12, 1, 12)
lobbySpawn.Position = settings.LobbySpawn
lobbySpawn.Color = colors.Steps
lobbySpawn.Duration = 0 -- no force-field bubble (it would block flood damage)
lobbySpawn.Parent = lobby
lobby.Parent = workspace
end
-- The Baseplate template has its own SpawnLocation right under the arena. Switch off
-- every spawn that isn't in the Lobby, so players always (re)spawn in the lobby.
local function disableOtherSpawns()
local lobby = workspace:FindFirstChild("Lobby")
for _, item in ipairs(workspace:GetDescendants()) do
if item:IsA("SpawnLocation") and not (lobby and item:IsDescendantOf(lobby)) then
item.Enabled = false
end
end
end
local function buildSignIfMissing()
if workspace:FindFirstChild("Sign") then
return
end
local c = settings.Center
local sign = makePart({
Name = "Sign",
Size = Vector3.new(30, 12, 1),
Position = Vector3.new(c.X, ArenaBuilder.FloorY + 16, c.Z - arenaWidth / 2 + 1),
Color = colors.SignBoard,
Parent = workspace,
})
local gui = Instance.new("SurfaceGui")
gui.Face = Enum.NormalId.Back -- the side facing into the arena
gui.SizingMode = Enum.SurfaceGuiSizingMode.PixelsPerStud
gui.LightInfluence = 0
local label = Instance.new("TextLabel")
label.Size = UDim2.fromScale(1, 1)
label.BackgroundTransparency = 1
label.Text = "DO NOT PRESS"
label.TextScaled = true
label.Font = Enum.Font.FredokaOne
label.TextColor3 = colors.SignText
label.Parent = gui
gui.Parent = sign
end
local function buildButtonIfMissing()
local button = workspace:FindFirstChild("TheButton")
if not button then
warn("[ArenaBuilder] No 'TheButton' model found, building a basic one for you.")
button = Instance.new("Model")
button.Name = "TheButton"
local c = settings.Center
local pedestal = makePart({
Name = "Pedestal",
Shape = Enum.PartType.Cylinder,
Size = Vector3.new(3, 14, 14),
CFrame = CFrame.new(c.X, ArenaBuilder.FloorY + 1.5, c.Z) * CFrame.Angles(0, 0, math.rad(90)),
Color = colors.Pedestal,
Parent = button,
})
makePart({
Name = "Cap",
Shape = Enum.PartType.Cylinder,
Size = Vector3.new(3, 8, 8),
CFrame = CFrame.new(c.X, ArenaBuilder.FloorY + 4.5, c.Z) * CFrame.Angles(0, 0, math.rad(90)),
Color = colors.ButtonCap,
Parent = button,
})
button.PrimaryPart = pedestal
button.Parent = workspace
end
local cap = button:FindFirstChild("Cap")
if not cap then
error("[ArenaBuilder] Your TheButton model needs a Part named 'Cap' (the red part you press).")
end
for _, part in ipairs(button:GetDescendants()) do
if part:IsA("BasePart") then
part.Anchored = true -- in case one was missed: unanchored parts fall when Play starts
part:SetAttribute("HomeCanCollide", part.CanCollide)
end
end
local prompt = cap:FindFirstChildOfClass("ProximityPrompt")
if not prompt then
prompt = Instance.new("ProximityPrompt")
prompt.Parent = cap
end
prompt.ActionText = "PRESS IT?"
prompt.ObjectText = "DO NOT PRESS"
prompt.HoldDuration = 0
prompt.MaxActivationDistance = 12
prompt.RequiresLineOfSight = false
prompt.Enabled = false
buttonHomePivot = button:GetPivot()
return button
end
local function applyLighting()
-- LightingStyle / PrioritizeLightingQuality can't be set from a script:
-- set them by hand in Lighting's Properties (Soft + off is best for phones).
Lighting.ClockTime = 14
Lighting.Brightness = 3
Lighting.Ambient = Color3.fromRGB(110, 110, 125)
Lighting.OutdoorAmbient = Color3.fromRGB(140, 150, 170)
Lighting.EnvironmentDiffuseScale = 0.6
Lighting.EnvironmentSpecularScale = 0.3
local atmosphere = Lighting:FindFirstChildOfClass("Atmosphere") or Instance.new("Atmosphere")
atmosphere.Density = 0.25
atmosphere.Offset = 0.1
atmosphere.Color = Color3.fromRGB(199, 225, 255)
atmosphere.Decay = Color3.fromRGB(255, 230, 200)
atmosphere.Haze = 0.8
atmosphere.Glare = 0
atmosphere.Parent = Lighting
local bloom = Lighting:FindFirstChildOfClass("BloomEffect") or Instance.new("BloomEffect")
bloom.Intensity = 0.6
bloom.Size = 24
bloom.Threshold = 1.6
bloom.Parent = Lighting
local grade = Lighting:FindFirstChildOfClass("ColorCorrectionEffect") or Instance.new("ColorCorrectionEffect")
grade.Contrast = 0.15
grade.Saturation = 0.25
grade.Parent = Lighting
end
function ArenaBuilder.Build()
arenaFolder = getOrMakeFolder(workspace, "Arena")
tilesFolder = getOrMakeFolder(arenaFolder, "FloorTiles")
eventFolder = getOrMakeFolder(arenaFolder, "EventStuff")
if #tilesFolder:GetChildren() == 0 then
buildFloor()
buildWalls()
buildLava()
else
for _, tile in ipairs(tilesFolder:GetChildren()) do
table.insert(tiles, tile)
end
end
buildLobbyIfMissing()
disableOtherSpawns()
buildSignIfMissing()
local button = buildButtonIfMissing()
if settings.ApplyLighting then
applyLighting()
end
return button
end
function ArenaBuilder.GetTiles()
return tiles
end
-- Where event scripts put the things they create, so cleanup is one line.
function ArenaBuilder.GetEventFolder()
return eventFolder
end
-- Put every tile back and delete everything the last event created.
function ArenaBuilder.Reset()
for _, tile in ipairs(tiles) do
tile.Transparency = 0
tile.CanCollide = true
tile.Material = Enum.Material.SmoothPlastic
tile.Color = tile:GetAttribute("HomeColor") or colors.TileA
end
eventFolder:ClearAllChildren()
liftCount = liftCount + 1
local button = workspace:FindFirstChild("TheButton")
if button and buttonHomePivot then
button:PivotTo(buttonHomePivot)
for _, part in ipairs(button:GetDescendants()) do
if part:IsA("BasePart") and part:GetAttribute("HomeCanCollide") ~= nil then
part.CanCollide = part:GetAttribute("HomeCanCollide")
end
end
end
end
-- A random standing spot on the arena floor, not too close to the button in the middle.
function ArenaBuilder.GetRandomSpot(heightAboveFloor)
local c = settings.Center
local limit = arenaWidth / 2 - 6
local x, z
repeat
x = (math.random() * 2 - 1) * limit
z = (math.random() * 2 - 1) * limit
until math.abs(x) > 10 or math.abs(z) > 10
return Vector3.new(c.X + x, ArenaBuilder.FloorY + (heightAboveFloor or 3), c.Z + z)
end
-- The button floats up out of the way during events. It turns non-solid first,
-- so nobody can stand on it and ride it up to safety.
function ArenaBuilder.LiftButton(height)
local button = workspace:FindFirstChild("TheButton")
if not (button and buttonHomePivot) then
return
end
for _, part in ipairs(button:GetDescendants()) do
if part:IsA("BasePart") then
part.CanCollide = false
end
end
liftCount = liftCount + 1
local myLift = liftCount
task.spawn(function()
for i = 1, 20 do
if liftCount ~= myLift then
return -- the arena was reset, stop moving the button
end
button:PivotTo(buttonHomePivot + Vector3.new(0, height * i / 20, 0))
task.wait(0.05)
end
end)
end
function ArenaBuilder.GetLobbySpot()
local offset = Vector3.new((math.random() * 2 - 1) * 15, 3, (math.random() * 2 - 1) * 15)
return settings.LobbySpawn + offset
end
return ArenaBuilder
Builds the floor, walls, lava, lobby and lighting, and resets everything between rounds. It keeps your hand-built TheButton and Sign (and anchors the button in case you forgot). If you skipped Step 3, it builds basic ones for you.
-- SoundPlayer (ModuleScript in ServerScriptService)
-- Plays the sounds listed in GameConfig.Sounds. If an ID is empty it quietly does nothing,
-- so the game works fine with no audio at all.
local Debris = game:GetService("Debris")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GameConfig = require(ReplicatedStorage:WaitForChild("GameConfig"))
local SoundPlayer = {}
local LIFETIME = 10 -- seconds before the little Sound object is cleaned up
-- Returns the Sound it started, or nil when there is nothing to play.
function SoundPlayer.Play(soundName, parent)
local sounds = GameConfig.Sounds
local id = sounds and sounds[soundName]
if type(id) ~= "string" or id == "" or parent == nil then
return nil
end
local sound = Instance.new("Sound")
sound.SoundId = "rbxassetid://" .. id
sound.Volume = sounds.Volume or 0.6
sound.Parent = parent
sound:Play()
Debris:AddItem(sound, LIFETIME)
return sound
end
return SoundPlayer
Plays the sounds listed in GameConfig. Until you paste in a sound ID (Step 10) it quietly does nothing, so the game works fine in silence. RoundManager needs it, so add it now.
-- HUD (LocalScript in StarterPlayer > StarterPlayerScripts)
-- Runs on each player's own computer/phone. It builds the on-screen text and plays screen effects.
-- It only DISPLAYS things; the server decides everything (so nobody can cheat coins from here).
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local player = Players.LocalPlayer
local fxRemote = ReplicatedStorage:WaitForChild("GameFX")
local FONT = Enum.Font.FredokaOne
local SHAKE_ENABLED = true -- set false if screen shake makes anyone feel sick
---------------------------------------------------------------------
-- Build the screen (Scale sizes so it fits phones and big monitors)
---------------------------------------------------------------------
local gui = Instance.new("ScreenGui")
gui.Name = "HUD"
gui.ResetOnSpawn = false -- keep it when your character respawns
local function makeLabel(name, position, size)
local label = Instance.new("TextLabel")
label.Name = name
label.AnchorPoint = Vector2.new(0.5, 0.5)
label.Position = position
label.Size = size
label.BackgroundTransparency = 1
label.Font = FONT
label.TextScaled = true
label.TextColor3 = Color3.fromRGB(255, 255, 255)
label.Text = ""
local stroke = Instance.new("UIStroke")
stroke.Thickness = 3
stroke.Color = Color3.fromRGB(20, 24, 36)
stroke.Parent = label
local limit = Instance.new("UITextSizeConstraint")
limit.MinTextSize = 16
limit.MaxTextSize = 64
limit.Parent = label
label.Parent = gui
return label
end
local status = makeLabel("Status", UDim2.fromScale(0.5, 0.06), UDim2.fromScale(0.6, 0.07))
status.BackgroundTransparency = 0.35
status.BackgroundColor3 = Color3.fromRGB(20, 24, 36)
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0.3, 0)
corner.Parent = status
local timer = makeLabel("Timer", UDim2.fromScale(0.5, 0.16), UDim2.fromScale(0.2, 0.1))
local info = makeLabel("Info", UDim2.fromScale(0.5, 0.96), UDim2.fromScale(0.4, 0.04))
local banner = makeLabel("Banner", UDim2.fromScale(0.5, 0.4), UDim2.fromScale(0.8, 0.12))
banner.TextColor3 = Color3.fromRGB(255, 220, 60)
banner.Visible = false
local flash = Instance.new("Frame")
flash.Name = "Flash"
flash.Size = UDim2.fromScale(1, 1)
flash.BackgroundColor3 = Color3.fromRGB(255, 40, 40)
flash.BackgroundTransparency = 1
flash.BorderSizePixel = 0
flash.ZIndex = 0
flash.Parent = gui
gui.Parent = player:WaitForChild("PlayerGui")
---------------------------------------------------------------------
-- Show what the server is telling us
---------------------------------------------------------------------
local function updateStatus()
status.Text = ReplicatedStorage:GetAttribute("Status") or "Loading..."
local timeLeft = ReplicatedStorage:GetAttribute("TimeLeft") or 0
timer.Text = timeLeft > 0 and tostring(timeLeft) or ""
-- the last 5 seconds of the button phase turn red
local phase = ReplicatedStorage:GetAttribute("Phase")
if phase == "Button" and timeLeft <= 5 then
timer.TextColor3 = Color3.fromRGB(255, 70, 70)
else
timer.TextColor3 = Color3.fromRGB(255, 255, 255)
end
end
local function updateInfo()
local round = ReplicatedStorage:GetAttribute("Round")
local difficulty = ReplicatedStorage:GetAttribute("Difficulty")
if round then
info.Text = "Round " .. round .. " | " .. tostring(difficulty)
end
end
ReplicatedStorage:GetAttributeChangedSignal("Status"):Connect(updateStatus)
ReplicatedStorage:GetAttributeChangedSignal("TimeLeft"):Connect(updateStatus)
ReplicatedStorage:GetAttributeChangedSignal("Round"):Connect(updateInfo)
ReplicatedStorage:GetAttributeChangedSignal("Difficulty"):Connect(updateInfo)
updateStatus()
updateInfo()
---------------------------------------------------------------------
-- Screen effects
---------------------------------------------------------------------
local bannerToken = 0
local function showBanner(text)
bannerToken = bannerToken + 1
local myToken = bannerToken
banner.Text = text
banner.Visible = true
task.delay(2.5, function()
if bannerToken == myToken then -- a newer banner replaces this one
banner.Visible = false
end
end)
end
local function playFlash()
flash.BackgroundTransparency = 0.4
TweenService:Create(flash, TweenInfo.new(0.6), { BackgroundTransparency = 1 }):Play()
end
local function shake(strength, duration)
if not SHAKE_ENABLED then
return
end
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if not humanoid then
return
end
local started = os.clock()
local connection
connection = RunService.RenderStepped:Connect(function()
local progress = (os.clock() - started) / duration
if progress >= 1 or humanoid.Parent == nil then
humanoid.CameraOffset = Vector3.new(0, 0, 0)
connection:Disconnect()
return
end
local amount = strength * (1 - progress) -- fades out
humanoid.CameraOffset = Vector3.new((math.random() * 2 - 1) * amount, (math.random() * 2 - 1) * amount, 0)
end)
end
fxRemote.OnClientEvent:Connect(function(kind, a, b)
if kind == "Banner" then
showBanner(a)
elseif kind == "Flash" then
playFlash()
elseif kind == "Shake" then
shake(a or 0.5, b or 0.5)
end
end)
Draws the status bar, the big countdown and the banners, and plays the red flash and screen shake. It only displays things, so nobody can cheat coins from their own computer.
Step 5
Three random events (scripts, part 2)
In ServerScriptService, click + → Folder and name it Events. Inside it, add three ModuleScripts. Each event has a Start function that sets up the chaos and returns a “stop” function.
Disappearing Floor
Tiles flash orange, then vanish. Lava below. Some tiles always survive.
Rising Flood
Water rises and two staircases appear. Climb or drown slowly.
Giant Rolling Ball
Unlocks on round 3. It aims at someone, pauses, then charges. Side-step it!
The button escapes
During every event the button floats up out of reach, so nobody can hide on it.
-- DisappearingFloor (ModuleScript inside ServerScriptService > Events)
-- Tiles flash orange, then vanish. Don't be standing on them. The lava is waiting.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GameConfig = require(ReplicatedStorage:WaitForChild("GameConfig"))
local DisappearingFloor = {}
function DisappearingFloor.Start(context)
local settings = context.Settings
local remaining = {}
for _, tile in ipairs(context.Arena.GetTiles()) do
table.insert(remaining, tile)
end
local running = true
task.spawn(function()
while running do
local count = math.min(settings.TilesPerWave, #remaining - settings.KeepTiles)
if count > 0 then
local wave = {}
for _ = 1, count do
local tile = table.remove(remaining, math.random(1, #remaining))
tile.Color = GameConfig.Colors.TileWarning
tile.Material = Enum.Material.Neon
table.insert(wave, tile)
end
task.wait(settings.WarnTime)
if not running then
break
end
for _, tile in ipairs(wave) do
tile.Transparency = 1
tile.CanCollide = false
end
end
task.wait(settings.WaveInterval - settings.WarnTime)
end
end)
return function()
running = false
end
end
return DisappearingFloor
RisingFlood · ModuleScript · Events · 79 lines
-- RisingFlood (ModuleScript inside ServerScriptService > Events)
-- Water fills the arena. Two staircases appear: climb before the water reaches you!
local TweenService = game:GetService("TweenService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GameConfig = require(ReplicatedStorage:WaitForChild("GameConfig"))
local RisingFlood = {}
local STEP_HEIGHTS = { 4, 8, 12, 16, 20, 24, 28 } -- a 7-stud jump easily goes up 4
local function makeStep(folder, position)
local step = Instance.new("Part")
step.Name = "Step"
step.Anchored = true
step.Size = Vector3.new(8, 1, 8)
step.Position = position
step.Color = GameConfig.Colors.Steps
step.Material = Enum.Material.SmoothPlastic
step.Parent = folder
end
function RisingFlood.Start(context)
local settings = context.Settings
local arena = context.Arena
local center = arena.Center
local floorY = arena.FloorY
local half = arena.Width / 2
-- One staircase along the north wall, a mirrored one along the south wall.
for i, height in ipairs(STEP_HEIGHTS) do
local offsetX = -half + 6 + (i - 1) * 10
local y = floorY + height - 0.5
makeStep(context.Folder, Vector3.new(center.X + offsetX, y, center.Z - half + 6))
makeStep(context.Folder, Vector3.new(center.X - offsetX, y, center.Z + half - 6))
end
local water = Instance.new("Part")
water.Name = "FloodWater"
water.Anchored = true
water.CanCollide = false
water.Material = Enum.Material.Glass
water.Color = GameConfig.Colors.Water
water.Transparency = 0.35
water.Size = Vector3.new(arena.Width, 1, arena.Width)
water.Position = Vector3.new(center.X, floorY + 0.5, center.Z)
water.Parent = context.Folder
local rise = TweenService:Create(water, TweenInfo.new(settings.RiseTime, Enum.EasingStyle.Linear), {
Size = Vector3.new(arena.Width, settings.MaxHeight, arena.Width),
Position = Vector3.new(center.X, floorY + settings.MaxHeight / 2, center.Z),
})
rise:Play()
local running = true
task.spawn(function()
while running do
local elapsed = task.wait(0.25)
if not running then
break
end
local waterTop = water.Position.Y + water.Size.Y / 2
for _, info in ipairs(context.GetAlive()) do
-- the root part is about waist height, so this means "waist-deep or worse"
if info.Root.Position.Y < waterTop then
info.Humanoid:TakeDamage(settings.DamagePerSecond * elapsed)
end
end
end
end)
return function()
running = false
rise:Cancel()
end
end
return RisingFlood
RollingBall · ModuleScript · Events · 97 lines
-- RollingBall (ModuleScript inside ServerScriptService > Events)
-- A giant ball picks a victim, pauses, then charges in a straight line. Side-step it!
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GameConfig = require(ReplicatedStorage:WaitForChild("GameConfig"))
local RollingBall = {}
local AIM_PAUSE = 0.6 -- seconds the ball "thinks" before each charge (your chance to dodge)
local function runBall(context, ball, isRunning)
local settings = context.Settings
local arena = context.Arena
local radius = settings.Size / 2
local limit = arena.Width / 2 - radius
local center = arena.Center
local y = arena.FloorY + radius
local position = ball.Position
local spin = CFrame.new()
while isRunning() do
-- 1. pick a target and aim
local alive = context.GetAlive()
local direction
if #alive > 0 then
local target = alive[math.random(1, #alive)].Root.Position
direction = Vector3.new(target.X - position.X, 0, target.Z - position.Z)
end
if direction == nil or direction.Magnitude < 1 then
local angle = math.random() * math.pi * 2
direction = Vector3.new(math.cos(angle), 0, math.sin(angle))
end
direction = direction.Unit
task.wait(AIM_PAUSE)
-- 2. charge until we hit a wall
local hitWall = false
while isRunning() and not hitWall do
local elapsed = task.wait()
local nextPosition = position + direction * settings.Speed * elapsed
local offsetX = nextPosition.X - center.X
local offsetZ = nextPosition.Z - center.Z
if math.abs(offsetX) > limit or math.abs(offsetZ) > limit then
hitWall = true
offsetX = math.clamp(offsetX, -limit, limit)
offsetZ = math.clamp(offsetZ, -limit, limit)
end
position = Vector3.new(center.X + offsetX, y, center.Z + offsetZ)
-- make it look like it's rolling
local rollAxis = Vector3.new(direction.Z, 0, -direction.X)
spin = CFrame.fromAxisAngle(rollAxis, settings.Speed * elapsed / radius) * spin
ball.CFrame = CFrame.new(position) * spin
for _, info in ipairs(context.GetAlive()) do
if (info.Root.Position - position).Magnitude < radius + 1.5 then
info.Humanoid.Health = 0
end
end
end
if hitWall then
context.FX("Shake", 0.6, 0.4)
end
end
end
function RollingBall.Start(context)
local settings = context.Settings
local running = true
local function isRunning()
return running
end
for i = 1, settings.Count do
local ball = Instance.new("Part")
ball.Name = "GiantBall" .. i
ball.Shape = Enum.PartType.Ball
ball.Size = Vector3.new(settings.Size, settings.Size, settings.Size)
ball.Anchored = true -- the script moves it, so physics can't fling it out of the arena
ball.CanCollide = false
ball.Color = GameConfig.Colors.Ball
ball.Material = Enum.Material.SmoothPlastic
-- start in a corner, away from the middle
local corner = (i % 2 == 1) and 1 or -1
local start = context.Arena.Center + Vector3.new(corner, 0, corner) * (context.Arena.Width / 2 - settings.Size / 2)
ball.Position = Vector3.new(start.X, context.Arena.FloorY + settings.Size / 2, start.Z)
ball.Parent = context.Folder
task.spawn(runBall, context, ball, isRunning)
end
return function()
running = false
end
end
return RollingBall
Step 6
Survival, coins and rewards (scripts, part 3)
Two more ModuleScripts in ServerScriptService. After these, press Play!
Everyone in the arena
1 coin
Survived the event
+10 coins
Pressed it and survived
+5 “brave” bonus
Harder difficulty
× 1 (Chill), × 1.5 (Normal), × 2.5 (Chaos)
Trails
Bronze at 50 coins, Silver 150, Gold 400, Rainbow 1000. They switch on automatically.
-- CoinManager (ModuleScript in ServerScriptService)
-- Shows Coins and Survived on the leaderboard and saves them between visits.
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GameConfig = require(ReplicatedStorage:WaitForChild("GameConfig"))
local CoinManager = {}
local AUTOSAVE_SECONDS = 120
local LOAD_TRIES = 3
local store = nil
local loadedOk = {} -- [player] = true once their saved data loaded safely
local saving = {} -- [player] = true while a save is running (so we never save twice at once)
local function keyFor(player)
return "player_" .. player.UserId
end
local function getStat(player, statName)
local leaderstats = player:FindFirstChild("leaderstats")
return leaderstats and leaderstats:FindFirstChild(statName)
end
local function loadData(player)
local ok, data
for try = 1, LOAD_TRIES do
ok, data = pcall(function()
return store:GetAsync(keyFor(player))
end)
if ok then
return true, data
end
task.wait(try) -- wait a little longer each time
end
return false, data
end
local function onPlayerAdded(player)
-- Load first, THEN show the stats, so the numbers never jump from 0 to the saved amount.
local saved = nil
if store then
local ok, data = loadData(player)
if ok then
saved = data
loadedOk[player] = true
else
-- Never save over data we couldn't read, or we might wipe someone's coins.
warn("[CoinManager] Couldn't load data for " .. player.Name .. ", progress won't save this visit: " .. tostring(data))
end
end
if player.Parent == nil then
loadedOk[player] = nil
return -- they left while we were loading
end
-- "leaderstats" is a special name: Roblox shows these values on the player list.
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
local coins = Instance.new("IntValue")
coins.Name = "Coins"
coins.Parent = leaderstats
local survived = Instance.new("IntValue")
survived.Name = "Survived"
survived.Parent = leaderstats
if type(saved) == "table" then
coins.Value = tonumber(saved.Coins) or 0
survived.Value = tonumber(saved.Survived) or 0
end
leaderstats.Parent = player
end
local function save(player)
if not (store and loadedOk[player]) or saving[player] then
return
end
local coins = getStat(player, "Coins")
local survived = getStat(player, "Survived")
if not (coins and survived) then
return
end
local data = { Coins = coins.Value, Survived = survived.Value }
saving[player] = true
local ok, err = pcall(function()
store:UpdateAsync(keyFor(player), function(old)
-- Coins only ever go up. If the stored number is already higher (another server
-- saved more recently), keep it: returning nil means "don't change anything".
-- (If you add a shop where coins get SPENT, this check must change. See Level 3.)
if type(old) == "table" and (tonumber(old.Coins) or 0) > data.Coins then
return nil
end
return data
end)
end)
saving[player] = nil
if not ok then
warn("[CoinManager] Save failed for " .. player.Name .. ": " .. tostring(err))
end
end
function CoinManager.Setup()
if GameConfig.SaveData then
local ok, result = pcall(function()
return DataStoreService:GetDataStore(GameConfig.DataStoreName)
end)
if ok then
store = result
else
warn("[CoinManager] Saving is off (turn on API access in Experience Settings > Security): " .. tostring(result))
end
end
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayerAdded, player)
end
Players.PlayerRemoving:Connect(function(player)
while saving[player] do
task.wait(0.1) -- an autosave is running; wait, then save the final numbers
end
save(player)
loadedOk[player] = nil -- so the shutdown save below skips them
end)
-- When the server shuts down, save everyone still here, then wait for every save to finish.
game:BindToClose(function()
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(save, player)
end
while next(saving) ~= nil do
task.wait(0.1)
end
end)
task.spawn(function()
while true do
task.wait(AUTOSAVE_SECONDS)
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(save, player)
end
end
end)
end
function CoinManager.AddCoins(player, amount)
local coins = getStat(player, "Coins")
if coins then
coins.Value = coins.Value + amount
end
end
function CoinManager.AddSurvived(player)
local survived = getStat(player, "Survived")
if survived then
survived.Value = survived.Value + 1
end
end
function CoinManager.GetCoins(player)
local coins = getStat(player, "Coins")
return coins and coins.Value or 0
end
return CoinManager
Puts Coins and Survived on the player list. It also saves them, once you turn saving on in Step 11. Until then the game still runs and Output shows one friendly warning.
-- Cosmetics (ModuleScript in ServerScriptService)
-- Gives each player the best trail their coins have unlocked (see GameConfig.Cosmetics).
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GameConfig = require(ReplicatedStorage:WaitForChild("GameConfig"))
local RoundLogic = require(ReplicatedStorage:WaitForChild("RoundLogic"))
local Cosmetics = {}
local equipped = {} -- [player] = name of the trail they have on
local function getCoins(player)
local leaderstats = player:FindFirstChild("leaderstats")
local coins = leaderstats and leaderstats:FindFirstChild("Coins")
return coins and coins.Value or 0
end
local function makeColorSequence(cosmetic)
if cosmetic.Rainbow then
return ColorSequence.new({
ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 60, 60)),
ColorSequenceKeypoint.new(0.25, Color3.fromRGB(255, 200, 40)),
ColorSequenceKeypoint.new(0.5, Color3.fromRGB(60, 220, 90)),
ColorSequenceKeypoint.new(0.75, Color3.fromRGB(60, 140, 255)),
ColorSequenceKeypoint.new(1, Color3.fromRGB(190, 80, 255)),
})
end
return ColorSequence.new(cosmetic.Color)
end
local function applyTrail(character, cosmetic)
local root = character:WaitForChild("HumanoidRootPart", 5)
if not root then
return
end
local old = root:FindFirstChild("CosmeticTrail")
if old then
old:Destroy()
end
if not cosmetic then
return
end
-- A Trail draws between two Attachments as the character moves.
local top = root:FindFirstChild("TrailTop") or Instance.new("Attachment")
top.Name = "TrailTop"
top.Position = Vector3.new(0, 1, 0)
top.Parent = root
local bottom = root:FindFirstChild("TrailBottom") or Instance.new("Attachment")
bottom.Name = "TrailBottom"
bottom.Position = Vector3.new(0, -1, 0)
bottom.Parent = root
local trail = Instance.new("Trail")
trail.Name = "CosmeticTrail"
trail.Attachment0 = top
trail.Attachment1 = bottom
trail.Color = makeColorSequence(cosmetic)
trail.Transparency = NumberSequence.new(0.2, 1)
trail.Lifetime = 0.6
trail.Parent = root
end
-- quiet = true: equip without the "unlocked" banner (used when someone joins with old coins)
local function refresh(player, fxRemote, quiet)
local best = RoundLogic.GetBestCosmetic(GameConfig, getCoins(player))
local bestName = best and best.Name or nil
if bestName == equipped[player] then
return
end
equipped[player] = bestName
if player.Character then
task.spawn(applyTrail, player.Character, best)
end
if best and fxRemote and not quiet then
fxRemote:FireClient(player, "Banner", "TRAIL UNLOCKED: " .. best.Name)
end
end
local function onPlayerAdded(player, fxRemote)
player.CharacterAdded:Connect(function(character)
local best = RoundLogic.GetBestCosmetic(GameConfig, getCoins(player))
applyTrail(character, best)
end)
local leaderstats = player:WaitForChild("leaderstats", 10)
local coins = leaderstats and leaderstats:WaitForChild("Coins", 10)
if not coins then
return
end
refresh(player, fxRemote, true) -- coins are already loaded here (CoinManager loads before showing stats)
coins.Changed:Connect(function()
refresh(player, fxRemote)
end)
end
function Cosmetics.Setup(fxRemote)
Players.PlayerAdded:Connect(function(player)
onPlayerAdded(player, fxRemote)
end)
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayerAdded, player, fxRemote)
end
Players.PlayerRemoving:Connect(function(player)
equipped[player] = nil
end)
end
return Cosmetics
Step 7
Test it: alone first, then with “players”
Solo test
Pick Test in the top-left dropdown and press Play. Here's what should happen:
0:00You spawn in the lobby. Top of screen: “Next round in 10…”. The arena floats in the sky.0:10You're teleported into the arena. “DON'T PRESS THE BUTTON! 20”. Walk to the button and a Press prompt appears.≤ 0:30Press it (or wait and it presses itself). Red flash, screen shake, a banner names the event, and the button floats away.+20–30 sSurvive! Then “1 of 1 survived!” and coins appear on the player list (top right).+5 sBack to the lobby. Round 2 starts.
Press Stop to finish. Anything you change while playing is thrown away when you stop.
Multiplayer test
Choose Server & Clients in the dropdown and set the number of players to 2 or 3.
Press Play. Studio opens one server window and one window per player.
In one player window, press the button. The other windows should say “Player1 PRESSED THE BUTTON!”.
When you're done, click End Session to close every window.
Checklist: tick these off in your test
The countdown goes 20 → 1 and stops early when someone presses.
Only players in the arena, standing near the button, can press it.
Falling into the lava, drowning or getting squashed sends you back to the lobby, and you sit out the rest of the round.
Survivors get more coins than eliminated players. The presser gets the bonus only if they survive.
Round 3 or later: the giant ball can appear.
After each round the floor is whole again and the button is back.
Output has no red errors.
Level 2 · Builder
Make it yours
The game has three difficulty levels and you choose how they're used. Everything here is done by changing numbers or adding one file.
Step 8
Choose the difficulty and tune the game
Open GameConfig and change DifficultyMode:
"Escalating": Chill → Normal (round 4) → Chaos (round 9)"Chill""Normal""Chaos"
Here's what each level does with the current numbers. The test suite computes this table from the real config, and it fails if any level becomes impossible or too safe:
Setting
Chill
Normal
Chaos
Event length (s)
20
25
30
Coin multiplier
x1
x1.5
x2.5
Flood: dry steps (of 7)
4
3
2
Flood: floor-campers drown at
10.0s
6.1s
3.8s
Floor: tiles gone by the end
30 / 100
80 / 100
94 / 100
MoonFloor: tiles gone (same floor settings)
30 / 100
80 / 100
94 / 100
MoonFloor: seconds in the air per jump
1.1s (normal 0.5s)
1.1s (normal 0.5s)
1.1s (normal 0.5s)
MoonFloor: flight distance at walk speed
17 studs
17 studs
17 studs
Ball: speed vs walk speed
1.4x (1 balls)
1.9x (1 balls)
2.5x (2 balls)
Ball: seconds to cross arena
3.0s
2.0s
1.4s
Coins: survive / brave survive
11 / 16
17 / 24
28 / 40
Rounds to Bronze / Gold (all survived)
5 / 37
3 / 24
2 / 15
Experiments to try (change one, press Play, see what happens)
ButtonPhaseTime = 5 for a frantic decision.
AutoPressWhenTimeUp = false. Now if nobody presses, everyone gets a boring but safe round.
Chaos.Ball.Count = 4. Good luck.
Weight = 5 on your favourite event so it shows up more often.
MinPlayers = 2 before you publish, so rounds wait for a friend.
Made a typo? RoundLogic checks the config when the game starts and lists every problem in Output, for example [GameConfig] Unknown DifficultyMode: Chaoss.
Step 9
Add your own event: Moon Mode
A new event is one ModuleScript plus one line in the config. Moon Mode lowers gravity and reuses the vanishing floor, so everyone does slow-motion leaps over the gaps.
Using the downloaded game file? Moon Mode is already inside it (look in ServerScriptService → Events). Do this step anyway to see how an event is made, or skip to Step 10.
In Events, add a ModuleScript called MoonFloor and paste the code below.
In GameConfig.Events, add this line under the other three: { Name = "MoonFloor", Settings = "Floor", DisplayName = "MOON MODE! (the floor is STILL leaving)", UnlockRound = 5, Weight = 1 },
Play until round 5. To test it straight away, set UnlockRound = 1 and a big Weight, then change them back.
MoonFloor · ModuleScript · Events · 30 lines
-- MoonFloor (Level 2 bonus event: ModuleScript inside ServerScriptService > Events)
-- Low gravity AND the floor disappears. Shows how a new event can reuse an old one.
-- It is already switched on in GameConfig.Events (it unlocks on round 5):
-- { Name = "MoonFloor", Settings = "Floor", DisplayName = "MOON MODE! (the floor is STILL leaving)", UnlockRound = 5, Weight = 1 },
local DisappearingFloor = require(script.Parent:WaitForChild("DisappearingFloor"))
local MoonFloor = {}
local MOON_GRAVITY = 50 -- normal Roblox gravity is 196.2
function MoonFloor.Start(context)
local normalGravity = workspace.Gravity
workspace.Gravity = MOON_GRAVITY
context.FX("Banner", "Gravity is broken!")
-- If the floor part breaks, put gravity back before passing the error on.
local started, stopFloor = pcall(DisappearingFloor.Start, context)
if not started then
workspace.Gravity = normalGravity
error(stopFloor)
end
return function()
stopFloor()
workspace.Gravity = normalGravity
end
end
return MoonFloor
Every event follows the same pattern: Start(context) gets the difficulty settings, the arena and the list of living players, and returns a function that stops the event. Put anything you create in context.Folder and it's cleaned up for you after the round.
Step 10
Make it look and sound great
The look is a bright toy diorama: calm teal, white and yellow everywhere, so the red button and orange danger tiles are the only scary colours on screen.
Lighting (two settings only you can change)
Click Lighting in Explorer. In Properties set LightingStyle = Soft and turn PrioritizeLightingQuality off. Scripts can't set these, and this choice runs smoothly on phones. (The old Technology setting is gone.)
ArenaBuilder sets the rest when the game starts: time of day 14:00, soft haze, bloom so Neon glows, and a little extra colour. Set Arena.ApplyLighting = false in GameConfig if you'd rather design the lighting yourself.
Sound
The game ships silent on purpose: no sound IDs are included, because every sound has to be one you've checked is free to use. Adding one takes a minute.
Open the Creator Store (create.roblox.com/store/audio) or Studio's Toolbox → Audio and search for “button click”, “buzzer” or “alarm”. Pick sounds marked free to use.
Open a sound's page and copy its asset ID: the number in the page address (for example .../asset/1234567890).
In GameConfig, find the Sounds section and paste just the number between the quotes, for example ButtonPress = "1234567890". There are three slots: ButtonPress, EventStart (when the chaos begins) and Survived. Leave a slot empty for silence. Volume goes from 0 to 1.
Press Play. SoundPlayer plays it for everybody. A typo shows up in Output as a [GameConfig] warning.
Prefer the old way? Drag a Sound onto Cap in Explorer. RoundManager plays any Sound inside Cap when the button is pressed.
Ideas for more juice later
A ParticleEmitter burst from the cap on press (Rate 0, then :Emit(25)).
Make the last 5 seconds of the countdown pulse bigger.
Confetti for survivors.
A different sky colour for each event.
Keeping phones happy: everything that doesn't move is Anchored, floor tiles don't cast shadows, and there's no blur or depth-of-field. Keep particle emitters to about 6 at once.
Level 3 · Creator
Ship it
Getting real players, keeping their coins, and growing the game.
Step 11
Publish and turn on saving
Short on time? Open the downloaded DontPressTheButton.rbxlx in Studio, press Play to check it, then use File → Publish to Roblox and carry on from the second item below.
File → Publish (publish to Roblox). Add a name, description and genre. New games are private: only you can play until you release it.
File → Experience Settings → Security → turn on Enable Studio Access to API Services → Save.
Press Play, earn some coins, Stop, then Play again. Your coins should still be there.
Studio uses the same saved data as the real game. Coins you earn while testing are real saves. To start fresh, change DataStoreName in GameConfig (for example to _v2).
The save code is careful. If loading someone's coins fails, it won't save over them that visit. It also saves when players leave, every 2 minutes, and when the server shuts down.
Step 12
Let people play
Test with friends first. After publishing, the Creator Dashboard lets you invite a few testers before going public.
Going public means File → Publish, then answering the Content Maturity question. Roblox requires an account at least 2 days old and an age check (face scan or ID). By default a newly released game is shown to age-checked players 16 and over.
Ask a parent or guardian to help with this step. The age-check rules decide who can play, and they change from time to time.
Before releasing: set MinPlayers = 2, make a thumbnail (a screenshot of the button with panicked players is perfect), and play a few rounds on a phone.
Step 13
Cosmetics, shop and high scores (plan)
Coin shop
Today trails unlock automatically at coin totals. Next: a shop where you spend coins. Save “owned” items in the same DataStore, and always check prices on the server. Heads up: the save code currently refuses to save a lower coin total (it protects against old servers), so a shop needs that check changed. Look for the comment in CoinManager.
Game Passes
For a permanent Robux cosmetic (for example a VIP crown), create a Game Pass in the Creator Dashboard after publishing. Check ownership on the server with UserOwnsGamePassAsync.
Developer Products
For things bought again and again, like coin packs. These need a ProcessReceipt function in one server Script, and it must save which purchases it already gave out.
High scores
A lobby leaderboard of “most rounds survived” using an OrderedDataStore, shown on a SurfaceGui board.
Step 14
More ridiculous events (easiest first)
The button should get sillier as rounds go on. Give each new event a higher UnlockRound.
1. Low Gravity
Change workspace.Gravity and put it back in the stop function. Moon Mode already does this.
2. Lights Out
Tween Lighting dark, with flashes. Lighting only, no parts.
3. Slippery Floor
Set tile Material to Ice and raise everyone's WalkSpeed.
4. Big Heads
Scale the players' heads. Harmless, very funny.
5. Meteor Shower
Drop parts from the sky at random spots, with a shadow warning first.
6. Lava Zones
Random tiles turn into glowing lava for a few seconds.
7. Sweeper Beam
A long bar spins around the middle. Jump it!
8. Two at once
From round 10, run two events together (start both, stop both).
When something goes wrong
Troubleshooting
Infinite yield possible on WaitForChild("…")
A script or object with that exact name is missing or misspelled. Check the Explorer tree in Step 4.
GameConfig has N problem(s)
Read the [GameConfig] warnings just above it. Each one names the setting to fix.
Your TheButton model needs a Part named 'Cap'
Rename the red part to exactly Cap.
Nothing happens at all
Is RoundManager a Script (not a LocalScript or ModuleScript) inside ServerScriptService?
No text on screen
HUD must be a LocalScript in StarterPlayer → StarterPlayerScripts.
Saving is off (turn on API access…)
Normal until Step 11. The game still works.
The button falls or gets pushed
Tick Anchored on Pedestal and Cap.
Attempt to index nil with …
The script looked for something that isn't there, usually a name typo. Output gives the script name and line number.