Lesson 3.2: Building Classes
Learning Objectives
- Create proper class constructors using metatables
- Type your classes correctly with
--!strict - Use the double-cast pattern for type safety
- Implement methods with proper
selfhandling - Build reusable simulation and drawing classes
AE/JS Syntax Comparison
| Concept | After Effects | JavaScript/TypeScript | Luau (Rive) |
|---|---|---|---|
| Class definition | N/A | class MyClass {} | local MyClass = {}; MyClass.__index = MyClass |
| Constructor | N/A | constructor(x) { this.x = x; } | function MyClass.new(x) ... end |
| Type annotation | N/A | class MyClass { x: number; } | export type MyClass = { x: number } |
| Create instance | N/A | new MyClass(10) | MyClass.new(10) |
| Method definition | N/A | method() { ... } | function MyClass:method() ... end |
Access this/self | this | this.x | self.x |
In Luau's strict mode, the double-cast pattern helps the type checker understand metatable-based objects.
TypeScript infers constructor return types automatically.
Luau benefits from explicit type assertions: (self :: any) :: MyType.
This pattern is recommended for better type inference, though classes work at runtime without it. Think of it as telling Luau: "Trust me, this table is the shape I defined."
Rive Context
Rive does not have built-in classes, so you build your own with metatables. In real Rive projects, class-like objects belong in Util scripts and are instantiated by Node scripts.
Use classes when you need:
- Reusable simulation objects (springs, timers, particles)
- Consistent drawing logic across multiple elements
- Clean separation between math/logic and rendering
The Strict Class Pattern (Rive-Friendly)
In Lesson 3.1, we learned how __index enables shared behavior. Now we'll formalize this into a reusable class pattern that:
- Works with
--!stricttype checking - Has a clear constructor (
new()) - Defines a proper type for instances
- Supports methods with the colon syntax
--!strict
local MyClass = {}
MyClass.__index = MyClass
export type MyClass = {
value: number,
step: (MyClass, number) -> (),
}
function MyClass.new(value: number): MyClass
local self = setmetatable({}, MyClass)
self.value = value
return (self :: any) :: MyClass
end
function MyClass:step(dt: number)
self.value += dt
end
return MyClass
Key detail: In --!strict, the double cast (self :: any) :: MyClass helps the type checker understand the metatable instance. While not strictly required at runtime, it improves type inference for methods.
Exercises
Exercise 1: Spring Step ⭐⭐
Premise
A class pattern groups state and behavior. A simple spring class shows how methods update internal state.
By the end of this exercise, you will implement a class method that updates state.
Use Case
Springs are common for easing UI or motion values. A class keeps the parameters and step logic together.
Example scenarios:
- Smooth UI transitions
- Animated easing helpers
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_SpringStep
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise1 = {}
local Spring = {}
Spring.__index = Spring
function Spring.new(value: number, target: number, stiffness: number)
return setmetatable({ value = value, target = target, stiffness = stiffness }, Spring)
end
function Spring:step(): number
self.value += (self.target - self.value) * self.stiffness
return self.value
end
function init(self: Exercise1): boolean
local spring = Spring.new(0, 10, 0.5)
local result = 0
-- TODO: Call spring:step() once and store the result
print(`ANSWER: {result}`)
return true
end
function draw(self: Exercise1, renderer: Renderer)
end
return function(): Node<Exercise1>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Call spring:step() one time
- Store the returned value in result
Expected Output
Your console output should display the spring's value after one step toward the target.
Verify Your Answer
Checklist
- Method updates the spring value
- Result equals 5
- Output matches the expected line
Exercise 2: Class Instance ⭐
Premise
A class-style table provides a consistent constructor and shared methods. Each instance keeps its own state.
By the end of this exercise, you will create and use a class instance.
Use Case
You need independent counters for different UI elements. Each instance should increment separately.
Example scenarios:
- Independent timers
- Multiple counters
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_ClassInstance
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise2 = {}
local Counter = {}
Counter.__index = Counter
function Counter.new(start: number)
return setmetatable({ value = start }, Counter)
end
function Counter:increment(): number
self.value += 1
return self.value
end
function init(self: Exercise2): boolean
local counter = Counter.new(10)
local value = 0
-- TODO: Call increment twice and store the final value
print(`ANSWER: {value}`)
return true
end
function draw(self: Exercise2, renderer: Renderer)
end
return function(): Node<Exercise2>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Call counter:increment() twice
- Store the final value in value
Expected Output
Your console output should display the counter's value after incrementing twice from the starting value.
Verify Your Answer
Checklist
- Counter instance keeps its own state
- Value equals 12
- Output matches the expected line
Exercise 3: Sprite Class ⭐
Premise
Classes are useful when objects have both data and behavior. A sprite class keeps position updates in one place.
By the end of this exercise, you will update instance state via a class method.
Use Case
You track a sprite's position in a Node Script. Methods update x and y together to avoid duplicated math.
Example scenarios:
- Moving UI elements
- Position tracking
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_SpriteClass
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise3 = {}
local Sprite = {}
Sprite.__index = Sprite
function Sprite.new(name: string, x: number, y: number)
return setmetatable({ name = name, x = x, y = y }, Sprite)
end
function Sprite:move(dx: number, dy: number)
self.x += dx
self.y += dy
end
function Sprite:posSum(): number
return self.x + self.y
end
function init(self: Exercise3): boolean
local sprite = Sprite.new("Orb", 2, 3)
-- TODO: Move by dx=3, dy=4
print(`ANSWER: name={sprite.name},pos={sprite:posSum()}`)
return true
end
function draw(self: Exercise3, renderer: Renderer)
end
return function(): Node<Exercise3>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Call sprite:move(3, 4)
- Keep the name unchanged
Expected Output
Your console output should display the sprite's name and the sum of x and y after moving.
Verify Your Answer
Checklist
- Sprite position updates correctly
- Position sum equals 12
- Output matches the expected line
Exercise 4: Instance Formatter ⭐⭐
Premise
Methods often format data for display. Keeping formatting logic on the class keeps usage consistent.
By the end of this exercise, you will implement an instance method that returns formatted text.
Use Case
You want consistent labels for debugging or UI readouts. The class method ensures every instance formats the same way.
Example scenarios:
- Debug labels
- UI formatting helpers
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_InstanceFormatter
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise4 = {}
local Label = {}
Label.__index = Label
function Label.new(text: string)
return setmetatable({ text = text }, Label)
end
function Label:format(): string
-- TODO: Return "Label:<text>"
return ""
end
function init(self: Exercise4): boolean
local label = Label.new("Play")
print(`ANSWER: {label:format()}`)
return true
end
function draw(self: Exercise4, renderer: Renderer)
end
return function(): Node<Exercise4>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Return "Label:" .. self.text
- Keep the method on the class
Expected Output
Your console output should display the formatted string from the label's format method.
Verify Your Answer
Checklist
- Method formats correctly
- Output matches the expected line
- Formatting uses self.text
Exercise 5: Multiple Constructors ⭐⭐
Premise
Factory functions are common in Lua classes. Multiple constructors let you create the same object from different inputs.
By the end of this exercise, you will implement two constructor helpers.
Use Case
You might construct a marker by name or by id. Both should produce a valid instance with consistent fields.
Example scenarios:
- Creating instances from different data sources
- Alternate constructors
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_MultipleConstructors
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise5 = {}
local Marker = {}
Marker.__index = Marker
function Marker.new(name: string, id: number)
return setmetatable({ name = name, id = id }, Marker)
end
function Marker.fromName(name: string)
-- TODO: Use name and id 1
return Marker.new("", 0)
end
function Marker.fromId(id: number)
-- TODO: Use name "M<id>" and the provided id
return Marker.new("", 0)
end
function init(self: Exercise5): boolean
local byName = Marker.fromName("Pulse")
local byId = Marker.fromId(7)
print(`ANSWER: name={byName.name},id={byName.id},fromId={byId.name}`)
return true
end
function draw(self: Exercise5, renderer: Renderer)
end
return function(): Node<Exercise5>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- In fromName, call Marker.new(name, 1)
- In fromId, call Marker.new(
M{id}, id)
Expected Output
Your console output should display the marker fields from both constructor helpers showing name, id, and the formatted fromId name.
Verify Your Answer
Checklist
- Both constructors call Marker.new
- Fields match expected values
- Output matches the expected line
Exercise 6: Class vs Plain Table ⭐⭐
Premise
Plain tables are great for static data. Classes are better when you need shared behavior.
By the end of this exercise, you will use a plain table and a class instance together.
Use Case
A configuration object holds static values, while a timer class updates over time. Each tool fits its purpose.
Example scenarios:
- Config + runtime state
- Static settings with dynamic behavior
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_ClassVsTable
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise6 = {}
local Timer = {}
Timer.__index = Timer
function Timer.new(value: number)
return setmetatable({ value = value }, Timer)
end
function Timer:tick(): number
self.value += 0.5
return self.value
end
function init(self: Exercise6): boolean
local config = {
mode = "loop",
}
local timer = Timer.new(1.0)
local nextValue = 0
-- TODO: Call timer:tick() and store the result
print(`ANSWER: mode={config.mode},time={nextValue}`)
return true
end
function draw(self: Exercise6, renderer: Renderer)
end
return function(): Node<Exercise6>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Call timer:tick() once
- Store the result in nextValue
Expected Output
Your console output should display the config's mode (plain table) and the timer's value after ticking (class method).
Verify Your Answer
Checklist
- Config is a plain table
- Timer uses a class method
- Output matches the expected line
Knowledge Check
Common Mistakes
1. Forgetting __index Setup
-- WRONG: No __index, methods won't be found
local MyClass = {}
function MyClass.new()
return setmetatable({}, MyClass) -- Missing __index!
end
function MyClass:greet()
print("Hello")
end
local obj = MyClass.new()
obj:greet() -- ERROR: attempt to call nil value
-- CORRECT: Set __index
local MyClass = {}
MyClass.__index = MyClass -- Add this line!
2. Putting Methods in the Type Definition
-- WRONG: Type includes method signatures unnecessarily
export type MyClass = {
value: number,
increment: (MyClass) -> (), -- Optional, but not required
}
-- The type describes the shape. Methods are found via __index.
-- You CAN include method signatures for documentation, but
-- the actual functions live on the class table.
3. Using Dot Instead of Colon for Method Calls
-- WRONG: Dot doesn't pass self
obj.method() -- self is nil!
-- CORRECT: Colon passes obj as self
obj:method() -- self is obj
4. Double Cast for Better Type Inference
-- Works at runtime, but may give type warnings
function MyClass.new(): MyClass
local self = setmetatable({}, MyClass)
return self -- May show type warning in strict mode
end
-- RECOMMENDED: Double cast for cleaner type checking
function MyClass.new(): MyClass
local self = setmetatable({}, MyClass)
return (self :: any) :: MyClass
end
Self-Assessment Checklist
- I can create a class with
__indexself-reference - I can define a proper instance type
- I understand and can use the double-cast pattern
- I can implement methods using colon syntax
- I can integrate classes with Rive's drawing system
- I can create multiple factory functions
- I know when to use classes vs. plain tables
Next Steps
- Continue to self and Methods
- Need a refresher? Review Quick Reference