Skip to content

Repository files navigation

Classy

A lifecycle manager for tagged instances in Luau

Make composition and OOP architecture feel good in Roblox.

Wally License: MIT Luau

Version Stars Issues Last commit PRs welcome

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

Package Purpose
Janitor Cleanup of connections and objects
Signal Events like InstanceAdded

🧪 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 Init method (if it has one), and calls Destroy when the instance is revoked. Rename them with NamingConvention. Because the object is registered first, Init can find its own object with getComponent or GetApplied. See Lifecycle for the full order and what happens on failure.

Note

When checks run. ClassNames, Ancestors, and Predicate are 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 :Apply yourself.

🔄 Lifecycle

Applying an instance (from its tag, :Apply, or applyComponents) runs these steps in order:

  1. Construct: your function, or your class's new, runs.
  2. Register: the object becomes available through GetApplied, GetAll, and getComponent.
  3. Init: your Init method runs (skipped if AutoInit = false or the method doesn't exist).
  4. Initialized: Initialized becomes true and InstanceAdded fires.

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 Init is running and Init then errors, the error is not rethrown and :Apply returns nil. The error is only logged as a warning when Logging is on.

Tip

Since Destroy also runs after a failed Init, 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

getComponent and GetApplied can return an object whose Init is still running. Check .Initialized if 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 Applied wrapper, and Classy:Destroy() clears the Classy, so don't use either afterwards. With Nuke = true (the default), revoking also clears your object's table and removes its metatable. Set Nuke = false if you need your object to stay intact after it's revoked. Don't call Classy: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!

  1. Fork the repo
  2. Create your branch: git checkout -b feature/cool-thing
  3. Commit your changes: git commit -m "Add cool thing"
  4. 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!

Releases

Packages

Contributors

Languages