Verification: Static review complete; Studio/device tests unverifiedLast verified: 2026-10-03Prerequisites: UI and client scripts, Modules and configuration

12 Co-op rounds

Before you start and what you will build

Create a new Baseplate place for this project and save it separately from the obby. Do not copy the old Collectibles, boost, checkpoint, or HUD scripts into it. This separation is intentional: here a coin belongs to the team and can be claimed only once globally per round, unlike the obby's per-player collection rule.

The game creates eight gold spheres around a small arena. Everyone works toward one total before a 45-second timer expires. The loop is Waiting → Intermission → Playing → Results → Waiting. The server owns the phase, clock, coin claims, and total. Clients display current replicated state. The first test supports one player; after that, use two clients and then require two players to start if desired.

This is shared-goal co-op, not a puzzle that requires two simultaneous actions. That distinction makes the game easy to verify alone before introducing multiplayer testing.

Exact setup in the new place

Delete only the template SpawnLocation in this new place. Keep the template Baseplate. Add these two source objects:

ServerScriptService
  RoundServer                Script
StarterPlayer
  StarterPlayerScripts
    RoundHud                 LocalScript

Do not manually create the generated objects below. During Play, the server creates:

Workspace
  CoopArena                  Folder
    ArenaFloor               anchored Part
    CoopSpawn                neutral SpawnLocation
    RoundCoins               Folder
      TeamCoin01 ... 08      Parts during Playing only
ReplicatedStorage
  RoundState                 Folder
    Attributes: Status, Collected, Target, TimeLeft

The client creates Players > YourPlayer > PlayerGui > RoundHud > RoundLabel. If you see the obby's CoinHud or BoostGui, you are using the wrong project or copied scripts that do not belong here.

Complete server implementation

Paste this whole file into RoundServer. The generated floor sits above the template Baseplate, so the playable arena has a predictable height. Coins appear at a radius of 24 studs and can be reached by normal walking.

Download 12-round-server.server.luau

-- NEW Baseplate project. Script: ServerScriptService > RoundServer
-- Remove the template SpawnLocation first. Keep the template Baseplate.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local MIN_PLAYERS = 1 -- Use 2 after verifying solo; minimum to start a round.
local TARGET = 8
local ROUND_SECONDS = 45
local INTERMISSION_SECONDS = 5
local RESULTS_SECONDS = 6

assert(not workspace:FindFirstChild("CoopArena"), "Run only one RoundServer")
assert(not ReplicatedStorage:FindFirstChild("RoundState"), "Remove duplicate RoundServer")
local arena = Instance.new("Folder")
arena.Name = "CoopArena"
arena.Parent = workspace
local floor = Instance.new("Part")
floor.Name = "ArenaFloor"
floor.Size = Vector3.new(100, 1, 100)
floor.Position = Vector3.new(0, 0.5, 0)
floor.Anchored = true
floor.Color = Color3.fromRGB(65, 85, 100)
floor.Parent = arena
local spawn = Instance.new("SpawnLocation")
spawn.Name = "CoopSpawn"
spawn.Size = Vector3.new(8, 1, 8)
spawn.Position = Vector3.new(0, 1.5, 0)
spawn.Anchored = true
spawn.Neutral = true
spawn.Duration = 0
spawn.Parent = arena
local coinsFolder = Instance.new("Folder")
coinsFolder.Name = "RoundCoins"
coinsFolder.Parent = arena

local state = Instance.new("Folder")
state.Name = "RoundState"
state:SetAttribute("Status", "Waiting for players")
state:SetAttribute("Collected", 0)
state:SetAttribute("Target", TARGET)
state:SetAttribute("TimeLeft", 0)
state.Parent = ReplicatedStorage
local phase = "Waiting"
local roundId = 0
local collected = 0
local activeDeadline = 0

local function playerCount()
    return #Players:GetPlayers()
end
local function setStatus(text)
    state:SetAttribute("Status", text)
