TagWatch
Reports CollectionService tag membership only for instances inside a root, Workspace by default, so tagged templates kept in storage stay inert.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .TagWatch - Since
- v0.9.0
- Source
- TagWatch.luau
CollectionService reports a tagged instance wherever it sits in the DataModel. A prefab in ServerStorage, a map kept
in ReplicatedStorage for rotation or an NPC template counts as tagged, and a plain tag watcher wires kill bricks,
prompts and zones onto copies no player can reach. TagWatch narrows membership to instances under a root, Workspace by
default: onAdded runs when a tagged instance enters the root and onRemoved when it leaves the root or loses the tag.
Moves are followed through each tagged instance’s AncestryChanged, so nothing polls. Since 0.9.0 the framework’s
tag-driven kits follow the same rule: InteractionKit and Zones watch
through TagWatch, and Mounts and TeamKit spawns check for Workspace the same
way. Game components mount under the same rule.
How it works
Section titled “How it works”TagWatch.watch(tag, onAdded, onRemoved, root?):
- Connects
CollectionService:GetInstanceAddedSignal(tag)andGetInstanceRemovedSignal(tag)first, then walksGetTagged(tag). Connecting first means an instance tagged during the walk is not missed. - Tracks every tagged instance in the DataModel, inside the root or not, with one
AncestryChangedconnection each.AncestryChangedfires when the instance or any of its ancestors changes parent, so moving a folder of tagged parts intoWorkspacereports every part in it. - On each event, reads the instance’s current state with
IsDescendantOf(root). Inside the root and not yet reported:onAdded(instance). Outside the root and reported:onRemoved(instance). Otherwise nothing. - When the removed signal fires (the tag was removed, or the instance left the DataModel, which includes
Destroy()), disconnects that instance’sAncestryChangedconnection and callsonRemovedif it was inside. - Returns a stop function that disconnects everything without calling
onRemoved.
The check in step 3 reads the current state instead of trusting the event, because under deferred signals a handler can
run after further moves. An instance that goes in and out before its handler runs is judged by where it is at that
moment, and onAdded and onRemoved always alternate for one instance.
Why stored templates stay inert
Section titled “Why stored templates stay inert”A template in storage is tracked but never inside the root, so onAdded never runs for it. The two ways it reaches the
world both work:
- Moved: its
AncestryChangedfires, it is inside the root,onAddedruns. - Cloned: the clone carries the tag, so the added signal fires when the clone is parented into the DataModel, and
onAddedruns once it is inside the root.
Moving it back to storage, or destroying the world copy, runs onRemoved. Nothing is wired onto the copy left in
storage, so there is nothing to clean up when the map rotates.
A kill brick service, after the Obby template:
--!strict-- KillBrickService: parts tagged KillBrick kill on touch while they are in the world.
local Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TagWatch = require(ReplicatedStorage.ChloeKernel.TagWatch)
local KillBrickService = { Name = "KillBrickService",}
function KillBrickService.init(self: any, kernel: any) self.Kernel = kernel self.Touches = {} :: { [Instance]: RBXScriptConnection }end
function KillBrickService.start(self: any) self.StopWatching = TagWatch.watch("KillBrick", function(instance) if not instance:IsA("BasePart") then return end self.Touches[instance] = (instance :: BasePart).Touched:Connect(function(hit) local Character = hit:FindFirstAncestorOfClass("Model") local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if Humanoid and Players:GetPlayerFromCharacter(Character) and Humanoid.Health > 0 then Humanoid.Health = 0 end end) end, function(instance) local Connection = self.Touches[instance] if Connection then Connection:Disconnect() self.Touches[instance] = nil end end)end
function KillBrickService.stop(self: any) if self.StopWatching then self.StopWatching() end -- The stop function does not call onRemoved: release what is still inside for _, Connection in self.Touches do Connection:Disconnect() end table.clear(self.Touches)end
return KillBrickServiceA narrower root watches one area only:
local Lobby = workspace:WaitForChild("Lobby")
local StopPads = TagWatch.watch("LaunchPad", function(pad) pad:SetAttribute("Armed", true)end, function(pad) pad:SetAttribute("Armed", false)end, Lobby)On a client, pass the local PlayerGui as the root to follow tagged UI instead of world parts.
API reference
Section titled “API reference”| Member | Description |
|---|---|
TagWatch.watch(tag: string, onAdded: (Instance) -> (), onRemoved: (Instance) -> (), root: Instance?): () -> () |
Calls onAdded for every instance carrying tag that is, or later comes, under root (default Workspace), and onRemoved when it leaves the root, loses the tag or leaves the DataModel. Returns a stop function. |
Require it from ReplicatedStorage.ChloeKernel.TagWatch. It works on both machines.
Gotchas
Section titled “Gotchas”- The cost is one
AncestryChangedconnection per tagged instance anywhere in the DataModel, stored templates included. A tag on thousands of stored copies costs thousands of connections. - A callback that yields holds the signal’s thread. Spawn slow work.
- With StreamingEnabled on the client, instances that stream in and out arrive and leave through the same signals.