Lesson 3.8: OOP Patterns
Learning Objectives
- Master the Factory pattern for flexible object creation
- Use the Prototype pattern for efficient object cloning
- Implement the Module pattern for organized code
- Apply the Singleton pattern for shared state
- Use the Observer pattern for reactive updates
- Choose the right pattern for your Rive use case
AE/JS Syntax Comparison
| Pattern | After Effects | JavaScript/TypeScript | Luau (Rive) |
|---|---|---|---|
| Factory | N/A | class Factory { static create(type) {...} } | Factory.create(config) function |
| Prototype | N/A | Object.create(proto) or structuredClone() | template:clone() method |
| Module | N/A | ES modules (export/import) | IIFE returning table (function() ... end)() |
| Singleton | N/A | static instance pattern | IIFE with captured state |
| Observer | N/A | EventEmitter or addEventListener | subscribe/publish pattern |
JavaScript/TypeScript: Has native support for modules, classes, and static members:
// ES Module
export class ShapeFactory {
static create(type: string): Shape {
switch(type) {
case 'circle': return new Circle();
case 'rect': return new Rectangle();
}
}
}
// Singleton
class Cache {
private static instance: Cache;
static getInstance(): Cache {
if (!this.instance) this.instance = new Cache();
return this.instance;
}
}
Luau: Uses functions and closures to implement patterns:
-- Factory
local ShapeFactory = {}
function ShapeFactory.create(config)
if config.type == "circle" then
return Circle.new(config.x, config.y, config.radius)
elseif config.type == "rect" then
return Rectangle.new(config.x, config.y, config.width, config.height)
end
end
-- Singleton (IIFE pattern)
local Cache = (function()
local data = {} -- Private state captured in closure
return {
get = function(key) return data[key] end,
set = function(key, value) data[key] = value end,
}
end)()
Luau uses closures and IIFEs (Immediately Invoked Function Expressions) to achieve what JavaScript does with class syntax.
Why Patterns Matter in Rive
Rive Node Scripts benefit from established OOP patterns:
- Factory: Create different types of drawables, particles, or UI elements
- Prototype: Clone complex objects without re-running expensive setup
- Module: Organize Util scripts with clean public/private separation
- Singleton: Manage shared resources like asset caches or event buses
- Observer: React to state changes for animations and UI updates
After Effects Comparison: In AE, you're limited to expressions with no real architectural patterns. Every expression is standalone. In Luau, patterns let you build sophisticated, maintainable systems with reusable components.
Each pattern solves specific problems you'll encounter in Rive development.
Practice Exercises
Exercise 1: Factory Pattern ⭐⭐
Premise
The factory centralizes object creation. With 3 configs (circle, rectangle, triangle), the factory creates 3 shapes. Adding new shape types only requires modifying the factory, not client code.
By the end of this exercise, you will be able to Complete the ShapeFactory.create method to produce different shape types from configuration objects.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_Exercise1FactoryPattern
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
export type ShapeType = {
shapeType: string,
x: number,
y: number,
draw: (ShapeType, Renderer) -> (),
}
-- Simple Circle class
local Circle = {}
Circle.__index = Circle
function Circle.new(x: number, y: number, radius: number): ShapeType
local self = setmetatable({}, Circle)
self.shapeType = "circle"
self.x = x
self.y = y
self.radius = radius
return (self :: any) :: ShapeType
end
function Circle:draw(renderer: Renderer)
print(`Drawing circle at ({self.x}, {self.y})`)
end
-- Simple Rectangle class
local Rectangle = {}
Rectangle.__index = Rectangle
function Rectangle.new(x: number, y: number, width: number, height: number): ShapeType
local self = setmetatable({}, Rectangle)
self.shapeType = "rectangle"
self.x = x
self.y = y
return (self :: any) :: ShapeType
end
function Rectangle:draw(renderer: Renderer)
print(`Drawing rectangle at ({self.x}, {self.y})`)
end
-- Simple Triangle class
local Triangle = {}
Triangle.__index = Triangle
function Triangle.new(x: number, y: number, size: number): ShapeType
local self = setmetatable({}, Triangle)
self.shapeType = "triangle"
self.x = x
self.y = y
return (self :: any) :: ShapeType
end
function Triangle:draw(renderer: Renderer)
print(`Drawing triangle at ({self.x}, {self.y})`)
end
-- THE FACTORY
local ShapeFactory = {}
export type ShapeConfig = {
type: string,
x: number,
y: number,
size: number?,
width: number?,
height: number?,
}
-- TODO: Complete the factory method
-- Check config.type and return the appropriate shape:
-- "circle" -> Circle.new(config.x, config.y, config.size or 20)
-- "rectangle" -> Rectangle.new(config.x, config.y, config.width or 40, config.height or 40)
-- "triangle" -> Triangle.new(config.x, config.y, config.size or 40)
-- Return nil for unknown types
function ShapeFactory.create(config: ShapeConfig): ShapeType?
-- Replace with implementation
return nil
end
export type FactoryDemo = {
shapes: {ShapeType},
}
function init(self: FactoryDemo): boolean
self.shapes = {}
print("Creating shapes from config...")
local configs: {ShapeConfig} = {
{ type = "circle", x = -60, y = 0, size = 25 },
{ type = "rectangle", x = 0, y = 0, width = 50, height = 30 },
{ type = "triangle", x = 60, y = 0, size = 40 },
}
for _, config in ipairs(configs) do
local shape = ShapeFactory.create(config)
if shape then
print(`Created {config.type} at ({config.x}, {config.y})`)
table.insert(self.shapes, shape)
end
end
print(`Total shapes created: {#self.shapes}`)
print(`ANSWER: {#self.shapes}`)
return true
end
function draw(self: FactoryDemo, renderer: Renderer)
end
return function(): Node<FactoryDemo>
return {
init = init,
draw = draw,
shapes = {},
}
end
Assignment
Complete these tasks:
- Complete the ShapeFactory.create method to produce different shape types from configuration objects.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Exercise 2: Prototype Pattern (Clone) ⭐⭐
Premise
The Prototype pattern avoids expensive initialization. Templates are created once (expensive), then spawned many times via cheap cloning. 5 sprites are spawned from the template without re-running the complex Path construction.
By the end of this exercise, you will be able to Complete the Sprite.clone method to efficiently copy objects without expensive re-initialization.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_Exercise2PrototypePatternClone
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
local Sprite = {}
Sprite.__index = Sprite
export type SpriteType = {
x: number,
y: number,
complexity: number,
path: Path,
paint: Paint,
}
-- EXPENSIVE constructor
function Sprite.new(complexity: number): SpriteType
local self = setmetatable({}, Sprite)
self.x = 0
self.y = 0
self.complexity = complexity
-- Build complex star shape (expensive!)
self.path = Path.new()
local points = complexity * 2
for i = 0, points - 1 do
local angle = (i / points) * math.pi * 2 - math.pi / 2
local radius = if i % 2 == 0 then 30 else 15
local px = math.cos(angle) * radius
local py = math.sin(angle) * radius
if i == 0 then
self.path:moveTo(Vector.xy(px, py))
else
self.path:lineTo(Vector.xy(px, py))
end
end
self.path:close()
self.paint = Paint.with({ style = "fill", color = Color.rgb(255, 200, 100) })
print(`Template created with complexity {complexity}`)
return (self :: any) :: SpriteType
end
-- TODO: Implement CHEAP clone method
-- 1. Create new object with setmetatable({}, Sprite)
-- 2. Copy x, y, complexity fields
-- 3. Create new path: copy.path = Path.new() and rebuild geometry
-- 4. Create new paint: copy.paint = Paint.with({ style = "fill", color = self.paint.color })
-- 5. Return (copy :: any) :: SpriteType
-- NOTE: Path has no clone() method - you must create a new Path and rebuild
function Sprite:clone(): SpriteType
-- Replace with implementation
return (self :: any) :: SpriteType
end
function Sprite:setPosition(x: number, y: number): SpriteType
self.x = x
self.y = y
return self
end
export type PrototypeDemo = {
sprites: {SpriteType},
}
function init(self: PrototypeDemo): boolean
self.sprites = {}
print("Creating template (expensive)...")
local template = Sprite.new(6)
print("Spawning 5 clones (cheap)...")
for i = 1, 5 do
local clone = template:clone()
local x = -80 + (i - 1) * 40
clone:setPosition(x, 0)
print(`Clone {i} spawned at ({x}, 0)`)
table.insert(self.sprites, clone)
end
print(`Total sprites: {#self.sprites}`)
print(`ANSWER: {#self.sprites}`)
return true
end
function draw(self: PrototypeDemo, renderer: Renderer)
end
return function(): Node<PrototypeDemo>
return {
init = init,
draw = draw,
sprites = {},
}
end
Assignment
Complete these tasks:
- Complete the Sprite.clone method to efficiently copy objects without expensive re-initialization.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Exercise 3: Module Pattern (IIFE) ⭐⭐
Premise
The Module pattern uses IIFEs to create private scope. The private 'interpolate' helper and constants are hidden. The public interface exposes only what's needed. The result 50 comes from lerp(0, 100, 0.5).
By the end of this exercise, you will be able to Complete the MathUtils module using the IIFE pattern with private helpers and public interface.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_Exercise3ModulePatternIife
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- TODO: Complete the MathUtils module using IIFE pattern
-- 1. Create private helper: local function interpolate(a, b, t) return a + (b - a) * t end
-- 2. Return public interface with:
-- - lerp(a, b, t): use interpolate helper
-- - clamp(v, min, max): return bounded value
-- - inverseLerp(a, b, v): return (v - a) / (b - a)
local MathUtils = (function()
-- Private helpers (hidden from outside)
-- Public interface
return {
lerp = function(a: number, b: number, t: number): number
return 0 -- Fix this
end,
clamp = function(value: number, min: number, max: number): number
return 0 -- Fix this
end,
inverseLerp = function(a: number, b: number, value: number): number
return 0 -- Fix this
end,
}
end)()
export type ModuleDemo = {}
function init(self: ModuleDemo): boolean
print("Testing MathUtils module...")
local lerpResult = MathUtils.lerp(0, 100, 0.5)
print(`lerp(0, 100, 0.5) = {lerpResult}`)
local clampResult = MathUtils.clamp(150, 0, 100)
print(`clamp(150, 0, 100) = {clampResult}`)
local invResult = MathUtils.inverseLerp(0, 100, 25)
print(`inverseLerp(0, 100, 25) = {invResult}`)
print("All functions work!")
print(`ANSWER: {lerpResult}`)
return true
end
function draw(self: ModuleDemo, renderer: Renderer)
end
return function(): Node<ModuleDemo>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Complete the MathUtils module using the IIFE pattern with private helpers and public interface.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Exercise 4: Singleton Pattern ⭐⭐
Premise
The Singleton ensures only one cache instance exists. When requesting 'player_path' twice, the first call is a miss (runs loader), the second is a hit (returns cached). With 1 miss and 1 hit, there's 1 item in cache.
By the end of this exercise, you will be able to Complete the AssetCache singleton with get() that caches expensive loader results.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_Exercise4SingletonPattern
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- TODO: Complete the AssetCache singleton
-- Use IIFE pattern with private 'cache' table and hit/miss counters
local AssetCache = (function()
local cache: { [string]: any } = {}
local hitCount = 0
local missCount = 0
return {
-- TODO 1: Implement get function
-- If cache[key] exists: increment hitCount, return cached value
-- Else: increment missCount, call loader(), store in cache, return result
get = function(key: string, loader: () -> any): any
-- Replace with implementation
return loader()
end,
-- TODO 2: Implement getStats function
-- Return { hits = hitCount, misses = missCount, size = (count of cache entries) }
getStats = function(): { hits: number, misses: number, size: number }
return { hits = 0, misses = 0, size = 0 } -- Fix this
end,
}
end)()
export type SingletonDemo = {}
function init(self: SingletonDemo): boolean
print("Testing AssetCache singleton...")
print("First request (should be miss)...")
local path1 = AssetCache.get("player_path", function()
print("Creating expensive path...")
return Path.new()
end)
print("Second request (should be hit)...")
local path2 = AssetCache.get("player_path", function()
print("Creating expensive path...") -- Should NOT print
return Path.new()
end)
local stats = AssetCache.getStats()
print(`Cache stats: hits={stats.hits}, misses={stats.misses}, size={stats.size}`)
print(`Both paths are same reference: {path1 == path2}`)
print(`ANSWER: {stats.size}`)
return true
end
function draw(self: SingletonDemo, renderer: Renderer)
end
return function(): Node<SingletonDemo>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Complete the AssetCache singleton with get() that caches expensive loader results.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Exercise 5: Observer Pattern ⭐⭐
Premise
The Observer pattern provides reactive updates. When the observable value changes from 100 to 50, all subscribed observers are automatically notified with both the new and old values. The UI updates without manual intervention.
By the end of this exercise, you will be able to Complete the createObservable factory with get(), set(), and observe() methods for reactive state management.
Use Case
This pattern shows up whenever you build behavior in Rive scripts.
Example scenarios:
- Debugging script behavior
- Driving animation logic
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_Exercise5ObserverPattern
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- TODO: Complete the createObservable factory
-- Creates a reactive value that notifies observers when changed
local function createObservable(initial: number)
local value = initial
local observers: { (number, number) -> () } = {}
return {
-- TODO 1: Implement get() - return current value
get = function(): number
return 0 -- Fix this
end,
-- TODO 2: Implement set(newValue)
-- Only if newValue ~= value:
-- Save oldValue = value
-- Update value = newValue
-- Call each observer with (newValue, oldValue)
set = function(newValue: number)
-- Replace with implementation
end,
-- TODO 3: Implement observe(callback)
-- Add callback to observers array
-- Return unsubscribe function that removes it
observe = function(callback: (number, number) -> ()): () -> ()
-- Replace with implementation
return function() end
end,
}
end
export type ObserverDemo = {}
function init(self: ObserverDemo): boolean
print("Creating observable with initial value 100")
local health = createObservable(100)
print("Subscribing observer...")
local unsubscribe = health.observe(function(newVal, oldVal)
print(`Observer called: {oldVal} -> {newVal}`)
end)
print("Setting value to 75...")
health.set(75)
print("Setting value to 50...")
health.set(50)
print("Setting same value (50) - no notification")
health.set(50) -- Should NOT trigger observer
print(`Final value: {health.get()}`)
print(`ANSWER: {health.get()}`)
return true
end
function draw(self: ObserverDemo, renderer: Renderer)
end
return function(): Node<ObserverDemo>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Complete the createObservable factory with get(), set(), and observe() methods for reactive state management.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Pattern Comparison
| Pattern | Purpose | Memory | Complexity | Use Case |
|---|---|---|---|---|
| Factory | Create varied objects | Low | Low | Shape types, particle variations |
| Prototype | Clone complex objects | Medium | Low | Spawning configured sprites |
| Module | Organize code | Low | Medium | Util scripts, libraries |
| Singleton | Shared instance | Low | Low | Caches, event buses |
| Observer | React to changes | Medium | Medium | UI updates, state sync |
Choosing the Right Pattern
Factory when:
- You have multiple object types with similar interfaces
- Object creation logic is complex or conditional
- You want to add new types without changing client code
Prototype when:
- Object initialization is expensive
- You need many similar copies
- You want to avoid repeated configuration
Module when:
- You're building reusable Util scripts
- You need clear public/private boundaries
- You want to organize related functionality
Singleton when:
- You need exactly one instance (cache, event bus)
- Global access is required
- State must be shared across components
Observer when:
- Multiple components need to react to state changes
- You want decoupled communication
- UI should update automatically with data
Common Mistakes
Mistake 1: Over-Engineering
-- OVERKILL: Factory for one shape type
local ShapeFactory = {}
function ShapeFactory.create(config)
return Circle.new(config.x, config.y, config.radius)
end
-- SIMPLER: Just use Circle.new() directly
local circle = Circle.new(x, y, radius)
Mistake 2: Wrong Pattern for the Job
-- WRONG: Singleton for something that should have multiple instances
local PlayerSingleton = (function()
local name = ""
local score = 0
return { getName = function() return name end }
end)()
-- What if you need 2 players?
-- CORRECT: Use a class or factory instead
local Player = {}
function Player.new(name: string)
return { name = name, score = 0 }
end
Mistake 3: Observer Memory Leaks
-- WRONG: Never unsubscribe
function init(self)
healthObservable.observe(function(h)
self.display:update(h)
end)
-- If this node is destroyed, the callback still exists!
end
-- CORRECT: Store and call unsubscribe
function init(self)
self.unsubscribe = healthObservable.observe(function(h)
self.display:update(h)
end)
end
-- Clean up when done
function cleanup(self)
if self.unsubscribe then
self.unsubscribe()
end
end
Mistake 4: Prototype Without Clone
-- WRONG: Modifying shared reference
local sprite = SpriteRegistry.spawn("enemy")
sprite.path:reset() -- This modifies the TEMPLATE's path!
-- CORRECT: Clone returns a copy with its own path
-- (The clone() method should clone the path too)
Knowledge Check
Self-Assessment Checklist
- I can implement and use the Factory pattern
- I understand when Prototype is better than Factory
- I can create well-organized Module patterns using IIFEs
- I know when Singleton is appropriate
- I can implement Observer for decoupled updates
- I understand how to properly unsubscribe from observers
- I can choose the right pattern for my use case
Module 3 Complete!
You've finished the OOP module. You now understand how to build reusable classes and apply professional design patterns for Rive Node scripts.
Key takeaways:
- Use metatables and
__indexfor prototype-based OOP - The double-cast pattern
(self :: any) :: Typesatisfies strict mode - Intersection types (
Parent & { ... }) enable typed inheritance - Keep hierarchies shallow and focused on shared drawing behavior
- Use IIFEs
(function() ... end)()for modules and singletons - Observer pattern enables reactive, decoupled UI updates
- Choose patterns based on the problem, not complexity
Next Steps
- Continue to Rive Environment
- Need a refresher? Review Quick Reference