end
local function makeCoins(thisRound)
    for index = 1, TARGET do
        local angle = (index - 1) * math.pi * 2 / TARGET
        local coin = Instance.new("Part")
        coin.Name = string.format("TeamCoin%02d", index)
        coin.Shape = Enum.PartType.Ball
        coin.Size = Vector3.new(3, 3, 3)
        coin.Position = Vector3.new(math.cos(angle) * 24, 3, math.sin(angle) * 24)
        coin.Anchored = true
        coin.CanCollide = false
        coin.CanTouch = true
        coin.Color = Color3.fromRGB(255, 215, 60)
        local claimed = false
        coin.Touched:Connect(function(hit)
            if phase ~= "Playing" or roundId ~= thisRound or claimed then return end
            if coin.Parent ~= coinsFolder then return end
            local character = hit:FindFirstAncestorOfClass("Model")
            local player = character and Players:GetPlayerFromCharacter(character)
            if not player then return end
            local humanoid = character:FindFirstChildOfClass("Humanoid")
            local root = character:FindFirstChild("HumanoidRootPart")
            if not humanoid or humanoid.Health <= 0 or not root
                or not root:IsA("BasePart") then return end
            local distance = (root.Position - coin.Position).Magnitude
            if not (distance <= 8) then return end -- Rejects NaN as well as excess distance.
            if os.clock() >= activeDeadline then return end
            -- Claim before changing shared score; this callback never yields.
            claimed = true
            collected += 1
            state:SetAttribute("Collected", collected)
            coin:Destroy()
        end)
        coin.Parent = coinsFolder
    end
end

while true do
    phase = "Waiting"
    collected = 0
    state:SetAttribute("Collected", 0)
    state:SetAttribute("TimeLeft", 0)
    setStatus("Waiting for " .. MIN_PLAYERS .. " player(s)")
    while playerCount() < MIN_PLAYERS do task.wait(1) end

    phase = "Intermission"
    setStatus("Next round starts soon")
    for seconds = INTERMISSION_SECONDS, 1, -1 do
        if playerCount() < MIN_PLAYERS then break end
        state:SetAttribute("TimeLeft", seconds)
        task.wait(1)
    end
    if playerCount() < MIN_PLAYERS then continue end

    roundId += 1
    local thisRound = roundId
    activeDeadline = os.clock() + ROUND_SECONDS
    phase = "Playing"
    setStatus("Collect the gold spheres together")
    makeCoins(thisRound)
    while collected < TARGET and playerCount() > 0 do
        local seconds = math.max(0, math.ceil(activeDeadline - os.clock()))
        state:SetAttribute("TimeLeft", seconds)
        if seconds == 0 then break end
        task.wait(0.2)
    end

    phase = "Results"
    state:SetAttribute("TimeLeft", 0)
    if collected >= TARGET then
        setStatus("Team success!")
    elseif playerCount() == 0 then
        setStatus("Round ended: no players remain")
    else
        setStatus("Time expired; try splitting up next round")
    end
    coinsFolder:ClearAllChildren()
    task.wait(RESULTS_SECONDS)
end

Complete client implementation

Paste this into RoundHud:

Download 12-round-hud.client.luau

-- NEW Baseplate project. LocalScript: StarterPlayer > StarterPlayerScripts > RoundHud
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local state = ReplicatedStorage:WaitForChild("RoundState")
local gui = Instance.new("ScreenGui")
gui.Name = "RoundHud"
gui.ResetOnSpawn = false
gui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")

local label = Instance.new("TextLabel")
label.Name = "RoundLabel"
label.AnchorPoint = Vector2.new(0.5, 0)
label.Position = UDim2.new(0.5, 0, 0, 16)
label.Size = UDim2.new(0.9, 0, 0, 100)
label.BackgroundColor3 = Color3.fromRGB(25, 30, 40)
label.TextColor3 = Color3.new(1, 1, 1)
label.TextSize = 20
label.TextWrapped = true
label.Parent = gui

local function render()
    label.Text = string.format("%s\nTeam coins: %d / %d  |  Time: %ds",
        state:GetAttribute("Status") or "Loading...",
        state:GetAttribute("Collected") or 0,
        state:GetAttribute("Target") or 0,
        state:GetAttribute("TimeLeft") or 0)
