Mounts
Mounts turns Studio tagging into wiring. Tag any Instance CKMount, set a MountType attribute plus whatever attributes that type needs, and Mounts.attach(kernel, options) reads those tags at level load and connects real systems to them — a waterfall part becomes an AudioKit emitter, a doorway becomes a Zones region, a hanging chain becomes a BonePhysics chain. The defining decision: the map is the configuration. A level designer places a part, sets a few attributes, and functionality exists — no per-prop script, no manual registration call, no PR to add a hook.
Mental model
Section titled “Mental model”Attach builds a handler table, not a scan loop
Section titled “Attach builds a handler table, not a scan loop”Mounts.attach takes an Options table naming which real systems are available — AudioKit, Zones, BonePhysics — plus an optional Handlers map for custom types. Internally it builds one handler function per built-in type, but only for the systems it was actually given. If options.Zones is nil, no Zone handler exists at all, not even a no-op one. That is what makes the same tagged map safe to attach from both sides at once:
- The server attaches with
Zones(regions are server-authoritative — see Zones) and mounts everyZone-typed part it finds; it silently skipsSoundandBoneChaintags because it has no handler for them. - The client attaches with
AudioKitandBonePhysicsand mountsSoundandBoneChainparts; it skipsZonetags the same way.
Neither side needs to know what the other side mounts. There is no negotiation, no “is this mine” check beyond “do I have a handler for this MountType.”
The mount cycle
Section titled “The mount cycle”For every tagged instance, mount(instance):
- Reads
instance:GetAttributes()and pullsMountType. If it isn’t a string, the instance is tagged but unconfigured — this warns once globally (not once per instance) and the instance is never registered as mounted. - Looks up a handler:
Handlers[MountType]first, then the built-in table — a game-defined handler for"Sound"overrides the built-inSoundhandler entirely. AMountTypewith no handler on either side (aZonetag reaching a client with noZonesinoptions) is the normal case for split server/client attachment and returns quietly. - Runs the handler through
pcall. A handler that throws warns with the error and the instance is never added to the mounted set — noMount.Added, no cleanup registered, and the mount loop keeps going for every other tagged instance. - On success, records
{ Type, Cleanup }(Cleanupmay benil) and publishesMount.Addedon the Bus.
Unmounting reverses it: the entry is dropped from the mounted set, its Cleanup (if any) runs via task.spawn, and Mount.Removed publishes.
Live tags, StreamingEnabled for free
Section titled “Live tags, StreamingEnabled for free”attach scans CollectionService:GetTagged(Tag) once up front, then connects GetInstanceAddedSignal/GetInstanceRemovedSignal for the rest of the session. Tagging or untagging an instance at runtime mounts or unmounts it immediately — which is also what makes this safe under instance streaming without any special-casing: a part that streams in is an instance getting tagged (from CollectionService’s perspective), and a part that streams out is an instance getting untagged. A map streams its mounts in and out along with its geometry. See StreamingEnabled.
destroy() disconnects both signals and unmounts everything still tracked — nothing is left running after a Mounts handle is torn down.
Tag parts in Studio, then attach the systems each side actually has. This example assumes a waterfall part tagged for sound, a doorway tagged as a zone, and a hanging chain tagged for bone physics:
-- Studio tagging (Properties/Tags window), not code:---- WaterfallPart [CKMount] MountType = "Sound" SoundName = "Waterfall" Volume = 0.6-- DoorwayPart [CKMount] MountType = "Zone" ZoneName = "Lobby"-- ChainPart [CKMount] MountType = "BoneChain" Damping = 0.3 Stiffness = 0.6-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServer
local Zones = require(Root.Zones)local Mounts = require(Root.Mounts)
return function(kernel) local Regions = Zones.attach(kernel)
-- Server only wires Zone mounts — Sound and BoneChain tags are skipped -- here because AudioKit/BonePhysics were never passed in. local Attachments = Mounts.attach(kernel, { Zones = Regions, })
kernel.Bus:subscribe("Zone.Entered", function(_, name, player) if name == "Lobby" then print(player.Name, "entered the lobby") end end)end-- src/Client/Bootstrap.luaulocal ReplicatedStorage = game:GetService("ReplicatedStorage")
local AudioKit = require(ReplicatedStorage.ChloeKernel.AudioKit)local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)local Mounts = require(ReplicatedStorage.ChloeKernel.Mounts)
return function(kernel) local Audio = AudioKit.new() local Bones = BonePhysics.attach()
-- Client wires Sound and BoneChain mounts off the same tagged map; -- Zone tags are skipped here because Zones was never passed in. local Attachments = Mounts.attach(kernel, { AudioKit = Audio, BonePhysics = Bones, })endCustom handlers for types not built in
Section titled “Custom handlers for types not built in”Handlers registers your own MountTypes, and takes priority over the built-ins — useful for anything the framework doesn’t ship (a swinging lamp, a destructible prop, a spawner). A handler receives the instance and its full attribute table, and optionally returns a cleanup closure that runs on unmount:
-- src/Server/Bootstrap.luau (excerpt)local Mounts = require(Root.Mounts)
return function(kernel) local Attachments = Mounts.attach(kernel, { Handlers = { Lamp = function(instance: Instance, attributes: { [string]: any }) local Light = instance:FindFirstChildWhichIsA("PointLight") if not Light then warn(`[Lamp] "{instance:GetFullName()}" has no PointLight`) return nil end Light.Color = attributes.LampColor and Color3.new(1, 0.8, 0.4) or Light.Color Light.Enabled = true
return function() Light.Enabled = false end end, }, })endTag a part CKMount with MountType = "Lamp" and LampColor = "amber", and it lights on mount, dims on unmount — no other wiring.
API reference
Section titled “API reference”Mounts.attach(kernel, options?) options:
| Option | Default | Description |
|---|---|---|
Tag |
"CKMount" |
CollectionService tag scanned |
AudioKit |
nil |
Enables the built-in Sound mount type |
Zones |
nil |
Enables the built-in Zone mount type |
BonePhysics |
nil |
Enables the built-in BoneChain mount type |
Handlers |
nil |
{ [MountType] = fn(instance, attributes) -> cleanup? }. Checked before the built-ins, so a game handler overrides a built-in type of the same name |
Return value of Mounts.attach:
| Member | Description |
|---|---|
unmount(instance) |
Manually unmount a tracked instance (runs its cleanup, publishes Mount.Removed) |
destroy() |
Disconnect tag signals and unmount everything still tracked |
Built-in mount types
Section titled “Built-in mount types”MountType |
Wires to | Attributes | Requires |
|---|---|---|---|
Sound |
AudioKit:playAt(SoundName, instance, { Volume, Speed }) |
SoundName (string, required), Volume?, Speed? |
options.AudioKit |
Zone |
Zones:addPart(ZoneName, instance) |
ZoneName (string, required) |
options.Zones; instance must be a BasePart |
BoneChain |
BonePhysics:bind(instance, { Damping, Stiffness, GravityScale, WindScale }) |
Damping?, Stiffness?, GravityScale?, WindScale? |
options.BonePhysics; instance must be a BasePart |
Handlers[MountType] signature:
(instance: Instance, attributes: { [string]: any }) -> (() -> ())?Return a cleanup function if the mount needs one; return nil if there’s nothing to tear down. The cleanup runs via task.spawn on unmount, so a slow or yielding cleanup never blocks the unmount call.
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Mount.Added |
instance, mountType |
An instance mounted successfully (handler ran without throwing) |
Mount.Removed |
instance, mountType |
A mounted instance unmounted — tag removed, unmount() called, or destroy() tore down the handle |
Design notes
Section titled “Design notes”- Zone mounts have no cleanup by design. The built-in
Zonehandler returnsnil— the comment in source is explicit that Zones drops streamed-out parts through its own sweep, soMountsdoesn’t need to callzones:removeor track anything extra on unmount.SoundandBoneChaindo return cleanup (Handle.stop()/Handle:destroy()), because those systems need an explicit teardown call. - Split attachment is the intended shape, not a workaround. Because a handler only exists for a system that was actually passed into
options, tagging one map once and callingMounts.attachfrom both the server and client Bootstraps is the normal pattern — each side wires only what it has, with zero coordination. - Handler failures never take down the mount loop. Every handler call is wrapped in
pcall; one broken custom handler warns and leaves every other tagged instance mounting normally.