Tutorial · Game systems

How to make a round system in Roblox Studio

Most round-based Roblox games run the same loop: wait for players, count down, load a map, teleport everyone in, run a timer, crown a winner and tidy up. This guide builds that loop as one readable server script, adds a timer every player can see, and handles the cases that break copied scripts: players who die, leave or join halfway through a round.

Updated 30 September 202614 min readIntermediateLuau

In short

Write one server Script in ServerScriptService that loops through a fixed set of states: intermission (wait for enough players, then count down), map load (clone a random map from ServerStorage into Workspace), teleport (move each living character to a spawn part with PivotTo), round (tick a timer until time runs out or one player or team is left) and cleanup (award Wins, respawn survivors, destroy the map).

  • Run the loop on the server only. Publish the status and seconds left as attributes on a Folder in ReplicatedStorage, and let a LocalScript display them.
  • Keep a table of who is still in the round. Remove a player when their Humanoid dies, their character is removed or they leave.
  • Only teleport players whose character is alive when the round starts. Anyone who joins later waits in the lobby for the next one.
On this page
  1. How it works
  2. 1. Lobby and maps
  3. 2. Round script
  4. How each state works
  5. 3. Timer UI
  6. Mid-round players
  7. Extending it
  8. Common errors
  9. With RoCode
  10. Questions
  11. Sources

How a round system works

A round system is a state machine. At any moment the server is in exactly one state, each state has one job, and one condition moves it to the next. Writing it this way keeps the logic in one place, which avoids the classic bug of a timer loop and a round loop both trying to end the same round.

The states in this guide's round loop
StateWhat the server doesMoves on when
WaitingShows "Waiting for players (1/2)"Enough players are in the server
IntermissionCounts down while everyone is in the lobbyThe countdown ends (or drops back to Waiting if players leave)
StartingCopies a random map into Workspace, shows its name, asks clients to load itA short "get ready" countdown ends
InRoundTeleports living players in, ticks the timer, drops players who die or leaveTime runs out, or one player or team is left
ResultsAdds a Win for everyone still alive and shows who wonA few seconds pass
CleanupRespawns survivors in the lobby and destroys the mapStraight away, then the loop starts again

The server owns every state. Clients only display what the server tells them. A countdown that runs in a LocalScript drifts from the real one, is wrong for players who join late, and can be edited by an exploiter. If the split between server and client is new to you, read Script vs LocalScript vs ModuleScript first.

Step 1: Build the lobby, the maps and the Wins stat

  1. Build a lobby in Workspace with a SpawnLocation. Make sure its Neutral property is checked, so every player spawns here, waits here and comes back here after being knocked out.
  2. In ServerStorage, insert a Folder named Maps. Objects in ServerStorage are never sent to players, so unused maps cost them nothing.
  3. Build each map as a Model inside Maps, with its parts anchored. Build it well away from the lobby (a few hundred studs is plenty), because the copy appears exactly where you built it and knocked-out players should not be able to walk back in.
  4. Inside each map, add a Folder named Spawns holding one anchored Part for each player you expect. Set their Transparency to 1 and turn off CanCollide so nobody sees or trips over them.
Explorer after this guide
Workspace
  Lobby Model
    SpawnLocation SpawnLocation
ServerStorage
  Maps Folder new
    Desert Model new
      Spawns Folder new
        Spawn1 Part new
        Spawn2 Part new
    Castle Model new
      Spawns Folder new
ServerScriptService
  Leaderstats Script new
  RoundSystem Script new
StarterGui
  RoundHud ScreenGui new
    RoundHudClient LocalScript new

The round script adds 1 to an IntValue named Wins inside the player's leaderstats folder. If your game has no leaderstats yet, add this short script. If you already have one, for example the saving version from the leaderboard guide, skip it: two scripts creating leaderstats gives each player two folders.

Script in ServerScriptService named Leaderstats
local Players = game:GetService("Players")

local function setupLeaderstats(player: Player)
	local leaderstats = Instance.new("Folder")
	leaderstats.Name = "leaderstats" -- must be exactly this name, all lowercase

	local wins = Instance.new("IntValue")
	wins.Name = "Wins"
	wins.Value = 0
	wins.Parent = leaderstats

	leaderstats.Parent = player
