Start typing to search packages!
butler
By @ondcerm019
Roblox
MirroredButler v2.0 — Documentation
Memory management module for Roblox Luau.
Supersedes Maid, Trove, and Janitor with a far richer feature set drawn from systems languages, reactive programming, and modern resource management proposals.
Philosophy & Influences
Butler doesn't just copy Maid and Trove — it draws from battle-tested patterns across the software world:
| Source | Pattern Borrowed | Butler Feature |
|---|---|---|
| Rust | Drop trait — resources tied to scope lifetime | butler:Guard(), LIFO cleanup order |
| Rust | defer! macro via scopeguard crate | butler:Defer() |
| C++ | std::unique_ptr with custom deleter, SCOPE_EXIT | butler:Guard(), butler:Defer() |
| RxJS | takeUntil operator | butler:Until() |
| RxJS | CompositeDisposable / Subscription groups | butler:Batch() |
| RxJS | finalize() teardown observer | butler:OnClean() |
| TC39 | Explicit Resource Management (using / Symbol.dispose) | butler:Wrap() |
| Go | defer statement | butler:Defer() |
| Angular | DestroyRef lifecycle abstraction | butler:LinkToInstance() |
Why Not Just Use Maid/Trove/Janitor?
| Problem | Butler Solution |
|---|---|
| No named/keyed task slots — you can't replace one task | :Set("name", obj) replaces and cleans the old one |
:Destroy() twice throws an error | Double-destroy is a safe no-op |
| One bad task crashes the whole cleanup chain | All errors are caught, warned, and execution continues |
No :Once() that is also safe if the butler dies first | :Once() auto-disconnects OR cleans if butler dies first |
| No sub-scoping / parent-child relationship | :Scope() creates a child butler with inherited lifetime |
Tweens: :Destroy() doesn't stop a playing animation | :Tween() calls :Cancel() before :Destroy() |
| No way to replace a running animation/connection atomically | :Set("slot", newTween) cancels the old one first |
| No interval helpers — must wire RunService manually | :Every(interval, fn) tracked loop, auto-cancelled |
No thread management — manual task.spawn leaks | :Task(), :Delay() — threads tracked and cancelled |
| No way to couple a resource to its custom teardown inline | :Guard(value, fn) — Rust-style Drop at acquisition site |
| No SCOPE_EXIT / defer for side effects | :Defer(fn) — Go-style deferred cleanup |
| No batch-add | :Batch({...}) — add many items at once |
| No teardown hooks | :OnClean(fn) — fires after every Clean/Destroy |
| No way to early-dispose one item while keeping tracking | :Wrap(obj) returns a disposable proxy |
| No fluent/builder chaining | :AddChain() returns self |
| No debug visibility into what's tracked | :Snapshot(), Butler.setDebug(true) |
Installation
Drop Butler.rbxm into ReplicatedStorage (or ServerScriptService) and require it:
local Butler = require(ReplicatedStorage.Butler)
Quick Start
local Butler = require(ReplicatedStorage.Butler)
local RunService = game:GetService("RunService")
local butler = Butler.new()
-- Track a connection
butler:Connect(RunService.Heartbeat, function(dt) doWork(dt) end)
-- Named slot — auto-replaces on next call
butler:Set("character", character)
-- Child scope
local charScope = butler:Scope()
charScope:Add(someModel)
-- Auto-destroy when player leaves
butler:LinkToInstance(player)
-- Done:
butler:Destroy()
Full API Reference
Butler.new() → Butler
Creates a new Butler.
Butler.setDebug(enabled: boolean)
Global toggle. When true, Butler prints each task as it is cleaned with elapsed time. Use during development to find leaks.
Core Tracking
butler:Add(object, method?, label?) → object
Tracks any object for cleanup. Returns the object for inline use.
| Type | Default Cleanup |
|---|---|
function | object() |
RBXScriptConnection | object:Disconnect() |
thread | task.cancel(object) |
Instance | object:Destroy() |
Table + Destroy | object:Destroy() |
Table + destroy | object:destroy() |
Table + Disconnect | object:Disconnect() |
Tween (has Cancel+Play) | object:Cancel() then object:Destroy() |
Promise (getStatus+cancel) | object:cancel() if Running |
method provided | object:<method>() |
local conn = butler:Add(signal:Connect(fn))
butler:Add(someInstance)
butler:Add(myTween, "Cancel")
butler:Add(function() print("cleaned!") end)
butler:AddChain(object, method?) → butler
Same as :Add() but returns self for builder chaining:
Butler.new()
:AddChain(conn1)
:AddChain(conn2)
:AddChain(part)
:LinkToInstance(player)
butler:Set(name, object, method?) → object
Named slot. If the slot is already occupied, the old object is cleaned first.
butler:Set("healthBar", healthBarGui)
-- Later on respawn:
butler:Set("healthBar", newHealthBarGui) -- old one auto-destroyed
butler:Remove(nameOrObject)
Remove and clean a single task by name or reference:
butler:Remove("healthBar") -- by name
butler:Remove(someConnection) -- by reference
butler:Has(name) → boolean
Returns true if a named slot is occupied.
butler:Count() → number
Total number of currently tracked tasks.
butler:IsAlive() → boolean
Returns false after :Destroy() has been called.
Signals
butler:Connect(signal, fn) → RBXScriptConnection
Shorthand for butler:Add(signal:Connect(fn)).
butler:Once(signal, fn) → RBXScriptConnection
One-shot connection. Disconnects after first fire. If the butler is destroyed first, the connection is still disconnected safely — no dangling callbacks.
butler:Until(untilSignal, listenSignal, fn) → RBXScriptConnection
RxJS takeUntil pattern. Connects fn to listenSignal, but permanently disconnects the moment untilSignal fires.
-- Update NPC AI on Heartbeat until it dies:
butler:Until(
npc.Humanoid.Died,
RunService.Heartbeat,
function(dt) updateNPC(npc, dt) end
)
butler:ConnectMany({{signal, fn}, ...}) → {RBXScriptConnection}
Connect multiple signal→fn pairs in one call:
butler:ConnectMany({
{ RunService.Heartbeat, onHeartbeat },
{ player.CharacterAdded, onCharacter },
})
Constructing & Instances
butler:Construct(class, ...) → object
Create and immediately track an object:
local sig = butler:Construct(Signal) -- calls Signal.new()
local sig = butler:Construct(Signal.new) -- function constructor
local part = butler:Construct(Instance.new, "Part")
butler:Clone(instance) → Instance
Clone an instance and track the result.
butler:AddInstance(inst, parent?) → Instance
Track an instance and optionally parent it in one call:
local part = butler:AddInstance(Instance.new("Part"), workspace)
Async / Threads
butler:Task(fn) → thread
Spawn a tracked task.spawn thread. If the butler is destroyed, the thread is cancelled. Think of it as a Rust scoped thread — its lifetime is bounded by the butler's.
butler:Task(function()
while true do
doWork()
task.wait(0.05)
end
end)
butler:Delay(t, fn) → thread
Schedule a call after t seconds. Thread is tracked and cancellable:
butler:Delay(5, function()
print("5 seconds later — or never if butler died")
end)
butler:Every(interval, fn) → thread
Run fn on a fixed interval using a tracked loop. No RunService connection needed:
butler:Every(1/20, function()
updateMinimap()
end)
Rust / C++ Patterns
butler:Guard(value, cleanupFn) → value
Rust Drop trait / C++ ScopeGuard / unique_ptr with custom deleter.
Couples a resource to its cleanup function at the acquisition site — the core RAII principle. The cleanup function receives the value as its argument.
-- Acquisition and teardown are co-located and explicit:
local lock = butler:Guard(acquireLock(), function(l) l:release() end)
local sound = butler:Guard(Sound:Play(), function(s) s:Stop() end)
local file = butler:Guard(openFile(path), function(f) f:close() end)
Unlike :Add(obj, "Method"), Guard accepts arbitrary teardown logic and makes the relationship between resource and destructor unambiguous.
butler:Defer(fn) → butler
C++ SCOPE_EXIT / Go defer / Rust defer! macro.
Queues a side-effect to run at the next Clean() or Destroy(). Explicitly communicates "this is a cleanup action, not a tracked resource":
butler:Defer(function()
Analytics:recordSessionEnd(player)
print("Scope exited.")
end)
Returns self for chaining.
RxJS / Reactive Patterns
butler:Batch({items}) → butler
RxJS CompositeDisposable pattern. Add multiple items at once:
butler:Batch({
conn1,
conn2,
{ myTween, "Cancel" }, -- {object, method} pair
{ myAudio, "Stop" },
})
butler:OnClean(fn) → butler
RxJS finalize() operator. Registers a teardown observer that fires after every Clean() or Destroy():
butler:OnClean(function()
Metrics:recordCleanup(player)
print("Cleanup complete!")
end)
Multiple observers can be registered; all are called in order.
butler:Wrap(object) → DisposableHandle
TC39 Explicit Resource Management / Symbol.dispose / JavaScript using keyword.
Returns a proxy that forwards all method calls to the wrapped object, but adds a :Dispose() method for early removal from the butler:
-- JavaScript equivalent: `using stream = openStream();`
local handle = butler:Wrap(openStream())
handle:Read(1024) -- proxied to stream:Read(1024)
handle:Dispose() -- removes stream from butler, cleans it early
-- If :Dispose() is never called, butler:Destroy() cleans it normally
Roblox QoL
butler:Tween(instance, tweenInfo, goals) → Tween
Create, play, and track a Tween. On cleanup, :Cancel() is called before :Destroy() — stopping any mid-play animation. Maid, Trove, and Janitor all miss this and only call :Destroy(), which doesn't stop a playing tween:
local tween = butler:Tween(
part,
TweenInfo.new(0.5, Enum.EasingStyle.Quad),
{ CFrame = targetCFrame }
)
-- part smoothly moves. If butler:Destroy() fires, the part stops immediately.
butler:WaitFor(inst, childName, timeout?) → Instance?
A tracked WaitForChild wrapper. If the butler is destroyed while waiting, the coroutine is cancelled — no orphaned yields:
local gui = butler:WaitFor(player.PlayerGui, "MainGui", 10)
if gui then
setupGui(gui)
end
Scoping & Linking
butler:Scope() → Butler
Create a child butler whose lifetime is tied to the parent. Inspired by Rust's std::thread::scope:
local playerButler = Butler.new()
local function onCharacterAdded(char)
-- Create a new scope each respawn
local charScope = playerButler:Scope()
charScope:Add(char)
charScope:Once(char.Humanoid.Died, function()
print("died")
end)
end
player.CharacterAdded:Connect(onCharacterAdded)
-- When playerButler:Destroy() is called, all charScopes are also destroyed
butler:LinkToInstance(instance, allowReAdd?) → RBXScriptConnection
Auto-destroy the butler when a Roblox instance is destroyed:
butler:LinkToInstance(player) -- Destroy() on player leave
butler:LinkToInstance(character, true) -- Clean() only (reuse the butler)
Lifecycle
butler:Clean()
Clean all tasks and fire OnClean observers, but keep the butler alive for reuse. Cleanup order: named tasks → anonymous tasks (LIFO) → OnClean observers.
butler:Destroy()
Clean and permanently invalidate. Calling :Destroy() a second time is a safe no-op.
butler:Snapshot() → {[key]: string}
Returns a table of all tracked tasks keyed by their index or name. Use during development to inspect what's currently tracked:
local snap = butler:Snapshot()
for k, v in pairs(snap) do
print(k, "→", v)
end
-- 1 → RBXScriptConnection
-- 2 → thread
-- healthConn → RBXScriptConnection
-- character → Instance
Common Patterns
Per-Player Setup
local Butler = require(ReplicatedStorage.Butler)
local playerButlers: { [Player]: typeof(Butler.new()) } = {}
game.Players.PlayerAdded:Connect(function(player)
local butler = Butler.new()
playerButlers[player] = butler
butler:LinkToInstance(player) -- auto-destroys on leave
butler:OnClean(function()
print(player.Name, "left, all cleaned up")
end)
butler:Connect(player.CharacterAdded, function(char)
local scope = butler:Scope()
scope:Add(char)
scope:Until(
char.Humanoid.Died,
game:GetService("RunService").Heartbeat,
function(dt) updateCharacter(char, dt) end
)
end)
end)
OOP Class with Butler
local MySystem = {}
MySystem.__index = MySystem
function MySystem.new(player)
local self = setmetatable({}, MySystem)
self._butler = Butler.new()
self._butler:LinkToInstance(player)
self._butler:OnClean(function()
print("MySystem cleaned up")
end)
return self
end
function MySystem:Destroy()
self._butler:Destroy()
end
Animation State Machine
-- Cleanly swap animations using named slots + Tween cleanup
local function playAnimation(butler, track, info, goals)
-- Old tween is Cancel()'d and Destroy()'d before new one plays
butler:Set("activeTween", butler:Tween(character.HumanoidRootPart, info, goals))
end
Scoped Debug Session
Butler.setDebug(true)
local butler = Butler.new()
butler:Defer(function()
print("Total tasks cleaned:", taskCount)
end)
Butler.setDebug(false) -- turn off for production
Batch Connection
butler:Batch({
RunService.Heartbeat:Connect(onHeartbeat),
RunService.RenderStepped:Connect(onRender),
{ myTween, "Cancel" },
{ mySound, "Stop" },
})
Cleanup Order (LIFO — same as Rust)
Butler cleans anonymous tasks in Last In, First Out order, mirroring how Rust drops local variables and how C++ destroys stack objects. This means the most recently added resource is cleaned first, which is the safest default:
butler:Add(A) -- cleaned 3rd
butler:Add(B) -- cleaned 2nd
butler:Add(C) -- cleaned 1st ← last added, first cleaned
Named tasks are cleaned before anonymous tasks, in arbitrary order.
Butler v2.0.0
Package Details
Install command (Click to copy)
Version
0.1.1
License
MIT
Safe for commercial use
Automated license review — not legal advice.
