World and interaction
Recipes for zones, validated prompts, round loops, teams, parties, cross-server matchmaking with party queues, quests, badges and leaderboards.
The world layer: named regions that fire events, prompts that survive hostile clients, and the structures (rounds, teams, parties, queues, quests, achievements, boards) that turn a map into a game. Server examples are services in src/Server/Game/Services/; kits that ship as services (Kit.service(config)) go straight into src/Server/Game/Registry.luau (see Services and controllers).
React to players and NPCs entering areas
Section titled “React to players and NPCs entering areas”--!strict-- SafeZoneService: the spawn safe zone and the objective room.
local ServerScriptService = game:GetService("ServerScriptService")
local Zones = require(ServerScriptService.ChloeKernelServer.Zones)
local SafeZoneService = { Name = "SafeZoneService", Dependencies = { "StatusService" },}
function SafeZoneService.init(self: any, kernel: any) self.Kernel = kernel self.Scope = kernel:createScope() self.Regions = self.Scope:add(Zones.attach(kernel), "destroy")end
function SafeZoneService.start(self: any) local Buffs = self.Kernel:getService("StatusService").Buffs Buffs:define("Protected", { Immune = { "Toxin", "Curse" } }) -- held while inside
-- A per-zone handle: no filtering a global topic by name local Safe = self.Regions:add("SafeZone", workspace.Zones.SafeZone, { OnEnter = function(player) local Session = self.Kernel:getSession(player) if Session then Buffs:apply(Session, "Protected") end end, OnLeave = function(player) local Session = self.Kernel:getSession(player) if Session then Buffs:remove(Session, "Protected") end end, }) self.Scope:add(Safe.Entered:connect(function(player) self.Kernel.Bus:publish("Hud.Toast", player, "You are safe here") end))
-- NPCs and props fire their own topics self.Scope:add(self.Regions:trackTag("Enemy")) self.Regions:add("Objective", workspace.Zones.Objective) self.Scope:add(self.Kernel.Bus:subscribe("Zone.EntityEntered", function(_topic, zoneName, entity) if zoneName == "Objective" then self.Kernel.Bus:publish("Objective.Breached", entity) end end))
-- Zones straight from Studio: tagged parts named by their ZoneName attribute (or Name) self.Scope:add(self.Regions:addTagged("CKZone"))end
function SafeZoneService.stop(self: any) self.Scope:destroy()end
return SafeZoneServiceZones detect occupants with volume queries every 0.25 seconds instead of Touched, so a slow walk cannot slip through, ragdoll limbs do not fire twice, and on a swap the leave always fires before the enter. A block part is queried as its box; balls, cylinders, wedges, meshes and unions by their exact volume. Players publish Zone.Entered(name, player) and Zone.Left(name, player); tracked entities publish Zone.EntityEntered and Zone.EntityLeft. Regions:playersIn(name), entitiesIn(name) and isInside(name, occupant) read the current state. Destroying or untagging a tag-tracked entity fires its leaves at once. The sweep skips its queries on a server with no characters or tracked instances when every zone is already empty.
Author zone parts as trigger volumes with CanCollide off. add, addPart and addTagged also turn CanQuery and CanTouch off, so weapon rays, sight checks and touch handlers pass through a shop or safe zone instead of hitting it; KeepQueryable = true leaves a solid or touch-wired part as authored. addTagged and trackTag count only tagged instances inside Workspace, so a map or NPC template kept in ServerStorage creates no phantom zone until it moves into the world.
Let players interact with the world
Section titled “Let players interact with the world”InteractionKit ships as a service. Its configuration lives with the other server tunables:
--!strict-- Interactions: InteractionKit prompts, one entry per tagged kind of object.
return { Interactions = { OpenChest = { Tag = "Chest", -- tag parts in Studio; prompts appear and follow the tag ActionText = "Open", HoldDuration = 0.5, MaxDistance = 10, Cooldown = 3, LineOfSight = true, }, },}InteractionKit.service(require(script.Parent.Config.Interactions)),--!strict-- ChestService: what opening a chest does, and who may open one.
local ChestService = { Name = "ChestService", Dependencies = { "EconomyService" },}
function ChestService.init(self: any, kernel: any) self.Kernel = kernel self.Scope = kernel:createScope()end
function ChestService.start(self: any) local Economy = self.Kernel:getService("EconomyService")
-- Game rules run before the kit's own gate (priority 50) self.Scope:add(self.Kernel.Hooks:on("Intent.Interact", function(context) if context.Id == "OpenChest" then return Economy.Inventory:count(context.Session, "RareRelic") == 0 -- one relic at a time end return nil end, 10))
self.Scope:add(self.Kernel.Bus:subscribe("Interact.Triggered", function(_topic, session, id, _chest) if id == "OpenChest" then Economy:openChest(session) end end))end
function ChestService.stop(self: any) self.Scope:destroy()end
return ChestServiceA prompt trigger is client input, and exploit tools fire prompts from across the map. Every trigger re-validates on the server through the fail-closed Intent.Interact chain before anything runs: the prompt’s Enabled flag (lock one with Kernel:getService("InteractionKit"):promptFor(id, instance).Enabled = false), distance (MaxDistance times 1.25 for latency), line of sight (a server raycast; with LineOfSight = true the prompt’s own RequiresLineOfSight also hides it while occluded) and the per-player cooldown. The hook context carries Session, Player, Id, Instance, Prompt and Definition. A definition can also take OnInteract(session, instance).
Prompts appear only on tagged instances inside Workspace that a ProximityPrompt supports (a BasePart, an Attachment, or a Model with a PrimaryPart); anything else warns. KeyboardKeyCode, GamepadKeyCode, Exclusivity, ClickablePrompt, Style, UIOffset and MaxIndicatorDistance pass through to the prompt. Bus: Interact.Triggered(session, id, instance), Interact.Rejected(player, id).
Go deeper: InteractionKit, Hooks.
Run a round-based match loop
Section titled “Run a round-based match loop”The lobby, round and results cycle is a list of phases. RoundKit can replicate its state through a Replica, and a ReplicaService needs the kernel, which the registry module does not have when it loads. Register the kit from the server entry, which receives the kernel:
--!strict-- Game server entry. Builds the network handles once, then registers every service.
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 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({ Replicas = ReplicaService.new(kernel), -- publishes the "RoundState" replica Phases = { { Name = "Lobby", MinPlayers = 2 }, { Name = "Intermission", Duration = 10 }, { Name = "Round", Duration = 180, MinPlayers = 2 }, { Name = "Results", Duration = 8 }, }, }))end-- Any serviceKernel.Bus:subscribe("Round.PhaseChanged", function(_topic, phaseName, duration) if phaseName == "Results" then Kernel:getService("ScoreService"):crownWinner() endend)Kernel:getService("RoundKit"):advance() -- end a phase earlyA timed phase arms one deadline for its end. A phase with MinPlayers holds until that many sessions exist; on a timed phase it also freezes the remaining time when a leave drops below the minimum and resumes it when enough players join. A phase with neither advances only through advance(). Nothing runs before start(), and stop() (at server close) cancels the timers. timeLeft() and current() read the state. With Replicas, the kit publishes a RoundState replica (Phase as String, TimeLeft as NumberU16) whose countdown updates once per TickSeconds (1) only while a timed phase runs; clients listen with ReplicaClient:listen("RoundState", { Phase = "String", TimeLeft = "NumberU16" }, fn). Bus: Round.PhaseChanged(name, duration?).
Split players into teams
Section titled “Split players into teams”TeamKit is one source of truth that the combat gates already consult:
--!strict-- TeamService: two teams with map-authored spawns.
local ServerScriptService = game:GetService("ServerScriptService")
local TeamKit = require(ServerScriptService.ChloeKernelServer.Kits.TeamKit)
local TeamService = { Name = "TeamService",}
function TeamService.init(self: any, kernel: any) self.Teams = TeamKit.attach(kernel, { Teams = { Raiders = { Color = Color3.fromRGB(200, 60, 60), Capacity = 8 }, Guards = { Color = Color3.fromRGB(60, 60, 200), Capacity = 8 }, }, SyncRoblox = true, -- real Team instances for the player list })end
return TeamServiceNew sessions join the emptiest team, weighted by capacity, so a 2-player corner fills in proportion to an 8-player army. assign, unassign, teamOf, players and sameTeam cover manual control. Friendly fire is off by default and enforced where damage happens: the kit vetoes Weapon.CanDamage and Melee.CanHit at priority 40, and NPC handles resolve through their Faction, so Raiders-faction NPCs and Raiders-team players read as teammates.
Spawns are authored in the map: tag parts in Workspace TeamSpawn and give them a Team attribute. A tagged SpawnLocation the engine accepts for the player (Neutral, or a TeamColor matching theirs) becomes player.RespawnLocation and the engine places the character; any other owned spawn gets the character pivoted onto it after the engine’s own spawn move, with the anti-cheat pardoned. With SyncRoblox, every team gets a distinct TeamColor (the engine matches teams, spawns and the player list by color), and two Colors that round to the same BrickColor error at attach. Bus: Team.Assigned(player, team, previous?), Team.Unassigned(player, previous).
Go deeper: TeamKit.
Group players into parties
Section titled “Group players into parties”-- In init()self.Parties = PartyKit.attach(kernel, { MaxSize = 4, InviteTtlSeconds = 60 })
-- In stop()self.Parties:destroy()PartyKit runs same-server parties. Inviting while partyless creates the party on the first accept, with the inviter as leader, and accepting leaves the current party first. Leaders kick and promote; a leaving leader passes leadership to the longest-standing member, and the last member out disbands the party. A session’s end leaves its party and clears its invites both ways. Clients drive everything through six fail-closed intents (PT_Invite, PT_Accept, PT_Decline, PT_Leave, PT_Kick, PT_Promote, with UserIds on the wire) and receive PT_Sync roster pushes and PT_Invited notifications; show the DisplayName fields.
Players who arrive in the same Roblox platform party (Player.PartyId) join each other’s PartyKit party on session start, up to MaxSize; PlatformParties = false turns that off. The fail-open Party.CanInvite hook (From, To, Platform?) holds blocklists and level rules. Parties are not teams and never touch the combat gates; sameParty(a, b) exists for games that want party friendly fire. Bus: Party.Created, Party.Disbanded, Party.Joined, Party.Left, Party.Kicked, Party.Invited, Party.Promoted.
Go deeper: PartyKit.
Queue players into matches
Section titled “Queue players into matches”JoinQueue = { Kind = "Intent", Schema = {}, RateLimit = 1, Open = true },LeaveQueue = { Kind = "Intent", Schema = {}, RateLimit = 1, Open = true },QueueStatus = { Kind = "State", Schema = { "Boolean8", "String" } }, -- queued, reason--!strict-- QueueService: the duel queue across every server.
local ServerScriptService = game:GetService("ServerScriptService")
local Matchmaking = require(ServerScriptService.ChloeKernelServer.Matchmaking)
local ArenaPlaceId = 1234567890 -- a place in the same universe, booted with its own kernel
local QueueService = { Name = "QueueService",}
function QueueService.init(self: any, kernel: any) self.Kernel = kernel self.Scope = kernel:createScope() self.Duels = self.Scope:add(Matchmaking.new(kernel, "Duels", { TeamSize = 2, PlaceId = ArenaPlaceId }), "destroy")end
function QueueService.start(self: any) self.Net.JoinQueue.on(function(session) local Ok, Reason = self.Duels:enqueue(session.Player) -- yields on MemoryStore if session.Active then self.Net.QueueStatus.send(session.Player, Ok, Reason or "") end end) self.Net.LeaveQueue.on(function(session) self.Duels:dequeue(session.Player) self.Net.QueueStatus.send(session.Player, false, "left") end) self.Scope:add(self.Kernel.Bus:subscribe("Matchmaking.TeleportFailed", function(_topic, _queue, members, reason) for _, Player in members do self.Net.QueueStatus.send(Player, false, reason) end end))end
function QueueService.stop(self: any) self.Scope:destroy()end
return QueueServiceThe queue is shared by every server through MemoryStore, the match broadcast rides MessagingService, and matched players are teleported to a reserved server wherever they are. Only servers with local queued players run the coordinate pass (every 5 seconds), and the queue’s read-invisibility window keeps two servers from forming the same match. enqueue reserves the player’s slot before its MemoryStore calls, so a double click cannot queue twice, and returns false and a reason: unavailable (the match subscription is not up yet), already queued, already teleporting, cancelled (the player dequeued, matched or left while it yielded) or the MemoryStore error. MemoryStore queues cannot remove by value, so a player who dequeues or leaves is tombstoned and every server filters its reads against the tombstones.
A match whose teleport fails after retries publishes Matchmaking.TeleportFailed(queueName, members, reason, result, message) and re-queues its members at a higher priority, except for Unauthorized and GameNotFound, which retrying cannot fix. The queue name must fit the 80-character MessagingService topic with its CKMM_ prefix, TeamSize is at most 100, and a match whose message would pass 1 KB never reserves a server. Teleports go out in batches of 50. For skill brackets, run one queue per bracket. Matchmaking.serverAttributes(kernel, map) instead steers Roblox’s own public-server matchmaking by mirroring Bus state into MatchmakingService attributes. Bus: Matchmaking.Queued, Matchmaking.Dequeued, Matchmaking.MatchFound(queueName, localMembers), Matchmaking.TeleportFailed.
Go deeper: Matchmaking, TeleportRetry.
Queue a party together
Section titled “Queue a party together”Queueing members one by one can split a party across matches. enqueueGroup queues the whole party as one item:
QueueSquad = { Kind = "Intent", Schema = {}, RateLimit = 1, Open = true },-- In init()self.Squads = self.Scope:add(Matchmaking.new(kernel, "Squads", { TeamSize = 4, PlaceId = ArenaPlaceId }), "destroy")
-- In start(); Parties is the PartyKit instance from the parties recipelocal Parties = self.Kernel:getService("PartyService").Partiesself.Net.QueueSquad.on(function(session) local Party = Parties:partyOf(session.Player) if Party and Party.Leader ~= session.Player then self.Net.QueueStatus.send(session.Player, false, "only the leader can queue") return end -- A partyless player is a party of one local Ok, Reason = self.Squads:enqueueGroup(Parties:members(session.Player)) if session.Active then self.Net.QueueStatus.send(session.Player, Ok, Reason or "") endend)A group item is never split. Matches pack whole items into TeamSize, starting from the oldest item that can complete a team, and items a match leaves out go back into the queue at a higher priority so they keep their place. A server holding a queued group reads partial teams; other servers read full teams only. enqueueGroup also refuses empty group and group larger than TeamSize, and a group whose member leaves while the call yields is cancelled. PartyKit’s platform-party grouping means friends who joined together are already one party.
Go deeper: Matchmaking, PartyKit.
Track quests from things that happen
Section titled “Track quests from things that happen”QuestKit ships as a service:
--!strict-- Quests: QuestKit configuration. Objectives count Bus topics the server publishes.
return { Quests = { WolfCull = { AutoAssign = true, -- Combat.Kill(killer, victim) is published by the game's damage code; count only the killer Objectives = { { Topic = "Combat.Kill", Count = 5, ActorArg = 1 } }, OnComplete = function(session) session.Profile.Data.Coins += 500 end, }, Summit = { AutoAssign = true, Objectives = { { Topic = "Zone.Entered", Match = "Peak" } } }, DailyObby = { AutoAssign = true, Repeatable = true, Objectives = { { Topic = "Obby.Checkpoint", Count = 10 } } }, },}QuestKit.service(require(script.Parent.Config.Quests)),A quest is data. Objectives count Bus topics the server publishes (the catalog lists them), so there is no client input to spoof. By default an event counts for a session when any argument is that session or its player, victims included; ActorArg = 1 counts only the first argument. Match also requires one argument to equal a value (a zone name, a weapon id), and Filter = fn(session, topic, ...) takes full control, which topics without an actor argument (Round.PhaseChanged) need. Progress persists in the profile when a DataDriver is attached; completions wait while the profile loads, so OnComplete pays into the profile. Repeatable resets a quest on completion. assign, abandon and progress on the service cover the rest. Bus: Quest.Progress(player, questId, index, count, required), Quest.Completed(player, questId).
Award a badge when an achievement unlocks
Section titled “Award a badge when an achievement unlocks”AchievementKit counts Bus topics into the profile and unlocks each achievement once per player. Give one a BadgeId and its platform badge follows:
--!strict-- AchievementService: achievements, their rewards and their badges.
local ServerScriptService = game:GetService("ServerScriptService")
local AchievementKit = require(ServerScriptService.ChloeKernelServer.Kits.AchievementKit)
local AchievementService = { Name = "AchievementService", Dependencies = { "DataService" },}
function AchievementService.init(self: any, kernel: any) self.Achievements = AchievementKit.attach(kernel, { Achievements = { FirstChest = { Topic = "Interact.Triggered", -- (session, id, instance) Where = function(_session, id) return id == "OpenChest" end, BadgeId = 2124567890, }, Slayer = { Topic = "Combat.Kill", -- the game publishes (killerSession, victim) Count = 100, BadgeId = 2124567891, Reward = function(session) session.Profile.Data.Coins += 1000 end, }, }, })end
function AchievementService.stop(self: any) self.Achievements:destroy()end
return AchievementServiceThe first event argument must be the session (override with SessionOf per achievement). Progress and unlocks live in the profile under Achievements; events that land before the profile loads count into session data and merge on Kernel.ProfileLoaded, and nothing unlocks while the profile loads, so Reward pays into the profile and an achievement the profile already holds never rewards twice. progress(session, id) returns (count, target, unlocked) for UI.
A BadgeId badge is awarded at unlock. Awards run one at a time, spaced under AwardBadgeAsync’s documented 50 + 35 x players per minute, and retry with backoff (BadgeRetries, 5) while the player stays. On Kernel.ProfileLoaded the kit checks unlocked achievements against the player’s badges with one GetUserBadgesAsync call per 100 ids (spaced under 10 + 5 x players per minute) and awards any that are missing, so a badge added after launch reaches veterans on their next join. The badge must be enabled and belong to the experience; an award that returns false warns and is not retried. Bus: Achievement.Progress(session, id, count, target), Achievement.Unlocked(session, id), Achievement.BadgeAwarded(session, id, badgeId).
Go deeper: AchievementKit, Milestones.
Show stats on the leaderboard
Section titled “Show stats on the leaderboard”-- In a service's init()self.Stats = Leaderstats.attach(kernel, { Coins = { Field = "Coins" }, -- read from the profile once it loads Stage = { Field = "BestStage" }, Title = { Field = "Title", Kind = "StringValue", From = "Session" }, -- session.Data})Each stat mirrors a field into the leaderstats value objects the player list renders. It reads session.Profile.Data when a profile exists (else session.Data), or session.Data with From = "Session". Values follow the data on a one-second sweep that runs only while sessions exist and refresh as soon as a profile loads. A value of the wrong type for its Kind (a table in an IntValue) is skipped with one warning instead of stopping other stats. Leaderstats is per server; for game-wide rankings see the next recipe.
Go deeper: Leaderstats.
Keep global top-100 boards
Section titled “Keep global top-100 boards”Leaderboards keeps game-wide top lists on OrderedDataStores within the request budget:
--!strict-- BoardService: global kill and speedrun boards.
local ServerScriptService = game:GetService("ServerScriptService")
local Leaderboards = require(ServerScriptService.ChloeKernelServer.Leaderboards)
local BoardService = { Name = "BoardService",}
function BoardService.init(self: any, kernel: any) self.Boards = Leaderboards.attach(kernel, { Boards = { Kills = {}, -- descending, keep-best, top 100, 60 s cache BestTime = { Ascending = true }, -- smaller wins; submit whole milliseconds }, })end
function BoardService.recordKill(self: any, session: any, kills: number) self.Boards:submit("Kills", session, kills) -- queues; false for a fractional valueend
-- Yields: the first read, or a read after the cache expires, fetches the pagefunction BoardService.topKills(self: any) return self.Boards:names(self.Boards:top("Kills", 10)) -- entries gain DisplayName and Usernameend
function BoardService.stop(self: any) self.Boards:destroy() -- waits for queued scores to drain, within ShutdownSeconds (10)end
return BoardServiceSubmits queue and flush in capped windows: a window runs FlushSeconds (15) after the first pending submit and repeats only while work remains, and each window writes at most WritesPerFlush (30), further capped by the server’s ordered-write budget (30 + 5 x players per minute). A KeepBest board (the default) keeps each key’s best queued value and overwrites stored scores only with improvements through an atomic UpdateAsync; KeepBest = false keeps the newest. A value that fails MaxWriteAttempts (10) consecutive windows is dropped with a warning. The default backend stores integers only, so scale scores (milliseconds). top() serves a cached page (one GetSortedAsync per window for every reader) and keeps the previous page when a refresh fails; MaxEntries above 100 reads several pages. scores(board, subjects) reads chosen players in one BatchGetAsync per 100 keys for “your score” rows, and names(entries) resolves display names in bulk with a cache. destroy() in stop() rejects new submissions and drains the queue when the server closes. The backend is replaceable (submit with keep-best semantics or DataStore-shaped set, plus sorted). Bus: Leaderboard.Updated(name, entries).
Go deeper: Leaderboards.