Skip to content

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.

local Regions = Zones.attach(Kernel)
-- Per-zone handle: no name-filtering global topics
local 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 function
Regions:trackTag("Enemy") -- every CollectionService-tagged instance, live
Kernel.Bus:subscribe("Zone.EntityEntered", function(_, name, entity)
if name == "Objective" then Alarm:trip(entity) end
end)
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.

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 end
end, 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.

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

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.

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.

local Duels = Matchmaking.new(Kernel, "Duels", { TeamSize = 2, PlaceId = ARENA_PLACE_ID })
Duels:enqueue(player) -- from a lobby pad or UI intent
Duels:dequeue(player) -- changed their mind
Kernel.Bus:subscribe("Matchmaking.MatchFound", function(_, queueName, members)
-- members are this server's players in the match; teleport is automatic
end)

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:

  • PlaceId must be a place in the same universe (a ReserveServer requirement), 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 and Matchmaking.TeleportFailed publishes.
  • 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.

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.

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.

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-cheap
local Top = Boards:top("Kills", 10) -- {Key, Value, Rank} from cache

Why 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.