How to Build a Roblox Round System: Intermission, Timer, Winners

Last updated:

Quick answer: A Roblox round system is a server-side loop that cycles through three states — Intermission, InRound, and Results — waiting for enough players, teleporting them to an arena with PivotTo, broadcasting one authoritative countdown, detecting the winner, and resetting everything before the next cycle.

A Roblox round system is a server-side loop that cycles a match through three states: an Intermission where players gather and the game waits for enough people, the InRound phase where players are teleported to the arena and compete, and a Results phase that announces the winner and resets the map. The server owns this loop, publishes the current state and time left so every client sees the same countdown, and cleans up fully before starting the next round.

What states does a round system need?

Model the game as a small state machine with exactly three phases, and let one script on the server move between them. Everything else — GUIs, teleporting, rewards — hangs off the current state.

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local MIN_PLAYERS = 2
local INTERMISSION_TIME = 15
local ROUND_TIME = 120
local RESULTS_TIME = 8

local function setState(stateName)
	ReplicatedStorage:SetAttribute("RoundState", stateName)
end

while true do
	setState("Intermission")
	task.wait(INTERMISSION_TIME)

	setState("InRound")
	task.wait(ROUND_TIME)

	setState("Results")
	task.wait(RESULTS_TIME)
end

Attributes on ReplicatedStorage are a lightweight way to publish state: clients read them with GetAttribute and react with GetAttributeChangedSignal, and only the server can write them, so no client can fake the phase. For the GUI you will want a countdown too, which the timer section below adds.

Keep the state values in one table or a ModuleScript config instead of scattering string literals; a typo like "Inround" in a client comparison silently breaks a whole GUI.

How do I wait for enough players without freezing the server?

Do not block startup waiting for players. Run the intermission loop as part of the cycle and simply stay in the Intermission state until the lobby has enough people:

local function waitForEnoughPlayers()
	while #Players:GetPlayers() < MIN_PLAYERS do
		task.wait(1)
	end
end

-- inside the main loop, replacing task.wait(INTERMISSION_TIME) when the lobby is empty:
while #Players:GetPlayers() < MIN_PLAYERS do
	ReplicatedStorage:SetAttribute("RoundState", "WaitingForPlayers")
	task.wait(1)
end

A task.wait(1) poll is the right tool here — the loop needs to re-check player count every second anyway, and unlike an event-driven setup it also handles players leaving right before the round starts. Never use the legacy wait, spawn, or delay functions for this; they are deprecated in favor of task.wait and task.spawn and their scheduling behavior is unreliable.

If you want the intermission to run only when the lobby is actually filling up, tick the countdown only while at least one player is present, and reset the countdown when the lobby drops below MIN_PLAYERS. That avoids announcing "round starting" to an empty server.

How do I teleport players to the arena safely?

Never set CFrame on individual body parts. Use character:PivotTo to move the whole character in one call, and verify each character actually exists and is alive — players disconnecting mid-teleport, or respawning right as the round begins, are normal occurrences:

