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
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.
| State | What the server does | Moves on when |
|---|---|---|
| Waiting | Shows "Waiting for players (1/2)" | Enough players are in the server |
| Intermission | Counts down while everyone is in the lobby | The countdown ends (or drops back to Waiting if players leave) |
| Starting | Copies a random map into Workspace, shows its name, asks clients to load it | A short "get ready" countdown ends |
| InRound | Teleports living players in, ticks the timer, drops players who die or leave | Time runs out, or one player or team is left |
| Results | Adds a Win for everyone still alive and shows who won | A few seconds pass |
| Cleanup | Respawns survivors in the lobby and destroys the map | Straight 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
- 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. - In ServerStorage, insert a
FoldernamedMaps. Objects in ServerStorage are never sent to players, so unused maps cost them nothing. - Build each map as a
ModelinsideMaps, 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. - Inside each map, add a
FoldernamedSpawnsholding one anchoredPartfor each player you expect. Set their Transparency to 1 and turn off CanCollide so nobody sees or trips over them.
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.
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.
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
forloop reaches 1 and finishes. - One side is left. A side is a player, or a team when
USE_TEAMSis 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.
- In the Explorer, insert a ScreenGui into StarterGui and name it
RoundHud. Leave ResetOnSpawn on (the default). - Insert a LocalScript into
RoundHud, name itRoundHudClientand paste the code below. It builds the label itself, so there is nothing else to draw. - Play. The label at the top of the screen shows the status and a
m:sscountdown.
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.
| Situation | What happens | Where 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 dying | Same as a death: they are out. | player.CharacterRemoving calls eliminate |
| A player leaves the server | They are removed, their connections are disconnected, and the next tick recounts the sides. | Players.PlayerRemoving calls eliminate |
| A player joins mid-round | They 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 started | They are not teleported and wait in the lobby. | getLivingCharacter |
| Everyone leaves or dies | The 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.
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.
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.
- 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
- Roblox Creator Docs: Add rounds. Roblox's own team-based round loop in the laser tag sample
- Roblox Creator Docs: PVInstance:PivotTo. moving models and characters from a script
- Roblox Creator Docs: Player. LoadCharacterAsync, CharacterRemoving and RequestStreamAroundAsync
- Roblox Creator Docs: Properties and attributes. setting attributes and detecting changes
- Roblox Creator Docs: Implement teams. adding teams and auto-assignment
- Roblox Creator Docs: Studio testing modes. the Server & Clients multi-client test
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.