RoundKit
Runs a round-based match loop as an ordered phase cycle with deadline timers, minimum-player gates and an optional replicated countdown.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .RoundKit - Since
- v0.1.0
- Source
- Kits/RoundKit.luau
RoundKit runs a round-based match loop as an ordered list of phases, for example lobby, intermission, round and
results. Each phase can have a timer and a minimum player count. The cycle wraps after the last phase, and every
transition publishes one Bus topic, Round.PhaseChanged. Game logic lives in subscribers. Phase names
are yours; the kit never compares them against anything. TeamKit handles sides and spawns, and
Leaderboards handle score.
Mental model
Section titled “Mental model”The service tracks the current phase index and, for a timed phase, either a deadline or the time left while the phase
waits. It runs no loop. A timed phase arms one Scheduler after for its end, at
Priority.Normal. Nothing is scheduled before start() or after stop().
Each phase is one of three kinds:
- Timed (
Durationset). On entry the kit arms a deadlineDurationseconds out and advances when it fires. The time comes from the kitClock(defaultos.clock), and the phase ends on that deadline, so a server hitch does not stretch it. - Gate-only (
MinPlayersset, noDuration). The phase advances as soon as the server hasMinPlayerssessions, on a zero-second deadline after the join that met the gate. A lobby that waits for two players works this way. - Manual (neither set). Only
advance()leaves it. Use it for a phase your game ends itself, such as a round that ends on last team standing.
A MinPlayers gate on a timed phase holds the timer. If the server has fewer sessions when the phase starts, the timer
does not start. If a leave drops the count below the minimum while it runs, the kit cancels the deadline and keeps the
remaining time. The join that meets the gate again arms a new deadline for exactly that remainder. The gate never ends
the phase.
advance() cancels the current deadline, moves to Index % #Phases + 1, patches the replica, publishes
Round.PhaseChanged(name, duration) and arms the new phase. If a subscriber calls advance() again during that publish,
the inner call arms its own phase and the outer one does nothing more.
The service starts with no phase. Its start(), run by the kernel during boot, subscribes to sessions and advances into
phase 1. stop(), run when the server closes, cancels the timers and keeps the remaining time.
Countdown replication
Section titled “Countdown replication”Pass a ReplicaService instance as Replicas and the kit creates one replica at init:
- Name
"RoundState", schema{ Phase = "String", TimeLeft = "NumberU16" },TickRate = 2. - Each advance patches both fields, with
TimeLeftset to the new phase’sDuration(0 for an untimed phase). - While a timed phase runs, a
Scheduler:every(TickSeconds)job setsTimeLefttomath.ceil(timeLeft()). The value is derived from the deadline, so it stays correct after a hitch. The job runs only while a timed phase is running. - A freeze writes the frozen remainder once.
TickSeconds (default 1) sets only this countdown cadence. It does not affect when phases end. Without Replicas
nothing replicates and the loop still runs.
RoundKit is a service kit. Without a replica, add it to the server Registry.luau with its phases from
Game/Config:
--!strict-- Round: the match phase cycle.
return { Phases = { { Name = "Lobby", MinPlayers = 2 }, -- waits for two sessions, then advances { Name = "Intermission", Duration = 10 }, { Name = "Round", Duration = 180 }, { Name = "Results", Duration = 8 }, },}--!strict-- Every server service, one require per module in Services. ServiceManager-- orders boot by each service's Dependencies.
local ServerScriptService = game:GetService("ServerScriptService")
local RoundKit = require(ServerScriptService.ChloeKernelServer.Kits.RoundKit)
return { require(script.Parent.Services.ComponentService), RoundKit.service(require(script.Parent.Config.Round)), require(script.Parent.Services.ArenaService),} :: { any }Replicas needs a ReplicaService built from the kernel, so a game that wants the countdown on clients registers the
service where the kernel is in scope, instead of listing it in Registry.luau:
--!strict-- Game server entry. Builds the network handles once, then registers every-- service listed in Registry, plus RoundKit with its replica.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local Channels = require(ReplicatedStorage.Game.Channels)local NetRegistry = require(ReplicatedStorage.ChloeKernel.Net.Registry)local ReplicaService = require(ServerScriptService.ChloeKernelServer.Replica)local Round = require(script.Config.Round)local RoundKit = require(ServerScriptService.ChloeKernelServer.Kits.RoundKit)local Services = require(script.Registry)
return function(kernel: any) local Net = NetRegistry.server(kernel, Channels) for _, Service in Services do Service.Net = Net kernel:registerService(Service) end kernel:registerService(RoundKit.service({ Phases = Round.Phases, Replicas = ReplicaService.new(kernel), }))endA game service reacts to phases. It subscribes in start() and then handles the phase that is already running, because
RoundKit may have started first and entered phase 1 before this subscription existed:
--!strict-- ArenaService: what each round phase does to the players.
local ArenaService = { Name = "ArenaService", Dependencies = { "RoundKit" },}
function ArenaService.init(self: any, kernel: any) self.Kernel = kernelend
function ArenaService.enter(self: any, name: string) if name == "Round" then self.Kernel.Bus:publish("Arena.RoundStarted") elseif name == "Results" then self.Kernel.Bus:publish("Arena.RoundEnded") endend
function ArenaService.start(self: any) self.Round = self.Kernel:getService("RoundKit") self.Connection = self.Kernel.Bus:subscribe("Round.PhaseChanged", function(_topic, name) self:enter(name) end) local Current = self.Round:current() if Current then self:enter(Current.Name) endend
function ArenaService.stop(self: any) if self.Connection then self.Connection:disconnect() endend
return ArenaServiceA client follows the countdown through the replica:
--!strict-- RoundController: the round phase and countdown for the HUD.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ReplicaClient = require(ReplicatedStorage.ChloeKernel.Net.ReplicaClient)
local RoundController = { Name = "RoundController", Phase = "", TimeLeft = 0,}
function RoundController.init(self: any, _kernel: any) self.Replicas = ReplicaClient.new()end
function RoundController.start(self: any) self.Replicas:listen("RoundState", { Phase = "String", TimeLeft = "NumberU16" }, function(round) self.Phase, self.TimeLeft = round.Data.Phase, round.Data.TimeLeft round.OnChange:connect(function(key, value) if key == "Phase" then self.Phase = value elseif key == "TimeLeft" then self.TimeLeft = value end end) end)end
function RoundController.stop(self: any) if self.Replicas then self.Replicas:destroy() endend
return RoundControllerEnd a phase early, for example when the last team is eliminated:
self.Kernel:getService("RoundKit"):advance()advance() ignores timers and gates. It is the override for “this phase is over because the game says so”.
Joining and leaving mid-round
Section titled “Joining and leaving mid-round”Sessions matter only to MinPlayers gates. The kit counts kernel.Sessions on each session start and end:
- A join counts toward gates at once and receives the
RoundStatesnapshot. Whether the player enters the fight or waits for the next round is game code; the usual pattern holds late joiners until the next"Round". - A leave that drops the count below a gated phase’s
MinPlayersfreezes its timer. A 180 s round withMinPlayers = 2holds its remaining time until a second player joins. Phases without a gate ignore population.
Team spawns and scoreboards
Section titled “Team spawns and scoreboards”RoundKit owns only the cycle. The GunArena starter template combines it with
TeamKit for sides, spawns and friendly fire and Leaderboards for score. A
Round.PhaseChanged subscriber that respawns everyone at the start of a round gets team spawns from TeamKit with no
RoundKit wiring. Keep score out of RoundState: a narrow replica for the countdown and a separate, slower one for
scores costs less than one wide replica sent at the countdown’s rate.
Configuration
Section titled “Configuration”RoundKit.service(config)
Section titled “RoundKit.service(config)”| Field | Type | Default | Description |
|---|---|---|---|
Phases |
{ Phase } |
required | The ordered cycle. At least two phases, asserted when the service is created. |
Replicas |
ReplicaService? |
nil |
Creates the RoundState replica. Omit to skip replication. |
TickSeconds |
number? |
1 |
Cadence of the replica countdown while a timed phase runs. Phases end on their own deadline regardless. |
Clock |
(() -> number)? |
os.clock |
Time source for deadlines and timeLeft(). Specs inject a fake clock. |
| Field | Type | Default | Description |
|---|---|---|---|
Name |
string |
required | Published on every transition. The kit never interprets it. |
Duration |
number? |
nil |
Seconds before the phase advances. nil means no timer. |
MinPlayers |
number? |
nil |
Sessions required. A timed phase holds its timer below it; a phase without Duration advances once it is met. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
RoundKit.service(config) -> service |
Returns a service definition named RoundKit. Asserts #Phases >= 2. |
service:start() |
Subscribes to session starts and ends, then advances into phase 1. The kernel calls it during boot. |
service:stop() |
Cancels the phase deadline and the countdown, keeps the remaining time and unsubscribes from sessions. The kernel calls it when the server closes. |
service:current() -> Phase? |
The live phase table, nil before start(). |
service:timeLeft() -> number |
Seconds left in a running timed phase, the held remainder of a frozen or waiting one, or 0 for an untimed phase. |
service:advance() |
Moves to the next phase at once, wrapping after the last. Ignores timers and gates, patches the replica and publishes Round.PhaseChanged. |
The TimeLeft field and _tick were removed in 0.9.0. Read timeLeft().
Hooks and Bus topics
Section titled “Hooks and Bus topics”RoundKit fires no hooks and accepts no client input.
| Topic | Payload | When |
|---|---|---|
Round.PhaseChanged |
phaseName, duration? |
Every phase entry: a deadline, a met gate or advance(). duration is the new phase’s Duration, nil for untimed phases. |
QuestKit objectives can count this topic with a Filter (it names no player), and analytics and
music can react to it.