Start typing to search packages!
classy
By @jaeymo
Roblox
Mirrored from WallyA lifecycle manager for tagged instances in Luau
Make composition and OOP architecture feel good in Roblox.
Quick Start · Examples · Settings · Lifecycle · API · Contributing
[!IMPORTANT] Classy is actively being worked on and features are constantly being added!
✨ What Is Classy?
Classy is a lifecycle manager built on top of CollectionService that makes both composition and OOP architecture more appealing in Luau.
It is the cleaner, strictly-typed, smarter iteration of the original package, Wrapper, which is now archived. If you're using Wrapper, switch to Classy.
🚀 Why Use Classy?
| Feature | What you get | |
|---|---|---|
| 🧩 | Two construction styles | Track instances with plain functions or full classes |
| 🧹 | Automatic cleanup | Handles the class object's metatable and the provided Janitor |
| 🔒 | Full type safety | Generics are preserved when accessing applied objects |
| 🏛️ | OOP-friendly | Write classes for game objects, then track them with tags |
| 🧱 | Composition-friendly | Register and fetch attached components (ClassyObjects) from an instance |
| ⚡ | Tiny API | A whole system can fit in a few lines |
👀 A Taste
Isn't this great? A quick shorthand:
-- All parts with the "KillPart" tag will now kill anything with a humanoid!
Classy.new("KillPart", function(instance: BasePart, janitor: Classy.Janitor)
janitor:Add(instance.Touched:Connect(function(hit: BasePart)
local humanoid = hit.Parent and hit.Parent:FindFirstChildWhichIsA("Humanoid")
if not humanoid then
return
end
humanoid.Health = 0
end))
end, {
ClassNames = { "BasePart" },
Ancestors = { workspace },
}):Init()
📦 Quick Start
Install
Wally (recommended)
Add Classy to your wally.toml:
[dependencies]
Classy = "jaeymo/classy@2.2.0"
Then run:
wally install
Or grab it from the Wally package page.
Dependencies
🧪 Examples
Classy's best feature is that you can use function-based or class-based construction, and both build the same underlying system. Here's the class used in the examples below:
📄 KillPartClass (click to expand)
--!strict
local Classy = require(path.to.classy)
local KillPartClass = {}
KillPartClass.__index = KillPartClass
export type KillPart = typeof(setmetatable({} :: {
Part: BasePart,
Janitor: Classy.Janitor,
}, KillPartClass))
function KillPartClass.new(part: Instance, janitor: Classy.Janitor): KillPart
return setmetatable({
Part = part :: BasePart,
Janitor = janitor,
}, KillPartClass)
end
function KillPartClass.Init(self: KillPart)
self.Janitor:Add(self.Part.Touched:Connect(function(hit: BasePart)
local humanoid = hit.Parent and hit.Parent:FindFirstChildWhichIsA("Humanoid")
if not humanoid then
return
end
humanoid.Health = 0
end))
end
function KillPartClass.DoSomething(self: KillPart)
print(self)
end
function KillPartClass.Destroy(self: KillPart)
print("This object has been destroyed!")
end
🏛️ Class-based construction
local KillPartClassy = Classy.newClass("KillPart", KillPartClass, {
ClassNames = { "BasePart" },
Ancestors = { workspace },
Logging = true,
})
🧩 Function-based construction
local AnotherExample = Classy.new("KillPart", function(instance: Instance, janitor: Classy.Janitor)
return KillPartClass.new(instance, janitor)
end, {
ClassNames = { "BasePart" },
Ancestors = { workspace },
Logging = true,
})
▶️ Initialize
KillPartClassy:Init()
AnotherExample:Init()
🔌 Interact with applied objects
Once initialized, you can interact with applied objects directly. ObserveApplied covers both instances that are already applied and ones added later:
KillPartClassy:ObserveApplied(function(instance, applied)
applied:GetData():DoSomething()
end)
Prefer a raw signal? KillPartClassy.InstanceAdded fires only for instances applied from now on, and InstanceRevoked fires when they are removed.
[!TIP] Building something simple that doesn't need a class? Use the shorthand from A Taste. It's the same system with less boilerplate.
⚙️ Settings
Both Classy.new and Classy.newClass accept an optional Config table (ClassyConfig) as their last argument. Every option is optional:
| Option | Type | Default | Description |
|---|---|---|---|
ClassNames | { string } | {} | Instances must pass IsA for at least one listed class name. Empty means no restriction |
Ancestors | { Instance } | {} | Instances must be a descendant of at least one listed ancestor. Empty means no restriction |
Predicate | (Instance) -> boolean | nil | Custom check. Return false to skip an instance |
Logging | boolean | false | Prints when instances are applied, revoked, and cleaned up |
RemoveTagOnCleanup | boolean | true | Removes the tag from the instance when it is revoked (including when the Classy is destroyed) |
Nuke | boolean | true | When an applied object is destroyed, also clears the table your constructor returned and removes its metatable |
NamingConvention | { Init: string, Destroy: string } | { Init = "Init", Destroy = "Destroy" } | Method names Classy calls on your object for its lifecycle |
WaitForMetadata | boolean | false | Waits up to 7 seconds for an instance's metadata attribute before applying it |
Context | { any } | {} | Table handed to your constructor as its third argument |
AutoInit | boolean | true | Calls your object's Init method automatically after it is registered. When false, call it yourself. Initialized is then true as soon as the object is registered |
Classy.newClass("Door", DoorClass, {
ClassNames = { "Model" },
Ancestors = { workspace.Doors },
Predicate = function(instance)
return instance:GetAttribute("Enabled") ~= false
end,
NamingConvention = { Init = "Start", Destroy = "Cleanup" },
WaitForMetadata = true,
Logging = true,
}):Init()
[!NOTE] Lifecycle methods are automatic. After your object is constructed and registered, Classy calls its
Initmethod (if it has one), and callsDestroywhen the instance is revoked. Rename them withNamingConvention. Because the object is registered first,Initcan find its own object withgetComponentorGetApplied. See Lifecycle for the full order and what happens on failure.
[!NOTE] When checks run.
ClassNames,Ancestors, andPredicateare checked when an instance gets the tag (or joins the game with it) and again whenever an applied instance's ancestry changes. An instance that stops passing is revoked, and it is not re-applied automatically if it passes again later. Re-tag it or call:Applyyourself.
🔄 Lifecycle
Applying an instance (from its tag, :Apply, or applyComponents) runs these steps in order:
- Construct: your function, or your class's
new, runs. - Register: the object becomes available through
GetApplied,GetAll, andgetComponent. - Init: your
Initmethod runs (skipped ifAutoInit = falseor the method doesn't exist). - Initialized:
InitializedbecomestrueandInstanceAddedfires.
Your constructor and Init are allowed to yield. Classy handles the cases that creates:
| Situation | What happens |
|---|---|
:Apply called again while the constructor is running | The second call waits and returns the same object |
:Apply called again while Init is running | Returns the existing object. Check .Initialized if you need it finished |
| Revoked while the constructor is running | The apply is cancelled and the finished object is destroyed. :Apply returns nil |
Revoked while Init is running | Revoked normally. :Apply returns nil |
| Constructor errors | Classy cleans up and rethrows the error |
Init errors | The object is revoked (your Destroy runs, the tag is removed) and the error is rethrown with a traceback |
ObserveApplied only hands you initialized objects. Ones still in Init arrive through InstanceAdded when they finish.
[!NOTE] If the instance is revoked while
Initis running andInitthen errors, the error is not rethrown and:Applyreturnsnil. The error is only logged as a warning whenLoggingis on.
[!TIP] Since
Destroyalso runs after a failedInit, write it so it tolerates a half-built object.
📚 API
Constructors
| Function | Returns | Description |
|---|---|---|
Classy.new(Tag, Constructable, Config?) | Classy<T> | Function-based construction |
Classy.newClass(Tag, Constructable, Config?) | Classy<T> | Class-based construction |
Your function (or your class's new) is called with the same four arguments:
| # | Argument | Description |
|---|---|---|
| 1 | Instance | The tagged instance |
| 2 | Janitor | A Janitor that is cleaned up when the instance is revoked |
| 3 | Context | The Context table from your config |
| 4 | Metadata? | The metadata the instance was applied with, if any |
Classy.new("Door", function(instance, janitor, context, metadata)
-- ...
end)
function DoorClass.new(instance, janitor, context, metadata)
-- ...
end
Working with components
Every applied object is registered on its instance, so you can fetch and drive an instance's components from anywhere.
| Function | Returns | Description |
|---|---|---|
Classy.getComponent(Inst, Tag) | Applied<any>? | Get the component applied to Inst under Tag |
Classy.getComponents(Inst) | { [string]: Applied<any> }? | Get every component applied to Inst, keyed by tag |
Classy.callOnAllComponents(Inst, MethodName, ...) | Calls MethodName (with ...) on every component of Inst that has that method | |
Classy.applyComponents(Inst, Components) | Applies each listed component to Inst (and tags it), passing its metadata. Each tag must be registered globally | |
Classy.emit(Inst, TriggerName, Context?) | Fires a trigger; components that map TriggerName to a method get that method called with Context |
-- Grab a specific component and use it
local killPart = Classy.getComponent(workspace.Lava, "KillPart")
if killPart then
killPart:GetData():DoSomething()
end
-- Call a method on every component the instance has
Classy.callOnAllComponents(workspace.Lava, "DoSomething")
-- Attach components at runtime, with per-component metadata.
-- The tag must be registered globally first!
Classy.registerGlobal("KillPart", KillPartClassy)
Classy.applyComponents(workspace.Lava, {
KillPart = { Damage = 25 },
})
-- Fire a trigger
Classy.emit(workspace.Lava, "Activated", { Source = "Lever" })
[!NOTE]
getComponentandGetAppliedcan return an object whoseInitis still running. Check.Initializedif you need it finished.
Triggers
A component can declare triggers in its metadata under Triggers, mapping a trigger name to one of its methods. Classy.emit then calls that method with the context you pass:
Classy.registerGlobal("Door", DoorClassy)
Classy.applyComponents(door, {
Door = {
Triggers = { Activated = "Open" },
},
})
-- Calls door's Door component: Data:Open({ Source = "Lever" })
Classy.emit(door, "Activated", { Source = "Lever" })
Component aliases
Metadata can include UseComponent (or useComponent) naming another globally registered component. Classy builds a copy of that component's Classy for the new tag, so one constructor can power many differently-configured components:
Classy.registerGlobal("Inventory", InventoryClassy)
Classy.registerGlobal("Resource", ResourceClassy)
Classy.applyComponents(player, {
Inventory = { MaxItemAmount = 20 },
-- Built from the Resource component instead of an "Ammo" Classy
Ammo = {
UseComponent = "Resource",
StartingAmount = 0,
MaxCount = 0,
},
})
The alias reuses the base's constructor and config (ClassNames, AutoInit, Ancestors, Predicate, NamingConvention, Logging, RemoveTagOnCleanup, Nuke, WaitForMetadata) and gets its own tag. Its Context is a copy of the base's with tag set to the alias tag. If the base isn't registered, Classy warns and skips the component.
Metadata
Metadata passed to Apply or applyComponents is handed to your constructor as its fourth argument and saved on the instance as a JSON attribute named _<lowercase tag>_metadata (for example _door_metadata), so it must only contain JSON-serializable values (no functions or Instances). With WaitForMetadata = true, Classy waits up to 7 seconds for that attribute to exist before applying, which lets you tag an instance first and set its metadata attribute (as a JSON string) afterwards.
Global registry
| Function | Returns | Description |
|---|---|---|
Classy.registerGlobal(Tag, ClassyInstance) | Register a Classy object globally under Tag. Required for applyComponents and UseComponent | |
Classy.getGloballyRegistered(Tag) | Classy<any>? | Look up a globally registered Classy object |
Classy objects
What Classy.new and Classy.newClass return.
| Member | Returns | Description |
|---|---|---|
:Init() | Applies all currently tagged instances that pass the checks and starts listening for new and removed ones. Call it once | |
:CanBeApplied(Inst) | boolean | Whether Inst passes ClassNames, Ancestors, and Predicate |
:Apply(Inst, Metadata?) | Applied<T>? | Applies Inst, bypassing CanBeApplied. Returns the existing one if already applied, and waits if another call is mid-construction. Returns nil if the instance is revoked before it finishes. Errors from your constructor or Init are rethrown |
:Revoke(Inst) | Destroys the applied object, removes it from Classy, and removes the tag (unless RemoveTagOnCleanup = false). Cancels the apply instead if the constructor is still running | |
:ObserveApplied(Callback) | Connection | Runs Callback(Instance, Applied) for every existing initialized object and every future one |
.InstanceAdded | Signal<Instance, Applied<T>> | Fires once an instance is applied and its Init has finished |
.InstanceRevoked | Signal<Instance> | Fires after an instance is revoked. Can fire for an object that never fired InstanceAdded (revoked during, or after a failed, Init) |
:GetApplied(Inst) | Applied<T>? | Get the applied object for Inst, if any |
:GetAll() | { [Instance]: Applied<T> } | Every applied object |
:Destroy() | Revokes everything, cleans up its Janitor and signals, and makes the Classy unusable |
local applied = KillPartClassy:Apply(workspace.Lava)
if applied then
applied:GetData():DoSomething()
end
local connection = KillPartClassy:ObserveApplied(function(instance, applied)
applied:GetData():DoSomething()
end)
-- Later
connection:Disconnect()
[!WARNING] Revoking clears the
Appliedwrapper, andClassy:Destroy()clears the Classy, so don't use either afterwards. WithNuke = true(the default), revoking also clears your object's table and removes its metatable. SetNuke = falseif you need your object to stay intact after it's revoked. Don't callClassy:Destroy()while an instance is still inside its constructor.
Applied objects
The wrapper Classy keeps for each instance.
| Member | Description |
|---|---|
:GetData() | Returns what your constructor or class produced |
:Destroy() | Runs your Destroy method, cleans its Janitor, and clears the object. Called by Revoke, so prefer Classy:Revoke(Inst) over calling this yourself |
.Instance | The instance it's attached to |
.Classy | The Classy that owns it |
.Janitor | The Janitor handed to your constructor |
.Data | Same as :GetData() |
.Triggers | The trigger map from its metadata, if any |
.Initialized | true once Init has finished. Always true when AutoInit = false |
🤝 Contributing
Contributions, issues, and feature requests are welcome!
- Fork the repo
- Create your branch:
git checkout -b feature/cool-thing - Commit your changes:
git commit -m "Add cool thing" - Push and open a Pull Request
Check the issues page for things to work on.
📄 License
Released under the MIT License.
If Classy helps you, consider leaving a ⭐. It really helps!
Package Details
Install command (Click to copy)
Version
2.2.0
License
MIT
Safe for commercial use
Automated license review — not legal advice.
