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.
Mental model
Section titled “Mental model”Attach builds a handler table
Section titled “Attach builds a handler table”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 mountsZoneinstances; it has no handler forSoundorBoneChainand skips them quietly. - The client attaches with
AudioKitandBonePhysicsand mountsSoundandBoneChaininstances; it skipsZonethe same way.
Neither side needs to know what the other mounts.
What counts as mounted
Section titled “What counts as mounted”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
CollectionServicesignals. The signals connect before the initialGetTaggedscan, so an instance tagged while a handler yields during the scan still mounts. MountTypethroughGetAttributeChangedSignal. AMountTypeset 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 underRoot, 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.
The mount cycle
Section titled “The mount cycle”For each wanted instance, mount:
- Looks up a handler:
Handlers[MountType]first, then the built-in table, so a game handler for"Sound"replaces the built-in one. - 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
attachreceived noHandlersat all and the type is not a built-in; with any custom handlers present, a misspelled type is silent. - Runs the handler under
pcallwith the instance andinstance:GetAttributes(). A throwing handler logs a warning and the instance is not mounted: noMount.Added, no cleanup. It is tried again when itsMountTypeor ancestry changes. - 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.
Live tags and StreamingEnabled
Section titled “Live tags and StreamingEnabled”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.
The local PlayerGui
Section titled “The local PlayerGui”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.
Built-in mount types
Section titled “Built-in mount types”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--!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--!strict-- AmbienceController: client-side map mounts for sounds and bone chains.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local AudioKit = require(ReplicatedStorage.ChloeKernel.AudioKit)local BonePhysics = require(ReplicatedStorage.ChloeKernel.BonePhysics)local Mounts = require(ReplicatedStorage.ChloeKernel.Mounts)local Sounds = require(ReplicatedStorage.Game.Config.Sounds) -- name -> AudioKit SoundConfig; Waterfall is Looped
local AmbienceController = { Name = "AmbienceController",}
function AmbienceController.init(self: any, kernel: any) self.Kernel = kernelend
function AmbienceController.start(self: any) self.Audio = AudioKit.attach(self.Kernel) self.Audio:registerBank(Sounds) self.Bones = BonePhysics.attach() -- No Zones here: Zone tags are skipped on the client self.Mounts = Mounts.attach(self.Kernel, { AudioKit = self.Audio, BonePhysics = self.Bones })end
function AmbienceController.stop(self: any) if self.Mounts then self.Mounts.destroy() end if self.Audio then self.Audio:destroy() end if self.Bones then self.Bones:destroy() endend
return AmbienceControllerCustom handlers
Section titled “Custom handlers”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.
API reference
Section titled “API reference”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 |
Bus topics
Section titled “Bus topics”| 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() |
Design notes
Section titled “Design notes”- Split attachment is the intended shape. Tag a map once and call
Mounts.attachfrom 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.