Skip to content

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.

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 every Zone-typed part it finds; it silently skips Sound and BoneChain tags because it has no handler for them.
  • The client attaches with AudioKit and BonePhysics and mounts Sound and BoneChain parts; it skips Zone tags 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.”

For every tagged instance, mount(instance):

  1. Reads instance:GetAttributes() and pulls MountType. 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.
  2. Looks up a handler: Handlers[MountType] first, then the built-in table — a game-defined handler for "Sound" overrides the built-in Sound handler entirely. A MountType with no handler on either side (a Zone tag reaching a client with no Zones in options) is the normal case for split server/client attachment and returns quietly.
  3. Runs the handler through pcall. A handler that throws warns with the error and the instance is never added to the mounted set — no Mount.Added, no cleanup registered, and the mount loop keeps going for every other tagged instance.
  4. On success, records { Type, Cleanup } (Cleanup may be nil) and publishes Mount.Added on 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.

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.luau
local 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

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,
},
})
end

Tag a part CKMount with MountType = "Lamp" and LampColor = "amber", and it lights on mount, dims on unmount — no other wiring.

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

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
  • Zone mounts have no cleanup by design. The built-in Zone handler returns nil — the comment in source is explicit that Zones drops streamed-out parts through its own sweep, so Mounts doesn’t need to call zones:remove or track anything extra on unmount. Sound and BoneChain do 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 calling Mounts.attach from 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.