Skip to content

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.

  1. Create the module: python tools/scaffold.py component Door for a server component, or python tools/scaffold.py component Beacon --client for a client one.
  2. In Studio (or through the Roblox Studio MCP), tag the instance CKMount.
  3. Set its MountType string attribute to the module name, Door.
  4. 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.

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:

src/Server/Game/Components/Door.luau
--!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()
end
end

Every connection, timer and tween the component creates goes into that Scope, so the cleanup releases all of it.

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:

src/Server/Game/Components/RoundDoor.luau
--!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)
end
end

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

Client components are presentation only: they change what one player sees, never game state. This one pulses the PointLight inside a tagged part:

src/Client/Game/Components/Beacon.luau
--!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
end
end

Scope: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.

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.

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:

  1. Connects to the tag’s added and removed signals before scanning, so an instance tagged while a slow handler runs is not missed.
  2. Tracks each tagged instance’s MountType attribute and ancestry. The instance mounts while it has a string MountType and sits under the root.
  3. Calls the handler with instance:GetAttributes() under pcall. A handler that throws logs <MountType> mount "<path>" failed: <error> and the instance stays unmounted.
  4. Unmounts when the tag is removed, the instance leaves the root (including Destroy), or MountType changes. Changing MountType unmounts the old type and mounts the new one. A MountType set after the tag mounts at that moment.
  5. 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.

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.

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 start
local StopWatching = TagWatch.watch("Coin", function(instance: Instance)
self.Coins[instance] = true
end, function(instance: Instance)
self.Coins[instance] = nil
end)
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.

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