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.
Mental model
Section titled “Mental model”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:
- Gate check. If the current phase has
MinPlayersand fewer sessions exist, the tick returns immediately. The timer is frozen, not reset — when enough players arrive, the countdown resumes where it stopped. - Timed phase (
Durationset):TimeLeftdecrements byTickSeconds. At<= 0, advance. - Gate-only phase (
MinPlayersset, noDuration): advances the moment the gate check passes — a lobby that holds until 2 players exist, then moves on the next tick. - 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.
Countdown replication
Section titled “Countdown replication”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 —NumberU16cannot carry the negative overshoot of a coarse tick). - Late joiners get the snapshot automatically; per-tick deltas are a couple of bytes. Omit
Replicasand 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)endClient, following the countdown:
local ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernellocal 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”.
Joining and leaving mid-round
Section titled “Joining and leaving mid-round”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
RoundStatesnapshot on the replica. Whether they spawn into the fight or wait out the phase is your call — the usual pattern parks late joiners onRound.PhaseChangedand spawns them on the next"Round". - A leave that drops the count below a gated phase’s
MinPlayersfreezes that phase’s timer mid-countdown. A 180 s round withMinPlayers = 2holds at whateverTimeLeftit had until a second player returns. Ungated phases are unaffected by population.
Team spawns and scoreboards
Section titled “Team spawns and scoreboards”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.
Configuration
Section titled “Configuration”RoundKit.service(config) — KitConfig
Section titled “RoundKit.service(config) — KitConfig”| 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. |
API reference
Section titled “API reference”| 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).
Hooks & bus topics
Section titled “Hooks & bus topics”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.