local function teleportToArena(spawnPoints)
	local players = Players:GetPlayers()
	for index, player in players do
		local character = player.Character
		local root = character and character:FindFirstChild("HumanoidRootPart")
		local humanoid = character and character:FindFirstChildOfClass("Humanoid")
		if root and humanoid and humanoid.Health > 0 then
			local spawnPoint = spawnPoints[(index - 1) % #spawnPoints + 1]
			character:PivotTo(spawnPoint.CFrame + Vector3.new(0, 3, 0))
		end
	end
end

The + Vector3.new(0, 3, 0) lift keeps characters from spawning clipped inside the spawn pad and getting stuck. Gather the spawn points once per round from the arena model (for example arena:GetChildren() filtered to parts named "SpawnPoint") and pass them in, so teleporting does not depend on global workspace layout.

Handle respawns too: if a player dies and respawns during a round, Roblox places them at a SpawnLocation. Either disable respawning for the round (set the players' Humanoid behavior via Players.CharacterAutoLoads = false and load characters yourself) or teleport every new character to the lobby on respawn until the next round starts.

How do I build a countdown timer everyone sees?

The timer must be server-authoritative. If each client counts down on its own, players drift apart and an exploiter can freeze their own display. Have the server update one shared value every second, and let clients merely display it:

local function countdown(seconds)
	for remaining = seconds, 0, -1 do
		ReplicatedStorage:SetAttribute("TimeLeft", remaining)
		task.wait(1)
	end
	ReplicatedStorage:SetAttribute("TimeLeft", 0)
end

-- usage inside the main loop:
countdown(INTERMISSION_TIME) -- shown in the lobby GUI
-- ... teleport players, then:
countdown(ROUND_TIME) -- shown in the round HUD

On the client, a LocalScript renders whatever the server published:

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local function refreshTimer()
	local timeLeft = ReplicatedStorage:GetAttribute("TimeLeft") or 0
	local stateName = ReplicatedStorage:GetAttribute("RoundState") or "Intermission"
	timerLabel.Text = stateName .. ": " .. tostring(timeLeft) .. "s"
end

ReplicatedStorage:GetAttributeChangedSignal("TimeLeft"):Connect(refreshTimer)
ReplicatedStorage:GetAttributeChangedSignal("RoundState"):Connect(refreshTimer)
refreshTimer()

Because task.wait(1) can drift slightly over a long round, deriving the display from one server counter (rather than having every client decrement its own copy) keeps every screen honest to the second.

How do I detect the winner and give rewards?

For a last-player-standing round, track who is still alive and end early when one remains. Connect to each Humanoid.Died at the start of the round:

local function runRound()
	local alive = {}

	for _, player in Players:GetPlayers() do
		alive[player] = true
		local character = player.Character
		local humanoid = character and character:FindFirstChildOfClass("Humanoid")
		if humanoid then
			humanoid.Died:Connect(function()
				alive[player] = nil
			end)
		end
	end

	local elapsed = 0
	while elapsed < ROUND_TIME do
		local survivors = 0
		for _ in alive do
			survivors += 1
		end
		if survivors <= 1 then
			break
		end
		task.wait(1)
		elapsed += 1
	end

	for player in alive do
		return player -- winner (nil on timeout or mutual elimination)
	end
	return nil
end

Award the winner on the server — coins through your leaderstats values or a persistent win counter saved with the DataStore guide pattern. Never hand the reward through a RemoteEvent the client requests; the server decides who won and what they get.

For other win conditions the same skeleton applies: collect scores during the round, then pick the highest score at the end instead of tracking eliminations.

How do I reset everything between rounds?

Rounds corrupt each other through leftovers: dead characters, arena clones, stale alive-tables, and GUIs still showing the last winner. Reset deliberately in the Results phase:

local function cleanupRound(arena, connections)
	for _, connection in connections do
		connection:Disconnect()
	end
	if arena then
		arena:Destroy()
	end
	for _, player in Players:GetPlayers() do
		local character = player.Character
		if character then
			character:PivotTo(lobbySpawn.CFrame + Vector3.new(0, 3, 0))
		end
	end
end

Destroying the arena clone matters even if the next round reuses the same map: leftover dropped tools, welded debris, and lingering Touched connections all live inside that model.

How do I support multiple maps?

Put your map models in a folder (for example ReplicatedStorage.Maps), pick one at random per round, clone it into workspace, and teleport players to its spawn points. A ModuleScript keeps the tuning in one place and is the natural next step once the single-map version works — see the ModuleScript guide for how to require it from the round script.

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local mapsFolder = ReplicatedStorage:WaitForChild("Maps")

local function startArena()
	local templates = mapsFolder:GetChildren()
	if #templates == 0 then
		return nil
	end
	local choice = templates[math.random(1, #templates)]
	local arena = choice:Clone()
	arena.Parent = workspace
	return arena
end

Random selection with math.random plus a full cleanup pass means players never see the same map twice in a row by accident of stale state, and adding a new map is just dropping a model into the folder.

Common mistakes that break round systems

Learn Luau by doing

A round system is really one loop, three states, and disciplined cleanup — the kind of structure that only becomes natural by writing it. On Luablox you can practice the language pieces on /theory, drill the loops and tables this design leans on at /problems, and follow the Luablox lessons from first script to full gameplay systems.

Frequently asked questions

How do I make a countdown timer everyone sees?

Store the time left on the server — for example an attribute on ReplicatedStorage updated once per second by the round loop — and have LocalScripts display it via GetAttributeChangedSignal. Never let each client count on its own, because the displays drift apart and exploiters can freeze their own timer.

How do I teleport all players to the arena at round start?

Iterate over Players:GetPlayers(), verify each player has a character with a HumanoidRootPart and a living Humanoid, then call character:PivotTo on the chosen spawn point CFrame with a small upward offset. PivotTo moves the whole character at once and is the modern replacement for setting body-part CFrames.

How do I stop players who join mid-round from interfering?

Only include players who were present when the round started: snapshot the player list at teleport time and leave everyone else in the lobby (or in spectator mode) until the next Intermission. Also handle PlayerAdded so late joiners get teleported to the lobby spawn instead of the arena.

How many players do I need before a round starts?

That is your MIN_PLAYERS setting — two for most games. Stay in the Intermission state and re-check #Players:GetPlayers() about once per second instead of blocking, so the server keeps responding while it waits for the lobby to fill.

How do I end the round early when only one player is left?

Track alive players in a table and clear each entry from a Humanoid.Died connection. Poll the table every second (or check on each death) and break the round loop as soon as one or zero survivors remain, then run your normal Results and cleanup phases.

Learn core Roblox scripting and Luau programming concepts, including: roblox round system tutorial, roblox intermission timer, roblox teleport players to arena, last man standing roblox script, roblox game loop script.

Related pages