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.

DO NOT PRESS(seriously)

You can press this one. Nothing bad happens here.

Play the web version now →

Level 1 · Beginner

Make it playable

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

  1. Go to create.roblox.com, download Roblox Studio for Windows (Windows 10 or 11), install it and sign in with your Roblox account.
  2. 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”.)
  3. 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 tabsHome, Model, Avatar, UI, Script, Plugins. Almost everything in Level 1 is on Home: Part, Move/Scale/Rotate, Color, Material, Anchor, Group.
Play controlsIn 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.
ExplorerA list of every object in your game, in folders. You will add scripts here.
PropertiesThe settings of whatever you selected: size, colour, position…
OutputMessages 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

  1. Home → Part adds a block in front of the camera.
  2. 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.
  3. Change Color and Material on the Home tab.
  4. Click Anchor. Anchored parts never fall or get pushed. Anchor everything you build.
  5. 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)

NamePedestal
ShapeCylinder
Size3, 14, 14
Orientation0, 0, 90
Position0, 51.5, 0
Color245, 200, 40
MaterialSmoothPlastic
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)

NameCap (exactly. The script looks for this name.)
Shape / OrientationCylinder · 0, 0, 90
Size3, 8, 8
Position0, 54.5, 0
Color235, 40, 40
Anchored✓ ticked

c. Group them into one Model

  1. In Explorer, click Pedestal, then hold Ctrl and click Cap.
  2. Press Ctrl+G (or Home → Group). Rename the new Model to TheButton.
  3. With TheButton selected, find PrimaryPart in Properties, click it, then click Pedestal in Explorer.
  4. 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

  1. 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.
  2. Click + on Sign → SurfaceGui. Set Face = Back (the side that faces into the arena), SizingMode = PixelsPerStud, LightInfluence = 0.
  3. 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.
  4. 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:

ScriptRuns on the server, the one computer that runs the game for everyone. Rules, timers and coins live here. Put it in ServerScriptService.
LocalScriptRuns on each player's device. Only for what that player sees (the on-screen text). Put it in StarterPlayer → StarterPlayerScripts.
ModuleScriptA box of code that other scripts require. It does nothing on its own.

Where everything goes (the finished Explorer)

How to add each script

  1. Hover the service in Explorer (for example ReplicatedStorage), click +, and choose the script type from the table above.
  2. Rename it to the exact name shown (double-click the name).
  3. 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 · ReplicatedStorage · 138 lines
-- 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 · ReplicatedStorage · 163 lines
-- 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 · ServerScriptService · 329 lines
-- 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 · ServerScriptService · 356 lines
-- 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 · ServerScriptService · 30 lines
-- 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 · StarterPlayer → StarterPlayerScripts · 153 lines
-- 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 · Events · 47 lines
-- 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 arena1 coin
Survived the event+10 coins
Pressed it and survived+5 “brave” bonus
Harder difficulty× 1 (Chill), × 1.5 (Normal), × 2.5 (Chaos)
TrailsBronze at 50 coins, Silver 150, Gold 400, Rainbow 1000. They switch on automatically.
CoinManager · ModuleScript · ServerScriptService · 168 lines
-- 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 · ServerScriptService · 110 lines
-- 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

  1. Choose Server & Clients in the dropdown and set the number of players to 2 or 3.
  2. Press Play. Studio opens one server window and one window per player.
  3. In one player window, press the button. The other windows should say “Player1 PRESSED THE BUTTON!”.
  4. 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:

SettingChillNormalChaos
Event length (s)202530
Coin multiplierx1x1.5x2.5
Flood: dry steps (of 7)432
Flood: floor-campers drown at10.0s6.1s3.8s
Floor: tiles gone by the end30 / 10080 / 10094 / 100
MoonFloor: tiles gone (same floor settings)30 / 10080 / 10094 / 100
MoonFloor: seconds in the air per jump1.1s (normal 0.5s)1.1s (normal 0.5s)1.1s (normal 0.5s)
MoonFloor: flight distance at walk speed17 studs17 studs17 studs
Ball: speed vs walk speed1.4x (1 balls)1.9x (1 balls)2.5x (2 balls)
Ball: seconds to cross arena3.0s2.0s1.4s
Coins: survive / brave survive11 / 1617 / 2428 / 40
Rounds to Bronze / Gold (all survived)5 / 373 / 242 / 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.

  1. In Events, add a ModuleScript called MoonFloor and paste the code below.
  2. 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 },
  3. 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)

  1. 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.)
  2. 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.

  1. 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.
  2. Open a sound's page and copy its asset ID: the number in the page address (for example .../asset/1234567890).
  3. 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.
  4. 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.

  1. File → Publish (publish to Roblox). Add a name, description and genre. New games are private: only you can play until you release it.
  2. File → Experience Settings → Security → turn on Enable Studio Access to API Services → Save.
  3. 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 GravityChange workspace.Gravity and put it back in the stop function. Moon Mode already does this.
2. Lights OutTween Lighting dark, with flashes. Lighting only, no parts.
3. Slippery FloorSet tile Material to Ice and raise everyone's WalkSpeed.
4. Big HeadsScale the players' heads. Harmless, very funny.
5. Meteor ShowerDrop parts from the sky at random spots, with a shadow warning first.
6. Lava ZonesRandom tiles turn into glowing lava for a few seconds.
7. Sweeper BeamA long bar spins around the middle. Jump it!
8. Two at onceFrom 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 allIs RoundManager a Script (not a LocalScript or ModuleScript) inside ServerScriptService?
No text on screenHUD 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 pushedTick 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.