Skip to main content

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

PatternAfter EffectsJavaScript/TypeScriptLuau (Rive)
FactoryN/Aclass Factory { static create(type) {...} }Factory.create(config) function
PrototypeN/AObject.create(proto) or structuredClone()template:clone() method
ModuleN/AES modules (export/import)IIFE returning table (function() ... end)()
SingletonN/Astatic instance patternIIFE with captured state
ObserverN/AEventEmitter or addEventListenersubscribe/publish pattern
Critical Difference from JavaScript

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_Exercise1FactoryPattern
  2. Attach and run:

    • Attach to any shape and press Play
  3. 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:

  1. Complete the ShapeFactory.create method to produce different shape types from configuration objects.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is 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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_Exercise2PrototypePatternClone
  2. Attach and run:

    • Attach to any shape and press Play
  3. 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:

  1. Complete the Sprite.clone method to efficiently copy objects without expensive re-initialization.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is 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).

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise3_Exercise3ModulePatternIife
  2. Attach and run:

    • Attach to any shape and press Play
  3. 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:

  1. Complete the MathUtils module using the IIFE pattern with private helpers and public interface.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is 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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise4_Exercise4SingletonPattern
  2. Attach and run:

    • Attach to any shape and press Play
  3. 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:

  1. Complete the AssetCache singleton with get() that caches expensive loader results.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is 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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise5_Exercise5ObserverPattern
  2. Attach and run:

    • Attach to any shape and press Play
  3. 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:

  1. Complete the createObservable factory with get(), set(), and observe() methods for reactive state management.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Pattern Comparison

PatternPurposeMemoryComplexityUse Case
FactoryCreate varied objectsLowLowShape types, particle variations
PrototypeClone complex objectsMediumLowSpawning configured sprites
ModuleOrganize codeLowMediumUtil scripts, libraries
SingletonShared instanceLowLowCaches, event buses
ObserverReact to changesMediumMediumUI 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

Q:What's the main benefit of the Factory pattern?
Q:When should you use the Prototype pattern instead of just calling new()?
Q:What makes a Singleton different from a regular module?
Q:What problem does the Observer pattern solve?

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 __index for prototype-based OOP
  • The double-cast pattern (self :: any) :: Type satisfies 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