Prototype-Based OOP
Learning Objectives
- Understand the difference between class-based and prototype-based OOP
- Learn how Lua/Luau uses prototypes instead of classes
- See how Rive leverages metatables for prototype chains
- Build reusable object patterns for Rive Node Scripts
AE/JS Syntax Comparison
| Concept | After Effects | JavaScript | Luau (Rive) |
|---|---|---|---|
| Create class | N/A | class Dog {} | local Dog = {} + metatable |
| Inheritance | N/A | class Poodle extends Dog {} | setmetatable(Poodle, {__index = Dog}) |
| Create instance | N/A | new Dog() | setmetatable({}, {__index = Dog}) |
| Method lookup | N/A | Automatic via prototype chain | Via __index metamethod |
this/self | this | this | self (explicit or via :) |
After Effects expressions don't have OOP at all—they're purely functional.
JavaScript has the class keyword that hides prototype mechanics.
Luau has NO classes—just tables that delegate to other tables via __index.
This isn't a limitation—it's more flexible. You can modify "classes" at runtime, mix and match behaviors, and create inheritance chains without any keywords.
Rive Context
Rive scripts don't have classes. Instead, you use prototype-based patterns where objects inherit behavior directly from other objects (prototypes) via metatables. This is perfect for Rive because:
- Lightweight: No class boilerplate, just tables and metatables
- Flexible: Prototypes can be modified at runtime
- Memory efficient: Methods are shared, not duplicated per instance
In Rive, you typically:
- Define a prototype table with shared methods
- Create instances that delegate to the prototype via
__index - Use these instances in
advanceanddrawfor reusable behavior
Class-Based vs Prototype-Based
Most popular languages (Java, C#, Python) use class-based OOP:
- Classes are blueprints
- Objects are instances of classes
- Inheritance happens between classes
Lua uses prototype-based OOP:
- No classes - just objects (tables)
- Objects inherit directly from other objects
- Inheritance happens via the
__indexmetamethod
Analogy: Class-based is like following a recipe to bake a cake. Prototype-based is like saying "make something like this other cake" and allowing modifications.
Exercises
Exercise 1: Prototype Instance ⭐
Premise
Prototypes let you define shared behavior once, then reuse it across many instances. This is the foundation of OOP in Luau.
By the end of this exercise, you will create an instance that delegates to a prototype.
Use Case
You want multiple Rive elements to share the same behavior without duplicating functions. A prototype gives every instance the same methods.
Example scenarios:
- Multiple UI elements with shared behaviors
- Reusable node helpers
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_PrototypeInstance
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise1 = {}
local Widget = {}
Widget.__index = Widget
function Widget.new(name: string)
return setmetatable({ name = name }, Widget)
end
function Widget:describe(): string
return self.name
end
function init(self: Exercise1): boolean
local widget = Widget.new("")
-- TODO: Set the widget name to "Panel"
local label = widget:describe()
print(`ANSWER: {label}`)
return true
end
function draw(self: Exercise1, renderer: Renderer)
end
return function(): Node<Exercise1>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Create the widget with the name "Panel"
- Keep the prototype and metatable setup unchanged
Expected Output
Your console output should display the widget name returned by the prototype's describe method.
Verify Your Answer
Checklist
- Widget uses the prototype
- Name is set to Panel
- Output matches the expected line
Exercise 2: Prototype Counter ⭐
Premise
Prototypes keep state and behavior together. A counter object demonstrates how methods update instance state.
By the end of this exercise, you will create multiple instances that share a method.
Use Case
You track independent counters for different UI components. Each counter has its own state but the same increment logic.
Example scenarios:
- Independent timers
- Multiple progress trackers
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_PrototypeCounter
- 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 a = Counter.new(0)
local b = Counter.new(5)
local aValue = 0
local bValue = 0
-- TODO: Increment a twice and store the final value in aValue
-- TODO: Increment b once and store the value in bValue
print(`ANSWER: a={aValue},b={bValue}`)
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 a:increment() twice and store the final result
- Call b:increment() once and store the result
Expected Output
Your console output should display the final values of both counters after incrementing (a twice, b once).
Verify Your Answer
Checklist
- Two instances are created
- a increments to 2, b increments to 6
- Output matches the expected line
Exercise 3: Prototype Inheritance ⭐⭐
Premise
Prototype chains enable inheritance. A derived table can reuse behavior from a base table through __index delegation.
By the end of this exercise, you will build a derived prototype that inherits behavior.
Use Case
You define a base entity with shared logic, then create specialized versions like enemies or UI widgets.
Example scenarios:
- Enemy types sharing base behavior
- Specialized components derived from a base
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_PrototypeInheritance
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise3 = {}
local Entity = {}
Entity.__index = Entity
function Entity.new(kind: string)
return setmetatable({ kind = kind }, Entity)
end
function Entity:describe(): string
return `Kind={self.kind}`
end
local Enemy = {}
Enemy.__index = Enemy
setmetatable(Enemy, { __index = Entity })
function Enemy.new(kind: string, level: number)
local base = Entity.new(kind)
base.level = level
return setmetatable(base, Enemy)
end
function Enemy:tag(): string
return `Enemy Lv.{self.level} {self.kind}`
end
function init(self: Exercise3): boolean
local enemy = Enemy.new("", 0)
-- TODO: Set kind to "Crawler" and level to 3
local label = enemy:tag()
print(`ANSWER: {label}`)
return true
end
function draw(self: Exercise3, renderer: Renderer)
end
return function(): Node<Exercise3>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Create the enemy with kind "Crawler" and level 3
- Keep the inheritance chain intact
Expected Output
Your console output should display the enemy's tag string showing level and kind from the inherited prototype chain.
Verify Your Answer
Checklist
- Enemy inherits from Entity
- Label shows level and kind
- Output matches the expected line
Exercise 4: Dynamic Prototype Update ⭐⭐
Premise
Prototypes are dynamic: you can add or change behavior at runtime. Instances automatically see updated methods.
By the end of this exercise, you will add a method to a prototype after creating an instance.
Use Case
You need to add a helper method during development without rebuilding every instance. Updating the prototype updates all instances.
Example scenarios:
- Debug methods added late
- Live updates to shared behavior
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_DynamicPrototype
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise4 = {}
local Pulse = {}
Pulse.__index = Pulse
function Pulse.new(value: number)
return setmetatable({ value = value }, Pulse)
end
function Pulse:double(): number
-- TODO: Return self.value * 2
return 0
end
function init(self: Exercise4): boolean
local pulse = Pulse.new(4)
local doubled = pulse:double()
print(`ANSWER: {doubled}`)
return true
end
function draw(self: Exercise4, renderer: Renderer)
end
return function(): Node<Exercise4>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Implement the double method
- Return value * 2 from the method
Expected Output
Your console output should display the doubled value returned by the prototype method.
Verify Your Answer
Checklist
- Method is added to the prototype
- doubled equals 8
- Output matches the expected line
Knowledge Check
Common Mistakes
1. Forgetting to Set Up the __index Chain
-- WRONG: No inheritance connection
local Parent = { greet = function() print("Hello") end }
local Child = {} -- Child can't access Parent.greet!
-- CORRECT: Link via metatable
local Child = setmetatable({}, { __index = Parent })
-- Now Child:greet() works
2. Confusing Prototype Table with Instance Table
-- WRONG: Putting instance data on the prototype
local Proto = { x = 0 } -- All instances share this x!
Proto.__index = Proto
local a = setmetatable({}, Proto)
local b = setmetatable({}, Proto)
a.x = 10
print(b.x) -- Still 0! Because b.x doesn't exist, it reads Proto.x
-- CORRECT: Each instance gets its own data
function Proto.new()
local self = setmetatable({}, Proto)
self.x = 0 -- Instance's own x
return self
end
3. Expecting Class Syntax
-- WRONG: Lua has no 'class' keyword
class MyClass { -- Syntax error!
constructor() {}
}
-- CORRECT: Use tables and metatables
local MyClass = {}
MyClass.__index = MyClass
function MyClass.new()
return setmetatable({}, MyClass)
end
4. Forgetting to Call Parent Constructor
-- WRONG: Child doesn't initialize parent properties
local Parent = {}
Parent.__index = Parent
function Parent.new()
local self = setmetatable({}, Parent)
self.name = "Unknown"
return self
end
local Child = setmetatable({}, { __index = Parent })
Child.__index = Child
function Child.new()
local self = setmetatable({}, Child)
-- self.name is nil! We forgot to initialize it
return self
end
-- CORRECT: Initialize parent properties manually
function Child.new()
local self = setmetatable({}, Child)
self.name = "Unknown" -- Copy from parent pattern
-- Or call parent constructor logic
return self
end
Self-Assessment Checklist
- I can explain the difference between class-based and prototype-based OOP
- I understand how
__indexcreates the prototype chain - I can create a prototype and instantiate objects from it
- I know why prototypes are memory-efficient
- I understand that modifying prototypes affects all instances
- I can apply prototype patterns in Rive Node Scripts
Next Steps
- Continue to Metatables & __index
- Need a refresher? Review Quick Reference