Skip to content

Mounts

Wires systems onto map instances tagged CKMount by their MountType attribute, mounting and unmounting live as tags, attributes and ancestry change.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.Mounts
Since
v0.6.0
Source
Mounts.luau

Mounts turns Studio tagging into wiring. Tag an instance CKMount, set a MountType attribute plus whatever attributes that type reads, and Mounts.attach(kernel, options) connects a real system to it: a waterfall part becomes an AudioKit emitter, a doorway joins a Zones region, a hanging chain becomes a BonePhysics chain. The map is the configuration: a level designer places a part and sets attributes, and no per-prop script or registration call is needed. The Game layer’s components are Mounts handlers: ComponentService and ComponentController attach every module in Game/Components as a custom type.

Mounts.attach takes the systems available on this side (AudioKit, Zones, BonePhysics) plus an optional Handlers map for custom types. It builds one handler per built-in type, but only for the systems it received: without Zones there is no Zone handler at all. That is what makes one tagged map safe to attach from both sides:

  • The server attaches with Zones (regions are server-authoritative) and mounts Zone instances; it has no handler for Sound or BoneChain and skips them quietly.
  • The client attaches with AudioKit and BonePhysics and mounts Sound and BoneChain instances; it skips Zone the same way.

Neither side needs to know what the other mounts.

An instance is wanted when it carries the tag, descends from Root (default workspace) and has a string MountType. Mounts follows all three live:

  • Tag add and remove through CollectionService signals. The signals connect before the initial GetTagged scan, so an instance tagged while a handler yields during the scan still mounts.
  • MountType through GetAttributeChangedSignal. A MountType set after the tag mounts then; changing it unmounts the old type and mounts the new one; clearing it unmounts.
  • Ancestry through AncestryChanged. A tagged template in ServerStorage or ReplicatedStorage stays inert until it is parented under Root, and moving it out unmounts it.

A tagged instance with no MountType logs one warning per attach (not per instance) and mounts as soon as one is set.

For each wanted instance, mount:

  1. Looks up a handler: Handlers[MountType] first, then the built-in table, so a game handler for "Sound" replaces the built-in one.
  2. With no handler, returns. A type whose system is absent on this side is normal. An unknown type logs one warning per type, but only when attach received no Handlers at all and the type is not a built-in; with any custom handlers present, a misspelled type is silent.
  3. Runs the handler under pcall with the instance and instance:GetAttributes(). A throwing handler logs a warning and the instance is not mounted: no Mount.Added, no cleanup. It is tried again when its MountType or ancestry changes.
  4. On success, records the type and the returned cleanup (which may be nil) and publishes Mount.Added.

Handlers may yield. If the instance is untagged, moved out of Root, retyped or the handle destroyed while its handler runs, the result is discarded when the handler returns: its cleanup runs, and whatever is wanted now is mounted instead.

Unmounting drops the entry, runs its cleanup through task.spawn (a slow or yielding cleanup never blocks the unmount) and publishes Mount.Removed.

Because everything follows signals, StreamingEnabled needs no special handling on the client. A part that streams in is added under the Workspace and tagged, so it mounts; a part that streams out leaves, so it unmounts. A map mounts and releases its props along with its geometry.

Root narrows mounting to one subtree. UI components live in the local player’s PlayerGui, not the Workspace, so ComponentController attaches Mounts twice with the same handlers: once with the default Root and once with Root = PlayerGui (waiting for the PlayerGui to exist). A tagged ScreenGui template in StarterGui stays inert; the copy StarterGui places in PlayerGui mounts. Pass Root = game only when you want every tagged instance anywhere to mount.

MountType Wires to Attributes Requires
Sound AudioKit:playAt(SoundName, instance, { Volume, Speed }); cleanup calls handle:stop() SoundName (a registered sound name), Volume?, Speed? AudioKit; the instance must be a BasePart or an Attachment
Zone Zones:addPart(ZoneName, instance); no cleanup ZoneName Zones; the instance must be a BasePart, and the zone must already exist
BoneChain BonePhysics:bind(instance, { Damping, Stiffness, GravityScale, WindScale }); cleanup calls handle:destroy() Damping?, Stiffness?, GravityScale?, WindScale? BonePhysics; the instance must be a BasePart

