Components
Behavior for many tagged instances, authored in Studio with the CKMount tag and a MountType attribute and mounted by ComponentService and ComponentController over Mounts.
A component is behavior attached to every instance that carries a tag: doors, pickups, spinning signs, glowing beacons. The game writes one module per behavior in Components/, and the map decides where it applies. ComponentService on the server and ComponentController on the client turn every module in their side’s Game/Components folder into a handler for Mounts, the framework’s map-authored attachment system. Mounts watches the CKMount tag, reads each instance’s MountType attribute and calls the matching component. Reach for a component instead of a service when the behavior belongs to many instances and has no client requests of its own. Game code that needs a different tag uses TagWatch instead.
Authoring in Studio
Section titled “Authoring in Studio”- Create the module:
python tools/scaffold.py component Doorfor a server component, orpython tools/scaffold.py component Beacon --clientfor a client one. - In Studio (or through the Roblox Studio MCP), tag the instance
CKMount. - Set its
MountTypestring attribute to the module name,Door. - Add any other attributes the component reads. They become its config.
Only instances under Workspace mount (and, on the client, under the local PlayerGui). A tagged template in ReplicatedStorage or ServerStorage stays inert until a copy is parented into Workspace, which mounts the copy. The component and its attributes go in docs/game/Plan.md under Components and tags, so the map can be rebuilt from the plan.
The contract
Section titled “The contract”A component module returns one function:
function(kernel, instance: Instance, attributes: { [string]: any }): (() -> ())?It runs once per instance when the instance mounts and may return a cleanup function, which runs when the instance unmounts. The scaffold writes the minimal shape:
--!strict-- Door component: behavior for instances tagged CKMount with MountType "Door".
return function(kernel: any, _instance: Instance, _attributes: { [string]: any }): (() -> ())? local Scope = kernel:createScope() return function() Scope:destroy() endendEvery connection, timer and tween the component creates goes into that Scope, so the cleanup releases all of it.
A server component
Section titled “A server component”Server components own authoritative behavior: collisions, damage, rewards, anything another player sees change. This door opens (loses collision and fades) while the round phase named by its OpenPhase attribute runs:
--!strict-- RoundDoor component: a part that opens while the RoundKit phase named by its OpenPhase attribute runs.
return function(kernel: any, instance: Instance, attributes: { [string]: any }): (() -> ())? if not instance:IsA("BasePart") then return nil end local Part = instance :: BasePart local OpenPhase = if type(attributes.OpenPhase) == "string" then attributes.OpenPhase else "Round" local ClosedTransparency = Part.Transparency local function setOpen(isOpen: boolean) Part.CanCollide = not isOpen Part.Transparency = if isOpen then 0.8 else ClosedTransparency end
local Scope = kernel:createScope() Scope:add(kernel.Bus:subscribe("Round.PhaseChanged", function(_topic: string, phaseName: string) setOpen(phaseName == OpenPhase) end)) -- A door mounted mid-round missed the last PhaseChanged; read the current phase once local Phase = kernel:getService("RoundKit"):current() setOpen(Phase ~= nil and Phase.Name == OpenPhase)
return function() Scope:destroy() setOpen(false) endendComponentService has no Dependencies, so it starts in its alphabetical slot. In a game whose registry holds ComponentService, RoundKit and VoteService, the doors mount before RoundKit.start runs: current() returns nil, and the door hears the first Round.PhaseChanged a moment later. A door cloned into Workspace mid-round reads the running phase instead. A component that needs another system’s state should read state that system builds in init, or subscribe and read the current value, as this one does.
A client component
Section titled “A client component”Client components are presentation only: they change what one player sees, never game state. This one pulses the PointLight inside a tagged part:
--!strict-- Beacon component: pulses the PointLight inside a tagged part. Attributes: Period (seconds), Peak (brightness).
return function(kernel: any, instance: Instance, attributes: { [string]: any }): (() -> ())? local Light = instance:FindFirstChildOfClass("PointLight") if not Light then return nil end local Period = if type(attributes.Period) == "number" and attributes.Period > 0 then attributes.Period else 2 local Peak = if type(attributes.Peak) == "number" then attributes.Peak else 4 local Resting = Light.Brightness
local Scope = kernel:createScope() -- Repeats forever and reverses: the engine animates it, so no Luau runs per frame Scope:tween(Light, TweenInfo.new(Period / 2, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true), { Brightness = Peak, })
return function() Scope:destroy() Light.Brightness = Resting endendScope:tween creates and plays the tween and cancels it when the scope is destroyed. Tween:Cancel leaves the property where it stopped, so the cleanup restores the resting brightness.
Server and client halves
Section titled “Server and client halves”Each side mounts only the modules in its own Components folder. A MountType with no module on one side is ignored there without a warning, so:
Components/Door.luau exists on |
Result |
|---|---|
| Server only | The server runs it. Clients ignore the tag. |
| Client only | Each client runs it for its own view. The server ignores the tag. |
| Both | Both run, each with its own copy of the attributes. The server half owns the outcome; the client half adds presentation (a sound, a highlight). |
ComponentController attaches Mounts twice with the same handlers: once rooted at Workspace, and once rooted at the local player’s PlayerGui, so a UI component (a tagged button or badge inside a ScreenGui copy) mounts too. If PlayerGui does not exist yet when the controller starts, it waits for it on LocalPlayer.ChildAdded. Other players’ PlayerGuis never replicate to a client, and templates in StarterGui stay inert because StarterGui is outside both roots.
With StreamingEnabled, the client mounts what streams in and unmounts what streams out, because both events move the instance into or out of the Workspace root.
How mounting works
Section titled “How mounting works”ComponentService.start (and ComponentController.start) requires every ModuleScript directly inside Game/Components and wraps each one as a Mounts handler keyed by the module’s name. Mounts.attach then:
- Connects to the tag’s added and removed signals before scanning, so an instance tagged while a slow handler runs is not missed.
- Tracks each tagged instance’s
MountTypeattribute and ancestry. The instance mounts while it has a stringMountTypeand sits under the root. - Calls the handler with
instance:GetAttributes()underpcall. A handler that throws logs<MountType> mount "<path>" failed: <error>and the instance stays unmounted. - Unmounts when the tag is removed, the instance leaves the root (including
Destroy), orMountTypechanges. ChangingMountTypeunmounts the old type and mounts the new one. AMountTypeset after the tag mounts at that moment. - Runs each cleanup on its own thread with
task.spawn, so a cleanup cannot stall the unmount of the next instance.
A handler may yield. If the instance is untagged, moved out of the root or retyped while the handler runs, Mounts runs the returned cleanup as soon as the handler returns and then mounts whatever the instance wants now. Mounts publishes Mount.Added(instance, mountType) and Mount.Removed(instance, mountType) on the Bus for each mount and unmount. stop on either system destroys its Mounts handles, which unmounts everything.
Attributes are read once
Section titled “Attributes are read once”The attributes table is a snapshot taken at mount time. Changing any attribute other than MountType does not remount the instance. A component that must follow a live attribute connects to it and puts the connection in its scope:
Scope:add(instance:GetAttributeChangedSignal("OpenPhase"):Connect(function() OpenPhase = instance:GetAttribute("OpenPhase") or "Round"end))Attributes set on the server replicate to clients, so one attribute can configure both halves of a component.
Game-side tag watchers
Section titled “Game-side tag watchers”A system that follows its own tag (every Coin part, every KillBrick) uses TagWatch instead of raw CollectionService signals. CollectionService reports tagged instances anywhere in the DataModel, including templates in storage. TagWatch.watch narrows that to instances under a root, Workspace by default:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TagWatch = require(ReplicatedStorage.ChloeKernel.TagWatch)
-- In a service's startlocal StopWatching = TagWatch.watch("Coin", function(instance: Instance) self.Coins[instance] = trueend, function(instance: Instance) self.Coins[instance] = nilend)self.Scope:add(StopWatching)onAdded runs when a tagged instance enters the root, onRemoved when it leaves the root or loses the tag. Moves are followed through each instance’s AncestryChanged, so nothing polls. The returned stop function disconnects everything without calling onRemoved, so clear any per-instance state yourself after stopping. templates/Obby/Server.luau watches KillBrick this way and pairs each brick’s Touched with a Zones volume the server sweeps, because a client can stop its own character’s touches from registering.