World & Interaction
The world layer: named regions that fire events, prompts that survive hostile clients, and the social structures — rounds, teams, parties, queues, quests, boards — that turn a map into a game. Every recipe is a complete example you can paste into a Bootstrap.
React to players and NPCs entering areas
Section titled “React to players and NPCs entering areas”local Regions = Zones.attach(Kernel)
-- Per-zone handle: no name-filtering global topicslocal Safe = Regions:add("SafeZone", workspace.SafeZonePart, { -- any invisible part as the volume OnEnter = function(player) Buffs:apply(Kernel:getSession(player), "Protected") end,})Safe.Left:connect(function(player) Buffs:remove(Kernel:getSession(player), "Protected") end)
-- Or listen globally on the Bus (handlers get the zone name first)Kernel.Bus:subscribe("Zone.Entered", function(_, name, player) ... end)Kernel.Bus:subscribe("Zone.Left", function(_, name, player) ... end)Zones aren’t players-only — track NPCs, props, or vehicles and they fire their own events:
Regions:track(BossModel) -- one entity; returns an untrack functionRegions:trackTag("Enemy") -- every CollectionService-tagged instance, live
Kernel.Bus:subscribe("Zone.EntityEntered", function(_, name, entity) if name == "Objective" then Alarm:trip(entity) endend)Regions:entitiesIn("Objective") -- plus :playersIn(name) and :isInside(name, occupant)Zero-code zones straight from Studio — tag parts and each becomes (or joins) a zone:
Regions:addTagged("CKZone") -- zone name = "ZoneName" attribute, falling back to the part's Name-- Parts resolving to the same name union into one zone, same as Regions:add("L", { PartA, PartB })Why it works: detection is volume queries on a cadence, not Touched — slow walks can’t slip through, ragdoll limbs don’t double-fire, and leaves always fire before enters on a swap. Destroying (or untagging) a tag-tracked entity fires its leaves immediately; a manually tracked entity that despawns leaves on the next sweep.
See Zones for the sweep internals, custom query injection, and zone-scoped replicas.
Let players interact with the world
Section titled “Let players interact with the world”Kernel:registerService(InteractionKit.service({ Interactions = { OpenChest = { Tag = "Chest", -- tag parts in Studio; prompts appear (and follow the tag) automatically ActionText = "Open", HoldDuration = 0.5, MaxDistance = 10, Cooldown = 3, LineOfSight = true, OnInteract = function(session, chest) Loot:roll(session, chest) end, }, },}))
-- Game rules prepend below the kit's gate (priority 50):Kernel.Hooks:on("Intent.Interact", function(context) if context.Id == "OpenChest" then return context.Session.Data.HasKey == true endend, 10)Why it works: a prompt trigger is client input — exploit tooling fires prompts from across the map — so every trigger re-validates server-side: distance (with latency slack), line of sight (server raycast, not the prompt’s client-side flag), and per-player cooldown, all through the fail-closed Intent.Interact chain before OnInteract runs. Bus: Interact.Triggered(session, id, instance), Interact.Rejected(player, id).
See InteractionKit for the full validation pipeline and Hooks for how gate chains resolve.
Run a round-based match loop
Section titled “Run a round-based match loop”The lobby → round → results cycle as a declarative phase list. Durations tick down, MinPlayers gates hold, and clients follow via a replica:
local Replicas = ReplicaService.new(Kernel)Kernel:registerService(RoundKit.service({ Replicas = Replicas, Phases = { { Name = "Lobby", MinPlayers = 2 }, { Name = "Intermission", Duration = 10 }, { Name = "Round", Duration = 180 }, { Name = "Results", Duration = 8 }, },}))
Kernel.Bus:subscribe("Round.PhaseChanged", function(_, name) if name == "Round" then teleportToArena() end if name == "Results" then crownWinner() endend)-- clients: ReplicaClient listen("RoundState", { Phase = "String", TimeLeft = "NumberU16" }, ...)End a round early with Kernel:getService("RoundKit"):advance().
Why it works: the phase list is the whole state machine — a phase with a Duration counts down, a phase with MinPlayers holds until enough sessions exist, and a phase with neither advances only through advance(). When a Replicas instance is passed, the kit publishes a RoundState replica (Phase, TimeLeft) so round UI is a render problem, not a networking one.
See RoundKit for phase semantics and Replica for how clients follow the state.
Split players into teams
Section titled “Split players into teams”TeamKit is one source of truth wired into every combat gate — no kit needs to know teams exist:
local 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, -- player-list colors via real Team instances})Why it works: new sessions auto-balance onto the emptiest team (capacity-weighted, so a 2-cap duelist corner fills proportionally against 8-cap armies); assign/teamOf/players/sameTeam cover manual control. Friendly fire is off by default and enforced where damage actually happens: the kit registers vetoes on Weapon.CanDamage and Melee.CanHit, and npc handles resolve through their Faction — Raiders-faction NPCs and Raiders-team players read as teammates with zero extra wiring. Spawn ownership is map-authored: tag parts TeamSpawn with a Team attribute and characters pivot to an owned spawn (anti-exploit pardoned). Bus: Team.Assigned(player, team, previous?).
See TeamKit for balance math, spawn ownership, and the friendly-fire vetoes.
Group players into parties
Section titled “Group players into parties”PartyKit is same-server parties: invites with expiry, one party per player, leader flow, and roster pushes so party UI is a render problem:
local Parties = PartyKit.attach(Kernel, { MaxSize = 4, InviteTtlSeconds = 60 })Kernel.Bus:subscribe("Party.Joined", function(_, party, player) ... end)Why it works: inviting while partyless creates the party on the first accept with the inviter as leader. Accepting leaves the current party. Leaders kick and promote; a leaving leader promotes the longest-standing member; the last member out disbands. Session end leaves the party and clears the player’s invites in both directions. Clients drive everything through six fail-closed intents (PT_Invite/Accept/Decline/Leave/Kick/Promote, UserIds on the wire) and receive PT_Sync roster pushes plus PT_Invited notifications. Parties are NOT teams — they never touch the combat gates; sameParty(a, b) exists for games that want party friendly-fire, and members(player) feeds Matchmaking group queues (a partyless player is a party of one). Gate invites in the Party.CanInvite hook (blocklists, level requirements). Bus: Party.Created/Disbanded/Joined/Left/Kicked/Invited/Promoted.
See PartyKit for the intent surface and leader-succession rules.
Queue players into matches
Section titled “Queue players into matches”local Duels = Matchmaking.new(Kernel, "Duels", { TeamSize = 2, PlaceId = ARENA_PLACE_ID })
Duels:enqueue(player) -- from a lobby pad or UI intentDuels:dequeue(player) -- changed their mind
Kernel.Bus:subscribe("Matchmaking.MatchFound", function(_, queueName, members) -- members are this server's players in the match; teleport is automaticend)Why it works: any server can assemble a match; members get teleported to a reserved server wherever they are. The queue is shared across all servers (MemoryStore), the match broadcast rides MessagingService, and the queue’s read-invisibility window keeps two coordinators from forming the same match twice.
Deployment notes:
PlaceIdmust be a place in the same universe (aReserveServerrequirement), booted with its own kernel to receive arrivals.- A player who queues and then leaves is tombstoned (MemoryStore queues can’t remove by value): coordinators on every server filter their reads against the tombstone map, consume reads containing ghosts, and re-queue the live members — a match never reserves a server for fewer real players than
TeamSize. If the match teleport fails after retries, the stranded members are re-queued andMatchmaking.TeleportFailedpublishes. - Matching is FIFO — for skill brackets, run one queue per bracket (
Matchmaking.new(Kernel, "Ranked_Gold", ...)).
See Matchmaking for the coordinator loop, tombstone semantics, and teleport retry policy.
Track quests from things that happen
Section titled “Track quests from things that happen”Kernel:registerService(QuestKit.service({ Quests = { WolfCull = { AutoAssign = true, Objectives = { { Topic = "Combat.Kill", Count = 5 } }, 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 } } }, },}))
Kernel.Bus:subscribe("Quest.Progress", function(_, player, questId, index, count, required) ... end)Kernel.Bus:subscribe("Quest.Completed", function(_, player, questId) ... end)Why it works: a quest is data — objectives count bus topics the server publishes (the catalog is the menu), so quests can’t be spoofed — there is no client input to validate. By default an event counts for a session when any published arg is that session or its player; Match additionally pins an arg (a zone name, a weapon id), and Filter = fn(session, topic, ...) takes full control (required for actor-less topics like Round.PhaseChanged). Progress persists via session.Profile when a DataDriver is attached; Repeatable resets to fresh on completion (dailies). assign/abandon/progress on the service handle the rest.
See QuestKit for objective matching rules and Signals & Bus for the event fabric quests count.
Show stats on the leaderboard
Section titled “Show stats on the leaderboard”Leaderstats.attach(Kernel, { Coins = { Field = "Coins" }, -- from session.Profile.Data Stage = { Field = "BestStage" }, Title = { Field = "Title", Kind = "StringValue", From = "Session" },})That’s the whole integration — values track the data automatically. Each stat reads its Field from session.Profile.Data by default (or session.Data with From = "Session") and mirrors it into the leaderstats value objects Roblox’s player list renders. Leaderstats is per-server display; for game-wide rankings see the next recipe.
See Leaderboards for both the per-server mirror and the global boards.
Keep global top-100 boards
Section titled “Keep global top-100 boards”Leaderstats is per-server; Leaderboards is the game-wide top-N on OrderedDataStores, with the persistence layer’s budget discipline:
local Boards = Leaderboards.attach(Kernel, { Boards = { Kills = {}, -- descending, keep-best, top 100, 60s cache BestTime = { Ascending = true }, -- speedruns: smaller wins },})Boards:submit("Kills", session, killCount) -- queue: instant, allocation-cheaplocal Top = Boards:top("Kills", 10) -- {Key, Value, Rank} from cacheWhy it works: submits queue and flush on a cadence — the newest value per player per window costs ONE write no matter how fast scores change, KeepBest boards only overwrite improvements (atomic UpdateAsync, no read-modify race), failed writes stay queued for the next window instead of vanishing, and WritesPerFlush caps each window so provider budgets survive spikes. top() serves a cached page so a hundred UI readers cost one GetSortedAsync per window, and a failed refresh keeps serving the previous page. The backend is a first-class seam — Roblox OrderedDataStores by default, custom stores (external DBs over HttpService, MemoryStores) as equals: implement submit(board, key, value, keepBest, ascending) and the keep-best rule arrives declaratively for your engine to apply atomically its own way (SQL upsert, Redis script, conditional PUT), or implement DataStore-shaped set(board, key, updater); both need sorted(board, ascending, count). Bus: Leaderboard.Updated(name, entries) on refresh.
See Leaderboards for the flush window math and the custom backend contract.