Skip to content

RoundKit

RoundKit runs a round-based match loop. The defining decision: the loop is not a hard-coded state machine — it is a declarative ordered phase cycle. You hand it a list like lobby → intermission → round → results; durations tick down, MinPlayers gates hold, the cycle wraps, and every transition publishes one bus topic. Your game logic lives in subscribers, not in the kit.

Phase names are yours. The kit compares nothing against "Lobby" or "Round" — it only knows ordered phases, each with an optional timer and an optional player gate.

The service holds two numbers: Index (which phase) and TimeLeft (seconds remaining). A tick loop registered on the scheduler (every(TickSeconds, ...) at Priority.Normal, default 1 s) drives everything:

  1. Gate check. If the current phase has MinPlayers and fewer sessions exist, the tick returns immediately. The timer is frozen, not reset — when enough players arrive, the countdown resumes where it stopped.
  2. Timed phase (Duration set): TimeLeft decrements by TickSeconds. At <= 0, advance.
  3. Gate-only phase (MinPlayers set, no Duration): advances the moment the gate check passes — a lobby that holds until 2 players exist, then moves on the next tick.
  4. Manual phase (neither set): only advance() moves past it. Use this for phases your game code ends explicitly (a round that ends on last-team-standing rather than a timer).

advance() wraps: Index = Index % #Phases + 1. The cycle never terminates; the phase after the last is the first. Each advance sets TimeLeft to the new phase’s Duration (or 0), patches the replica, and publishes Round.PhaseChanged(name, durationOrNil).

The service starts at Index = 0 — no phase. kernel:start() calls the service’s start(), which advances into phase 1. Booting the kernel is starting the loop.

Pass a ReplicaService instance and the kit creates one replica:

  • Name "RoundState", schema { Phase = "String", TimeLeft = "NumberU16" }, TickRate = 2.
  • Every advance patches both fields; every tick sets TimeLeft (clamped to ≥ 0 — NumberU16 cannot carry the negative overshoot of a coarse tick).
  • Late joiners get the snapshot automatically; per-tick deltas are a couple of bytes. Omit Replicas and nothing replicates — the loop still runs.

Server, the full lobby → round cycle:

local ServerScriptService = game:GetService("ServerScriptService")
local Root = ServerScriptService.ChloeKernelServer
local RoundKit = require(Root.Kits.RoundKit)
local ReplicaService = require(Root.Replica)
return function(kernel: any)
local Replicas = ReplicaService.new(kernel)
kernel:registerService(RoundKit.service({
Replicas = Replicas,
Phases = {
{ Name = "Lobby", MinPlayers = 2 }, -- holds until two sessions exist
{ Name = "Intermission", Duration = 10 },
{ Name = "Round", Duration = 180 },
{ Name = "Results", Duration = 8 },
},
}))
kernel.Bus:subscribe("Round.PhaseChanged", function(_, name)
if name == "Round" then
teleportEveryoneToArena()
elseif name == "Results" then
crownWinner()
end
end)
end

Client, following the countdown:

local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernel
local ReplicaClient = require(ChloeKernel.Net.ReplicaClient)
local Replicas = ReplicaClient.new()
Replicas:listen("RoundState", { Phase = "String", TimeLeft = "NumberU16" }, function(round)
HudTimer.setPhase(round.Data.Phase, round.Data.TimeLeft)
round.OnChange:connect(function(key, value)
if key == "TimeLeft" then
HudTimer.setSeconds(value)
else
HudTimer.setPhase(value, round.Data.TimeLeft)
end
end)
end)

End a round early — sudden death triggered, last team eliminated — with a manual advance:

kernel:getService("RoundKit"):advance()

advance() ignores timers and gates: it is the programmatic override for “this phase is over because I say so”.

Nothing pauses for individual players — sessions only matter to MinPlayers gates, counted live from kernel.Sessions each tick:

  • A join mid-round counts toward gates immediately and receives the RoundState snapshot on the replica. Whether they spawn into the fight or wait out the phase is your call — the usual pattern parks late joiners on Round.PhaseChanged and spawns them on the next "Round".
  • A leave that drops the count below a gated phase’s MinPlayers freezes that phase’s timer mid-countdown. A 180 s round with MinPlayers = 2 holds at whatever TimeLeft it had until a second player returns. Ungated phases are unaffected by population.

RoundKit deliberately owns nothing beyond the cycle. The GunArena starter template shows the standard composition — TeamKit owns spawns and friendly fire, Leaderboards own score, and RoundKit just turns the crank:

TeamKit.attach(kernel, {
Teams = {
Red = { Color = Color3.fromRGB(200, 60, 60), Capacity = 8 },
Blue = { Color = Color3.fromRGB(60, 60, 200), Capacity = 8 },
},
SyncRoblox = true,
})
kernel:registerService(RoundKit.service({
Phases = {
{ Name = "Lobby", MinPlayers = 2 },
{ Name = "Round", Duration = 180 },
{ Name = "Results", Duration = 8 },
},
}))

TeamKit’s tagged TeamSpawn parts place characters (anti-exploit pardoned) with zero RoundKit wiring; a Round.PhaseChanged subscriber respawning everyone at round start gets team-correct placement for free. Score lives in its own replica or leaderboard at its own tick rate — don’t widen RoundState; one fast narrow replica plus one slow wide one beats a single replica ticking everything at the fastest field’s rate.

Field Type Default Description
Phases { Phase } required Ordered cycle. At least two phases — asserted at construction.
Replicas ReplicaService? Creates the RoundState replica. Omit to skip replication entirely.
TickSeconds number? 1 Loop cadence and timer resolution.
Field Type Default Description
Name string required Published on every transition. Purely yours — the kit never interprets it.
Duration number? Seconds before auto-advance. nil = no timer.
MinPlayers number? Phase holds (timer frozen) until this many sessions exist. On a phase without Duration, meeting the gate advances.
Member Description
RoundKit.service(config: KitConfig) Returns a service definition for kernel:registerService. Asserts #Phases >= 2.
service:start() Advances into phase 1. Called by the kernel during boot — you don’t call this.
service:current(): Phase The live phase table (nil before boot completes). Read Name/Duration/MinPlayers off it.
service:advance() Immediately move to the next phase, wrapping after the last. Skips timers and gates, resets TimeLeft, patches the replica, publishes Round.PhaseChanged.

_tick and _sessionCount are internal (specs drive _tick directly to step time deterministically).

RoundKit fires no hook chains — it accepts no client input. Its single topic:

Topic Args Published when
Round.PhaseChanged phaseName: string, duration: number? Every phase entry — timer expiry, gate satisfaction, or manual advance(). duration is the new phase’s Duration, nil for untimed phases.

That one topic is the integration surface: QuestKit objectives can count it (with a Filter — it carries no player args), analytics can log it, music can duck on it.