Skip to content

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.

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 (Duration set). On entry the kit arms a deadline Duration seconds out and advances when it fires. The time comes from the kit Clock (default os.clock), and the phase ends on that deadline, so a server hitch does not stretch it.
  • Gate-only (MinPlayers set, no Duration). The phase advances as soon as the server has MinPlayers sessions, 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.

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 TimeLeft set to the new phase’s Duration (0 for an untimed phase).
  • While a timed phase runs, a Scheduler:every(TickSeconds) job sets TimeLeft to math.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:

src/Server/Game/Config/Round.luau
--!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 },
},
}
src/Server/Game/Registry.luau
--!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:

src/Server/Game/init.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),
}))
end

A 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:

src/Server/Game/Services/ArenaService.luau
--!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 = kernel
end
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")
end
end
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)
end
end
function ArenaService.stop(self: any)
if self.Connection then
self.Connection:disconnect()
end
end
return ArenaService

A client follows the countdown through the replica:

src/Client/Game/Controllers/RoundController.luau
--!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()
end
end
return RoundController

End 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”.

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 RoundState snapshot. 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 MinPlayers freezes its timer. A 180 s round with MinPlayers = 2 holds its remaining time until a second player joins. Phases without a gate ignore population.

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.

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.
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().

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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.