end

Players.PlayerAdded:Connect(setupLeaderstats)

-- In Studio you can join before this script runs, so set up anyone already here
for _, player in Players:GetPlayers() do
	setupLeaderstats(player)
end

These Wins reset when the player leaves. To keep them between sessions, use the data store version in the leaderboard guide.

Step 2: Add the round script

Insert a Script into ServerScriptService, name it RoundSystem and paste the code below. The settings at the top are the only lines you should need to change. Each state is its own function, and the loop at the bottom runs them in order, forever.

Script in ServerScriptService named RoundSystem
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")

-- Settings
local MIN_PLAYERS = 2 -- set to 1 while you test alone
local INTERMISSION_SECONDS = 20
local GET_READY_SECONDS = 5
local ROUND_SECONDS = 120
local RESULTS_SECONDS = 5
local USE_TEAMS = false -- true: the round ends when only one team has players left

local mapsFolder = ServerStorage:WaitForChild("Maps")
local random = Random.new()

-- What every player's screen shows lives in attributes on this folder.
-- Attributes set by the server replicate to all clients, including late joiners.
local roundInfo = Instance.new("Folder")
roundInfo.Name = "RoundInfo"
roundInfo.Parent = ReplicatedStorage

local function setState(state: string, status: string)
	roundInfo:SetAttribute("State", state)
	roundInfo:SetAttribute("Status", status)
	roundInfo:SetAttribute("TimeLeft", 0)
end

local function setTimeLeft(seconds: number)
	roundInfo:SetAttribute("TimeLeft", seconds)
end

-- Who is still in the current round, and the connections watching each of them
local alive: { [Player]: boolean } = {}
local watchers: { [Player]: { RBXScriptConnection } } = {}

local function eliminate(player: Player)
	if not alive[player] then
		return
	end
	alive[player] = nil
	player:SetAttribute("InRound", false)
	local connections = watchers[player]
	if connections then
		for _, connection in connections do
			connection:Disconnect()
		end
		watchers[player] = nil
	end
end

Players.PlayerRemoving:Connect(eliminate)

-- Returns the character and humanoid only if the player is alive right now
local function getLivingCharacter(player: Player): (Model?, Humanoid?)
	local character = player.Character
	if not character or not character.Parent then
		return nil, nil
	end
	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 or not humanoid.RootPart then
		return nil, nil
	end
	return character, humanoid
end

-- Counts the sides still in the round (players, or teams when USE_TEAMS is on)
local function countSides(): (number, number)
	local sides: { [Instance]: boolean } = {}
	local sideCount = 0
	local playerCount = 0
	for player in alive do
		playerCount += 1
		local team = player.Team
		local side: Instance = if USE_TEAMS and team then team else player
		if not sides[side] then
			sides[side] = true
			sideCount += 1
		end
	end
	return sideCount, playerCount
end

-- STATE: Waiting and Intermission. Returns false if players left during the countdown.
local function runIntermission(): boolean
	while #Players:GetPlayers() < MIN_PLAYERS do
		setState("Waiting", `Waiting for players ({#Players:GetPlayers()}/{MIN_PLAYERS})`)
		task.wait(1)
	end
	setState("Intermission", "Intermission")
	for secondsLeft = INTERMISSION_SECONDS, 1, -1 do
		if #Players:GetPlayers() < MIN_PLAYERS then
			return false
		end
		setTimeLeft(secondsLeft)
		task.wait(1)
	end
	return true
end

-- STATE: Starting, part 1. Copy a random map (not the last one, if there is a choice).
local lastMapName = ""

