Skip to main content

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

ConceptAfter EffectsJavaScriptLuau (Rive)
Create classN/Aclass Dog {}local Dog = {} + metatable
InheritanceN/Aclass Poodle extends Dog {}setmetatable(Poodle, {__index = Dog})
Create instanceN/Anew Dog()setmetatable({}, {__index = Dog})
Method lookupN/AAutomatic via prototype chainVia __index metamethod
this/selfthisthisself (explicit or via :)
Critical Difference: No Built-in Classes

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:

  1. Define a prototype table with shared methods
  2. Create instances that delegate to the prototype via __index
  3. Use these instances in advance and draw for 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 __index metamethod

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.

Goal

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:

  1. Create the script:

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

  1. Create the widget with the name "Panel"
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Call a:increment() twice and store the final result
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Create the enemy with kind "Crawler" and level 3
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Implement the double method
  2. Return value * 2 from the method

Expected Output

Your console output should display the doubled value returned by the prototype method.


Verify Your Answer

Verify Your Answer

Checklist

  • Method is added to the prototype
  • doubled equals 8
  • Output matches the expected line

Knowledge Check

Q:In prototype-based OOP, how do objects inherit behavior?
Q:Why are prototypes memory-efficient?
Q:What happens when you modify a prototype at runtime?

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