Skip to main content

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 self handling
  • Build reusable simulation and drawing classes

AE/JS Syntax Comparison

ConceptAfter EffectsJavaScript/TypeScriptLuau (Rive)
Class definitionN/Aclass MyClass {}local MyClass = {}; MyClass.__index = MyClass
ConstructorN/Aconstructor(x) { this.x = x; }function MyClass.new(x) ... end
Type annotationN/Aclass MyClass { x: number; }export type MyClass = { x: number }
Create instanceN/Anew MyClass(10)MyClass.new(10)
Method definitionN/Amethod() { ... }function MyClass:method() ... end
Access this/selfthisthis.xself.x
The Double-Cast Pattern

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:

  1. Works with --!strict type checking
  2. Has a clear constructor (new())
  3. Defines a proper type for instances
  4. 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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_SpringStep
  2. 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:

  1. Call spring:step() one time
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_ClassInstance
  2. 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:

  1. Call counter:increment() twice
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise3_SpriteClass
  2. 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:

  1. Call sprite:move(3, 4)
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise4_InstanceFormatter
  2. 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:

  1. Return "Label:" .. self.text
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise5_MultipleConstructors
  2. 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:

  1. In fromName, call Marker.new(name, 1)
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise6_ClassVsTable
  2. 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:

  1. Call timer:tick() once
  2. 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

Verify Your Answer

Checklist

  • Config is a plain table
  • Timer uses a class method
  • Output matches the expected line

Knowledge Check

Q:Why do we need MyClass.__index = MyClass?
Q:What does the double cast (self :: any) :: Type accomplish?
Q:Why are methods defined on the class table, not in the type definition?
Q:When should you use a plain table instead of a class?

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 __index self-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