local function loadMap(): Model?
	local allMaps: { Model } = {}
	local freshMaps: { Model } = {}
	for _, child in mapsFolder:GetChildren() do
		if child:IsA("Model") then
			table.insert(allMaps, child)
			if child.Name ~= lastMapName then
				table.insert(freshMaps, child)
			end
		end
	end
	local choices = if #freshMaps > 0 then freshMaps else allMaps
	if #choices == 0 then
		warn("RoundSystem: add at least one map Model to ServerStorage > Maps")
		return nil
	end
	local chosen = choices[random:NextInteger(1, #choices)]
	lastMapName = chosen.Name
	local map = chosen:Clone()
	map.Parent = workspace
	return map
end

local function getSpawnPoints(map: Model): { BasePart }
	local points: { BasePart } = {}
	local folder = map:FindFirstChild("Spawns")
	if folder then
		for _, child in folder:GetChildren() do
			if child:IsA("BasePart") then
				table.insert(points, child)
			end
		end
	end
	-- Shuffle, so players do not land on the same spawn every round
	for i = #points, 2, -1 do
		local j = random:NextInteger(1, i)
		points[i], points[j] = points[j], points[i]
	end
	return points
end

-- How high a standing character's root part sits above the floor.
-- The formula is from Roblox's Humanoid.HipHeight reference (R6 rigs add the leg height).
local function rootHeight(character: Model, humanoid: Humanoid, rootPart: BasePart): number
	local height = rootPart.Size.Y / 2 + humanoid.HipHeight
	if humanoid.RigType == Enum.HumanoidRigType.R6 then
		local leg = character:FindFirstChild("Left Leg")
		if leg and leg:IsA("BasePart") then
			height += leg.Size.Y
		end
	end
	return height
end

-- STATE: Starting, part 2. Get ready, then teleport everyone who is alive.
local function startRound(map: Model): boolean
	local spawnPoints = getSpawnPoints(map)
	if #spawnPoints == 0 then
		warn(`RoundSystem: {map.Name} needs a Spawns folder with at least one part in it`)
		return false
	end

	-- With streaming on, ask each client to load the map before its character arrives
	if workspace.StreamingEnabled then
		local mapPosition = map:GetPivot().Position
		for _, player in Players:GetPlayers() do
			task.spawn(function()
				pcall(function()
					player:RequestStreamAroundAsync(mapPosition, GET_READY_SECONDS)
				end)
			end)
		end
	end

	setState("Starting", `Map: {map.Name}`)
	for secondsLeft = GET_READY_SECONDS, 1, -1 do
		setTimeLeft(secondsLeft)
		task.wait(1)
	end

	-- Only players who are alive right now take part. Everyone else waits for the next round.
	local participants: { Player } = {}
	for _, player in Players:GetPlayers() do
		if getLivingCharacter(player) then
			table.insert(participants, player)
		end
	end
	if #participants < MIN_PLAYERS then
		setState("Waiting", "Not enough players, starting over")
		task.wait(2)
		return false
	end

	for index, player in participants do
		local character, humanoid = getLivingCharacter(player)
		local rootPart = if humanoid then humanoid.RootPart else nil
		if character and humanoid and rootPart then
			local spawnPart = spawnPoints[(index - 1) % #spawnPoints + 1]
			-- A character's pivot is its root part, so lift it to standing height above the part
			local height = spawnPart.Size.Y / 2 + rootHeight(character, humanoid, rootPart)
			character:PivotTo(spawnPart.CFrame + Vector3.new(0, height, 0))
			alive[player] = true
			player:SetAttribute("InRound", true)
			watchers[player] = {
				humanoid.Died:Connect(function()
					eliminate(player)
				end),
				player.CharacterRemoving:Connect(function()
					eliminate(player)
				end),
			}
		end
	end
	return true
end

-- STATE: InRound. Ends when time runs out or only one side is left.
local function runRound()
	setState("InRound", "Round in progress")
	local startingSides = countSides()
	for secondsLeft = ROUND_SECONDS, 1, -1 do
		local _, playersLeft = countSides()
		roundInfo:SetAttribute("Status", `{playersLeft} left`)
		setTimeLeft(secondsLeft)
		task.wait(1)
		local sidesLeft = countSides()
		-- A round that started with one side (a solo test) only ends on time or death
		if sidesLeft == 0 or (startingSides > 1 and sidesLeft == 1) then
			break
		end
	end
end

-- STATE: Results. Everyone still alive wins.
local function awardWin(player: Player)
	local leaderstats = player:FindFirstChild("leaderstats")
	local wins = leaderstats and leaderstats:FindFirstChild("Wins")
	if wins and wins:IsA("IntValue") then
		wins.Value += 1
	end
end

local function finishRound(): { Player }
	local sidesLeft = countSides()
	local winners: { Player } = {}
	for player in alive do
		table.insert(winners, player)
	end

	local message = `{#winners} players survived!`
	local firstTeam = if #winners > 0 then winners[1].Team else nil
	if #winners == 0 then
		message = "Nobody survived"
	elseif USE_TEAMS and sidesLeft == 1 and firstTeam then
		message = `{firstTeam.Name} wins!`
	elseif #winners == 1 then
		message = `{winners[1].DisplayName} wins!`
	end

	for _, player in winners do
		awardWin(player)
		eliminate(player) -- stop watching them; they stay on the map until cleanup
	end
	setState("Results", message)
	task.wait(RESULTS_SECONDS)
	return winners
end

-- STATE: Cleanup. Send survivors back to the lobby, then remove the map.
local function cleanup(map: Model, survivors: { Player })
	for _, player in survivors do
		if player.Parent then -- still in the server
			task.spawn(function()
				local ok, err = pcall(function()
					player:LoadCharacterAsync()
				end)
				if not ok then
					warn(`RoundSystem: could not respawn {player.Name}: {err}`)
				end
			end)
		end
	end
	map:Destroy()
end

-- The loop: one pass is one round
while true do
	if runIntermission() then
		local map = loadMap()
		if map then
			local survivors: { Player } = {}
			if startRound(map) then
				runRound()
				survivors = finishRound()
			end
			cleanup(map, survivors)
		else
			task.wait(5) -- no maps yet: try again shortly
		end
	end
end

Everything that matters happens here, on the server: who is in the round, the timer, the winner and the Wins. The only thing clients get is three attributes on ReplicatedStorage.RoundInfo (State, Status and TimeLeft) and an InRound attribute on each player.

How each state works

Waiting and intermission

runIntermission first holds the loop until #Players:GetPlayers() reaches MIN_PLAYERS. Then it counts down one second at a time with task.wait(1), checking the player count on every tick. If someone leaves and the server drops below the minimum, it returns false and the main loop starts over at Waiting, so a round never begins with one person in it.

Picking and loading a map

loadMap collects every Model in ServerStorage.Maps, drops the one used last round if there is another to choose from, and picks one with Random:NextInteger. It then clones the map into Workspace. The original stays untouched in ServerStorage, so every round starts from a fresh copy: anything players broke or moved last round is gone.

New places have instance streaming turned on, which means a client only has the parts near its character. Before teleporting, the script calls Player:RequestStreamAroundAsync for each player, which asks the engine to send that client the area around the map during the "get ready" countdown. Without it, players can arrive before the floor has loaded and pause for a moment.

Teleporting players with PivotTo

After the countdown, the script builds the list of participants from players whose character exists, is still in the world and has a Humanoid with health above 0 and a root part. That check happens after the countdown, so someone who died or left during it is not included.

Each participant is moved with character:PivotTo(cframe). Roblox's reference calls PivotTo the primary way to move models from a script, and it moves every part of the character together. Setting HumanoidRootPart.Position is not the same: Roblox adjusts the joints so that one part moves on its own, which can leave the rest of the body behind. A character's pivot is its root part, so the target is the spawn part's CFrame raised by half the part's height plus the height the root part stands above the floor. rootHeight works that out with the formula from Roblox's Humanoid.HipHeight reference: half the root part's height plus HipHeight, plus the leg length on R6 rigs. That keeps scaled and custom avatars from landing inside the part or dropping onto it. Because the CFrame includes rotation, players face the way the spawn part faces, so rotate the parts to aim everyone at the centre of the map.

If there are more players than spawn parts, the script loops back to the first part and players land on top of each other, so add at least as many spawns as your maximum player count.

The round timer and end conditions

runRound ticks once a second. On each tick it updates the status (how many players are left) and the seconds remaining, then counts the sides still alive. The round ends in one of three ways:

  • Time runs out. The for loop reaches 1 and finishes.
  • One side is left. A side is a player, or a team when USE_TEAMS is on. The check only applies when the round started with two or more sides, so a solo test does not end the moment it begins.
  • Nobody is left. Everyone died or left, so the round ends with no winner.

Checking once a second means a round can run up to a second after the last knockout, which is rarely noticeable on a round timer.

Awarding Wins and showing the result

The rule in this script is simple: everyone still alive when the round ends wins. That covers last player standing, last team standing and "survive until the timer runs out" in one line of logic. finishRound adds 1 to each winner's Wins, stops watching them, and shows a message such as "Alex wins!" for RESULTS_SECONDS. If you want a time-out to have no winner, skip awardWin and change the message when sidesLeft is above 1. Keep returning the list either way, because cleanup uses it to bring survivors back to the lobby.

Cleanup

Survivors are still standing on the map, so cleanup respawns them in the lobby with Player:LoadCharacterAsync(), which replaces the older LoadCharacter, and then destroys the map copy. Each respawn runs in its own task.spawn inside a pcall, because the call waits for the character to load and can fail if the player leaves at that moment. Knocked-out players do not need anything: they already respawned in the lobby on their own.

Step 3: Show the timer and status to every player

The server publishes three attributes on ReplicatedStorage.RoundInfo: State (a fixed word such as InRound, for scripts), Status (text for people) and TimeLeft (seconds). Attributes that a server script sets on a replicated object reach every client automatically, and a player who joins halfway through gets the current values straight away. That is why this is a better fit than a RemoteEvent for a timer: a RemoteEvent fired before someone joined never reaches them.

  1. In the Explorer, insert a ScreenGui into StarterGui and name it RoundHud. Leave ResetOnSpawn on (the default).
  2. Insert a LocalScript into RoundHud, name it RoundHudClient and paste the code below. It builds the label itself, so there is nothing else to draw.
  3. Play. The label at the top of the screen shows the status and a m:ss countdown.
LocalScript in the RoundHud ScreenGui in StarterGui named RoundHudClient
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local roundInfo = ReplicatedStorage:WaitForChild("RoundInfo")
local screenGui = script.Parent :: ScreenGui

-- The label. If you designed your own TextLabel in RoundHud, delete this block and use:
-- local label = screenGui:WaitForChild("StatusLabel") :: TextLabel
local label = Instance.new("TextLabel")
label.Name = "StatusLabel"
label.AnchorPoint = Vector2.new(0.5, 0)
label.Position = UDim2.new(0.5, 0, 0, 12)
label.Size = UDim2.fromOffset(320, 44)
label.BackgroundColor3 = Color3.fromRGB(20, 14, 32)
label.BackgroundTransparency = 0.2
label.TextColor3 = Color3.new(1, 1, 1)
label.Font = Enum.Font.BuilderSansBold
label.TextSize = 22
label.TextWrapped = true -- longer messages use a second line
label.Text = ""
label.Parent = screenGui

local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 10)
corner.Parent = label

local function formatTime(seconds: number): string
	return string.format("%d:%02d", seconds // 60, seconds % 60)
end

local function update()
	local status = roundInfo:GetAttribute("Status")
	local timeLeft = roundInfo:GetAttribute("TimeLeft")
	local text = if type(status) == "string" then status else ""
	if type(timeLeft) == "number" and timeLeft > 0 then
		text ..= "   " .. formatTime(timeLeft)
	end
	label.Text = text
end

roundInfo:GetAttributeChangedSignal("Status"):Connect(update)
roundInfo:GetAttributeChangedSignal("TimeLeft"):Connect(update)
update()

Because ResetOnSpawn is on, Roblox gives the player a fresh copy of RoundHud each time their character spawns, including the respawn at cleanup. The script then reads the current attributes again, so the label is never stale.

The client only reads. Nothing a player changes on their own screen affects the real timer, because attribute changes made by a LocalScript stay on that one client. For a countdown that shows tenths of a second, the server can store an end time from workspace:GetServerTimeNow() in an attribute instead, and each client subtracts its own workspace:GetServerTimeNow() every frame.

Players who die, leave or join mid-round

These cases are easy to miss when a round script is first written. When one is not handled, you usually see an error in the Output or a round that never ends. Here is what the script above does with each one.

Edge cases and how the round script handles them
SituationWhat happensWhere in the script
A player dies in the round (killed, reset, fell off)They are removed from the round and respawn in the lobby after Players.RespawnTime (5 seconds by default).humanoid.Died calls eliminate
A character is removed without dyingSame as a death: they are out.player.CharacterRemoving calls eliminate
A player leaves the serverThey are removed, their connections are disconnected, and the next tick recounts the sides.Players.PlayerRemoving calls eliminate
A player joins mid-roundThey spawn in the lobby, see the live status and timer, and join the next round.Participants are only chosen in startRound
A player was dead when the round startedThey are not teleported and wait in the lobby.getLivingCharacter
Everyone leaves or diesThe round ends with "Nobody survived" and the loop starts again: Intermission if enough players are still in the server, Waiting if not.sidesLeft == 0 in runRound

eliminate is written so it is safe to call more than once for the same player. A player who dies and then respawns fires both Died and CharacterRemoving, and one who leaves may fire all three events; only the first call does anything.

Extending it: teams, map votes and round events

Last team standing

Set USE_TEAMS = true, then add Team objects under the Teams service in the Explorer, each with its own name and TeamColor. By default Roblox puts each joining player on the auto-assignable team with the fewest members. It does not rebalance when players leave, so uneven teams can happen in long sessions. Keep the lobby SpawnLocation Neutral so every team spawns there. The round then ends when only one team has living players, and the result reads "Red wins!".

For a one-versus-many format (one hunter, everyone else survives), pick one participant at random in startRound and set their Team to a hunter team before teleporting. Uncheck AutoAssignable on the hunter team so new players are never put on it, and move the hunter back to the survivors' team in cleanup. The same end conditions then work: the hunter wins if every survivor is out, and the survivors win if they last until the timer runs out, as long as you skip the hunter's awardWin on a time-out.

Letting players vote for a map

A vote needs the client to talk to the server, which means a RemoteEvent. During intermission the server publishes two or three map names as attributes, each client fires the RemoteEvent with the name it clicked, and the server counts the votes before loadMap runs. The server must check every vote: that the argument is a string, that it is one of the current options, that the state is Intermission, and that each player counts once (store votes in a table keyed by Player, so a second vote replaces the first, and clear it on PlayerRemoving). The RemoteEvents guide covers that validation pattern in full.

Giving players a tool when the round starts

Other scripts can react to the round without touching RoundSystem. The round script sets an InRound attribute on each player when they are teleported in and clears it when they are knocked out. This script listens for that and hands each player a copy of a Tool named RoundTool kept in ServerStorage.

Script in ServerScriptService named GiveRoundTool
local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage")

local toolTemplate = ServerStorage:WaitForChild("RoundTool") :: Tool

local function watchPlayer(player: Player)
	player:GetAttributeChangedSignal("InRound"):Connect(function()
		if player:GetAttribute("InRound") ~= true then
			return
		end
		local backpack = player:FindFirstChildOfClass("Backpack")
		if backpack then
			toolTemplate:Clone().Parent = backpack
		end
	end)
end

Players.PlayerAdded:Connect(watchPlayer)
for _, player in Players:GetPlayers() do
	watchPlayer(player)
end

There is no code to take the tool away. When a character dies, Roblox replaces the Backpack with a fresh one filled from StarterPack, and LoadCharacterAsync at cleanup clears it for survivors, so the tool is gone when the round is over.

NPCs and other round-only objects

Anything that should exist only during a round can live inside the map model: enemies, pickups, moving platforms. Scripts inside a model in ServerStorage do not run; once the copy is in Workspace they start, and map:Destroy() at cleanup removes them along with everything else. To add enemies that chase players, build one with the NPC pathfinding guide and drop it into a map.

Turning it into a module

You do not need a ModuleScript to start. A single Script is easier to read while you build the game. Split it when other scripts need to call round functions, or when you want the settings in one shared ModuleScript that both the server and your UI can require. Until then, the State attribute on RoundInfo and the InRound attribute on each player already work as the round system's public interface: any script can listen to them.

Common errors and fixes

Open the Output window (from Studio's Window menu or the Script tab) before you press Play. Most round system problems print a warning or an error there, with the script name and line number.

Stuck on "Waiting for players (1/2)"

You are testing alone and MIN_PLAYERS is 2. Set it to 1 for solo tests, or test with Server & Clients and 2 clients.

Infinite yield possible on 'ServerStorage:WaitForChild("Maps")'

The folder is missing, misspelt or in the wrong service. It must be a Folder named exactly Maps directly inside ServerStorage. The same warning for RoundInfo in a LocalScript means the RoundSystem script never got as far as creating it: it is disabled, it errored, or it is still waiting for the Maps folder.

Players fall through the map after the teleport

The map's parts are not anchored, so the copy falls apart as soon as it is in Workspace. Select the map, anchor its parts, and check the spawn parts are above the floor, not inside it.

Knocked-out players respawn inside the arena

There is a SpawnLocation inside a map. Replace it with a plain Part in the map's Spawns folder.

The round never ends after everyone dies

Check that your damage actually kills the Humanoid (health reaches 0) rather than moving players or anchoring them. If you disabled the Dead humanoid state, Died never fires and the round has to run to the timer.

The round loop stops for good after one error

The loop runs in one thread, so an error inside any state ends it. Read the error in the Output, fix the line, and test again. A frequent cause is editing the script to use player.Character.Humanoid without checking that the character exists.

Build it with RoCode

If you would rather not assemble this by hand, RoCode can build it in the place you have open. It is an AI agent that works through a Roblox Studio plugin, so the scripts and parts appear in your Explorer instead of in a chat window for you to paste.

You type

Add a round system: a 20 second intermission that needs 2 players, a random map from ServerStorage.Maps, teleport everyone to the map's spawn parts, 2 minute rounds where the last player alive wins, add a Win in leaderstats, and show the status and timer at the top of the screen.

RoCode does
  • Can look through your place first with List Children, Search Game and Search Scripts, so it uses your existing Maps folder and leaderstats script rather than adding a second set
  • Creates the round Script in ServerScriptService and the timer LocalScript with Create Script, and can place an arena from its ready-made structures with Place Prefab if you have no map yet
  • Compile-checks each script inside Studio with Check Script before it finishes. That catches syntax errors, not round logic
  • After you play-test, Check Output reads the Server and Client lines from your last playtest so it can fix a runtime error

RoCode does not start playtests or control a character, so you run the rounds yourself with Server & Clients. Each batch of changes is an undo point in Studio. How RoCode connects to Studio.

Questions

How do I make a round system with teams?

Add Team objects under the Teams service and set USE_TEAMS = true in the round script. Roblox assigns joining players to the auto-assignable team with the fewest members, and the round ends when only one team has living players.

How do I show a countdown to every player?

Set the seconds left as an attribute on an object in ReplicatedStorage from the server, and listen with GetAttributeChangedSignal in a LocalScript that updates a TextLabel, as in Step 3. Players who join late get the current value immediately.

Should I use wait() or task.wait() in a round loop?

Use task.wait(). Roblox marks the global wait() as deprecated and superseded by task.wait().

How do I teleport players to a different place for each round?

That is a different tool. This guide moves characters inside one server with PivotTo. Sending players to another place in your experience uses TeleportService, which moves them to a different server.

Why do my players freeze for a moment after the teleport?

With instance streaming on, the client has not received the map yet. The script's RequestStreamAroundAsync call during the get-ready countdown asks the engine to send it early; a longer GET_READY_SECONDS gives slow connections more time.

Sources and further reading

How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 30 September 2026). A type check catches misspelt APIs and wrong types, not game logic, so play-test in Studio before you publish.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.