Skip to content

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.

TagWatch.watch(tag, onAdded, onRemoved, root?):

  1. Connects CollectionService:GetInstanceAddedSignal(tag) and GetInstanceRemovedSignal(tag) first, then walks GetTagged(tag). Connecting first means an instance tagged during the walk is not missed.
  2. Tracks every tagged instance in the DataModel, inside the root or not, with one AncestryChanged connection each. AncestryChanged fires when the instance or any of its ancestors changes parent, so moving a folder of tagged parts into Workspace reports every part in it.
  3. 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.
  4. When the removed signal fires (the tag was removed, or the instance left the DataModel, which includes Destroy()), disconnects that instance’s AncestryChanged connection and calls onRemoved if it was inside.
  5. 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.

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 AncestryChanged fires, it is inside the root, onAdded runs.
  • Cloned: the clone carries the tag, so the added signal fires when the clone is parented into the DataModel, and onAdded runs 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:

src/Server/Game/Services/KillBrickService.luau
--!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 KillBrickService

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

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.

  • The cost is one AncestryChanged connection 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.

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