end
for _, name in ipairs({"Status", "Collected", "Target", "TimeLeft"}) do
    state:GetAttributeChangedSignal(name):Connect(render)
end
render()

Read the state machine

Only the Playing phase accepts claims, and each touch checks the server deadline directly. The deadline is set before entering Playing or spawning coins. A touch at or after that deadline is rejected even if the polling loop has not resumed yet. Each callback captures its round ID and a local claimed flag. The flag is set before increasing the shared total, and that short critical section does not yield. Multiple body parts touching the same sphere therefore cannot count it repeatedly. The round ID also makes the intended lifetime explicit: an old callback must not contribute to a later round.

The timer uses a deadline based on os.clock() rather than assuming every task.wait(1) resumes at precisely one-second intervals. The loop refreshes several times a second but the displayed value is an integer; the touch handler's deadline check, rather than that display refresh, defines the final eligible instant. This remains a tutorial-scale server timer, not a synchronized competitive timing protocol.

RoundState Attributes carry the latest display information. A player joining halfway through a round can read the current count and timer without needing to have received a past “round started” event. Attributes and change signals are covered in Properties and attributes. Separate Attribute updates are not an atomic snapshot: a HUD can briefly render intermediate combinations. They are display data only; the server does not make win decisions by reading them back from a client.

If everyone leaves, the active round ends. With MIN_PLAYERS = 2, two are required at the start; if one leaves during Playing, the remaining player may finish. That is an explicit design choice. Mid-round joining is allowed and does not reset the target. At Results, unclaimed coins are destroyed; six seconds later the loop prepares another round.

Server-side touches and proximity checks do not validate an entire movement history. Keep these rewards low-stakes until you understand network ownership and movement validation. The example demonstrates state ownership, not a complete anti-cheat implementation.

Verify the full loop

  1. Play alone with MIN_PLAYERS = 1. Expect a five-second intermission and eight gold spheres.
  2. Collect one sphere. It disappears and the shared count increases exactly once.
  3. Collect all eight before time runs out. Expect Team success!, followed by another fresh round.
  4. In another round, leave some spheres untouched. Expect the timeout result and no leftover coins in the next round. For a timing-boundary test, temporarily set ROUND_SECONDS to 5 and approach the final sphere as time expires: no claim at or after the server deadline may win. The integer client timer is approximate; verify with a server breakpoint or temporary timestamp logging if the boundary is unclear, then restore 45 seconds.
  5. Reset during Playing. The HUD should survive and the team's existing count should remain.
  6. Start a two-client test. Collect on one client, then the other. Both HUDs should converge on the same total and each sphere should disappear for both.
  7. Have both players approach the same sphere. The score must increase by one, never two.
  8. Stop, set MIN_PLAYERS = 2, then test with one and two clients. One waits; two start the intermission.

The code was statically reviewed, not executed in Studio during authoring. Record these checks in your own test notes, including any actual failure and its Output trace.

Troubleshooting and exercises

  • Old UI or score appears: confirm this is the separate project and remove copied obby systems.
  • Players spawn elsewhere: remove the template SpawnLocation and any extra spawns in this new place.
  • No coins: read the status and server Output; Waiting and Intermission intentionally contain none.
  • Score jumps unexpectedly: search for duplicate RoundServer scripts and verify the claimed guard remains before incrementing.
  • Never wins: check TARGET matches the number of generated coins; this implementation uses the same constant for both.

Exercises: make the round longer and compare solo versus two-player completion time. Add a non-authoritative color change on the HUD during the last ten seconds. Then design a rule requiring each player to contribute at least one coin: specify how late joiners and leavers should affect eligibility before writing code.

Sources and navigation

Last checked 2026-10-03: attributes, client-server runtime, movement security, Studio test modes.

Previous: 11 Modules and configuration · Next: 13 Cross-device controls

Related: 07 Collectibles and score · 14 Testing multiplayer · 19 Build with family · Project map

Progress and help notes

Nothing is sent automatically. Copying happens only when you press the button.

Sources