A missing required attribute (SoundName, ZoneName) or a non-BasePart Zone or BoneChain instance logs a warning and returns no cleanup. That is what a successful mount with nothing to clean up looks like, so the instance still counts as mounted and still publishes Mount.Added. Watch the output: a mount the Bus reports can still be a no-op.

Tag parts in Studio, then attach the systems each side has:

-- Studio tags and attributes, not code:
-- WaterfallPart [CKMount] MountType = "Sound" SoundName = "Waterfall" Volume = 0.6
-- VaultDoorway [CKMount] MountType = "Zone" ZoneName = "Vault"
-- ChainPart [CKMount] MountType = "BoneChain" Damping = 0.3 Stiffness = 0.6
src/Server/Game/Services/MapService.luau
--!strict
-- MapService: server-side map mounts. Zone mounts join zones created here.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Mounts = require(ReplicatedStorage.ChloeKernel.Mounts)
local Zones = require(ServerScriptService.ChloeKernelServer.Zones)
local Log = Logger.scope("MapService")
local MapService = {
Name = "MapService",
}
function MapService.init(self: any, kernel: any)
self.Kernel = kernel
self.Regions = Zones.attach(kernel)
end
function MapService.start(self: any)
-- A Zone mount adds its part to an existing zone, so the zone is created first
local Vault = workspace:FindFirstChild("VaultVolume")
if Vault and Vault:IsA("BasePart") then
self.Regions:add("Vault", Vault)
else
Log.warn("expected a BasePart at Workspace.VaultVolume")
end
-- No AudioKit or BonePhysics here: Sound and BoneChain tags are skipped on the server
self.Mounts = Mounts.attach(self.Kernel, { Zones = self.Regions })
end
function MapService.stop(self: any)
if self.Mounts then
self.Mounts.destroy()
end
self.Regions:destroy()
end
return MapService

Handlers adds your own types and overrides built-ins. A handler receives the instance and its attribute table and may return a cleanup:

local Lamps = Mounts.attach(kernel, {
Handlers = {
Lamp = function(instance: Instance, attributes: { [string]: any }): (() -> ())?
local Light = instance:FindFirstChildWhichIsA("PointLight")
if not Light then
Log.warn(`"{instance:GetFullName()}" has no PointLight`)
return nil
end
if typeof(attributes.LampColor) == "Color3" then
Light.Color = attributes.LampColor
end
Light.Enabled = true
return function()
Light.Enabled = false
end
end,
},
})

Tag a part CKMount with MountType = "Lamp" and a Color3 attribute LampColor, and it lights on mount and goes dark on unmount. For game code, prefer a Game-layer component (python tools/scaffold.py component Lamp), which ComponentService or ComponentController mounts through this same path.

Mounts.attach(kernel, options?) options:

Option Default Description
Tag "CKMount" CollectionService tag followed
Root workspace Only descendants of Root mount; pass the local PlayerGui for UI, or game for everywhere
AudioKit nil Enables the built-in Sound type
Zones nil Enables the built-in Zone type
BonePhysics nil Enables the built-in BoneChain type
Handlers nil { [MountType]: (instance, attributes) -> cleanup? }, checked before the built-ins

Mounts.attach returns a table whose functions are called with a dot:

Member Description
handle.unmount(instance) Unmounts one instance now (runs its cleanup, publishes Mount.Removed). It is still watched, so a later MountType or ancestry change mounts it again
handle.destroy() Disconnects every signal, discards handlers still running and unmounts everything
Topic Payload When
Mount.Added instance, mountType A handler returned without throwing
Mount.Removed instance, mountType A mounted instance unmounted: tag removed, left Root, MountType changed or cleared, unmount(), or destroy()
  • Split attachment is the intended shape. Tag a map once and call Mounts.attach from a server service and a client controller; each side wires only what it has.
  • A handler failure never stops the others: every call runs under pcall, and one broken handler warns while every other instance keeps mounting.

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