Metatables & __index
AE/JS Syntax Comparison
| Concept | After Effects | JavaScript | Luau (Rive) |
|---|---|---|---|
| Object creation | N/A (no objects) | { key: value } or new Class() | { key = value } with setmetatable() |
| Method inheritance | N/A | class extends or prototype chain | __index metamethod |
| Property lookup | Direct expression refs | obj.prop or prototype chain | Table lookup → metatable __index |
| Shared behavior | Copy-paste expressions | Class methods / prototype | Metatable with shared functions |
| Constructor | N/A | constructor() or factory | Factory function with setmetatable() |
this / self | thisLayer, thisComp | this keyword | self parameter (explicit) |
After Effects: Has no object-oriented programming. Expressions are purely functional - you write formulas that compute values. You cannot create "objects" that hold state and methods together.
JavaScript: Has both prototype-based inheritance (original) and class syntax (ES6+). Classes are syntactic sugar over prototypes.
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} says hello`; }
}
Luau (Rive): Has NO class keyword. Instead, you use metatables - special tables that define custom behavior. The __index metamethod creates inheritance by telling Luau "if a key isn't found in this table, look in that other table."
-- This is how you create "classes" in Luau
local Animal = {}
Animal.__index = Animal -- Key pattern!
function Animal.new(name)
local self = setmetatable({}, Animal)
self.name = name
return self
end
function Animal:speak()
return `{self.name} says hello`
end
This is not just syntax difference - it's a fundamentally different mental model. Understanding metatables is essential for writing any non-trivial Rive scripts.
Learning Objectives
- Understand what metatables are and how they work
- Use
setmetatable()to attach behavior to tables - Master the
__indexmetamethod for property lookup - Implement prototype patterns for reusable behavior
Rive Context
Rive scripts are not classes. When you need reusable behavior (drawing helpers, physics helpers, layout logic), you build prototype tables with metatables. This lets Node scripts share methods and data structures without leaving the Rive scripting environment.
Typical Rive pattern:
- Put reusable logic in a Util script or a local prototype table
- Create instances with
setmetatable - Call shared methods from
advanceanddraw
Why Metatables?
Luau doesn't have built-in classes like Java or C#. Instead, it uses metatables - special tables that define custom behavior for other tables.
Think of a metatable as a "behavior manual" for a table. When Luau doesn't know how to perform an operation on a table, it consults the metatable for instructions.
Real-world analogy: If you find a device you've never seen before, you'd look for an instruction manual. The metatable is that manual.
JavaScript Equivalent Concept:
// JavaScript prototype chain (similar concept)
const animalProto = {
speak() { return `${this.name} says hello`; }
};
const dog = Object.create(animalProto);
dog.name = "Rex";
dog.speak(); // "Rex says hello"
// dog doesn't have speak(), so JavaScript looks in animalProto
-- Luau equivalent
local animalProto = {
speak = function(self)
return `{self.name} says hello`
end
}
local dog = setmetatable({ name = "Rex" }, { __index = animalProto })
print(dog:speak()) -- "Rex says hello"
-- dog doesn't have speak(), so Luau looks in animalProto via __index
Exercises
Exercise 1: Basic Metatable ⭐
Premise
Metatables let you define fallback behavior for missing fields. This is the core of Lua's object model.
By the end of this exercise, you will use __index to supply default values.
Use Case
You want default styling for multiple UI elements. Each instance can override a value, but missing values fall back to defaults.
Example scenarios:
- Default style settings
- Shared configuration tables
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_BasicMetatable
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise1 = {}
function init(self: Exercise1): boolean
local defaults = {
opacity = 0.6,
shadow = 1,
}
-- TODO: Set opacity to 0.8 and attach defaults via __index
local style = {
opacity = 0,
}
setmetatable(style, { __index = defaults })
print(`ANSWER: opacity={style.opacity},shadow={style.shadow}`)
return true
end
function draw(self: Exercise1, renderer: Renderer)
end
return function(): Node<Exercise1>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Set style.opacity to 0.8
- Keep __index pointing to defaults
Expected Output
Your console output should display the opacity value (overridden) and the shadow value (from defaults via __index).
Verify Your Answer
Checklist
- __index provides fallback values
- opacity override is applied
- Output matches the expected line
Exercise 2: Class Pattern ⭐
Premise
The standard class pattern in Luau uses __index plus a constructor function. This is the most common OOP pattern in Rive scripts.
By the end of this exercise, you will create a basic class-style table.
Use Case
You want reusable components with shared methods, like buttons or markers. The class pattern keeps creation consistent.
Example scenarios:
- Reusable UI widgets
- Instance factories for nodes
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_ClassPattern
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise2 = {}
local Button = {}
Button.__index = Button
function Button.new(label: string)
return setmetatable({ label = label }, Button)
end
function Button:describe(): string
return `Button:{self.label}`
end
function init(self: Exercise2): boolean
local button = Button.new("")
-- TODO: Set the button label to "Start"
print(`ANSWER: {button:describe()}`)
return true
end
function draw(self: Exercise2, renderer: Renderer)
end
return function(): Node<Exercise2>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Create the button with label "Start"
- Keep the class pattern intact
Expected Output
Your console output should display the button's describe method result, showing the class name and label.
Verify Your Answer
Checklist
- Button instance uses the metatable
- Label is Start
- Output matches the expected line
Exercise 3: Shared Methods ⭐
Premise
Metatables let different instances share the same methods. This reduces duplicate code and keeps behavior consistent.
By the end of this exercise, you will call shared methods across instances.
Use Case
Multiple UI elements share a describe method for debugging or logging. The prototype provides the common behavior.
Example scenarios:
- Shared debug output
- Common helper methods
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_SharedMethods
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise3 = {}
local Renderable = {}
Renderable.__index = Renderable
function Renderable.new(name: string)
return setmetatable({ name = name }, Renderable)
end
function Renderable:describe(): string
return self.name
end
function init(self: Exercise3): boolean
local left = Renderable.new("")
local right = Renderable.new("")
-- TODO: Set left name to "Icon" and right name to "Badge"
print(`ANSWER: left={left:describe()},right={right:describe()}`)
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 a left instance named "Icon"
- Create a right instance named "Badge"
Expected Output
Your console output should display the names returned by the shared describe method for both instances.
Verify Your Answer
Checklist
- Both instances share the same method
- Names are set correctly
- Output matches the expected line
Exercise 4: Default Styles ⭐⭐
Premise
Default tables with __index let you keep style values centralized while still allowing per-instance overrides.
By the end of this exercise, you will combine defaults with overrides.
Use Case
A style system provides defaults for color and width, while specific elements override only what they need.
Example scenarios:
- Style overrides for UI
- Theme defaults
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_DefaultStyles
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise4 = {}
function init(self: Exercise4): boolean
local defaults = {
width = 2,
color = "blue",
}
-- TODO: Override width to 5, keep color from defaults
local style = {
width = 0,
}
setmetatable(style, { __index = defaults })
print(`ANSWER: width={style.width},color={style.color}`)
return true
end
function draw(self: Exercise4, renderer: Renderer)
end
return function(): Node<Exercise4>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Set width to 5
- Keep color inherited from defaults
Expected Output
Your console output should display the width (overridden) and color (inherited from defaults via __index).
Verify Your Answer
Checklist
- Override width only
- Default color is applied
- Output matches the expected line
Exercise 5: Computed __index ⭐⭐
Premise
__index can be a function, not just a table. That means you can compute values on demand.
By the end of this exercise, you will compute a property using __index.
Use Case
You want a vector length property without storing it explicitly. __index computes it from x and y.
Example scenarios:
- Derived metrics
- Computed properties
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_ComputedIndex
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise5 = {}
function init(self: Exercise5): boolean
local vec = { x = 3, y = 4 }
-- TODO: Add an __index function that returns length
setmetatable(vec, {
__index = function(t, key)
if key == "length" then
return 0
end
return nil
end,
})
print(`ANSWER: length={vec.length}`)
return true
end
function draw(self: Exercise5, renderer: Renderer)
end
return function(): Node<Exercise5>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Compute length as sqrt(xx + yy)
- Return the computed value from __index
Expected Output
Your console output should display the computed length from the __index function (the hypotenuse of a 3-4-5 triangle).
Verify Your Answer
Checklist
- __index computes length
- Length equals 5
- Output matches the expected line
Exercise 6: __tostring Metamethod ⭐⭐
Premise
Metamethods customize how tables behave. __tostring lets you control how a table prints.
By the end of this exercise, you will implement __tostring on a table.
Use Case
You want clean debug output for vector values in the console without manually formatting every time.
Example scenarios:
- Debugging vectors
- Consistent log formatting
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_ToString
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise6 = {}
function init(self: Exercise6): boolean
local point = { x = 2, y = 5 }
-- TODO: Add __tostring to format as "(2,5)"
setmetatable(point, {
__tostring = function(t)
return ""
end,
})
local label = tostring(point)
print(`ANSWER: {label}`)
return true
end
function draw(self: Exercise6, renderer: Renderer)
end
return function(): Node<Exercise6>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Return a string in the form "(x,y)"
- Use t.x and t.y inside __tostring
Expected Output
Your console output should display the formatted point string from the __tostring metamethod.
Verify Your Answer
Checklist
- __tostring returns formatted output
- Output matches the expected line
- tostring uses the metamethod
Knowledge Check
Self-Assessment Checklist
- I can create a metatable and attach it with
setmetatable() - I understand when
__indexis triggered (missing keys only) - I can use
__indexwith both tables and functions - I can trace the lookup chain when a method is called
- I understand the pattern
MyClass.__index = MyClass - I can implement other metamethods like
__tostringand__add
Summary
| Concept | Purpose |
|---|---|
| Metatable | Special table that defines custom behavior for another table |
setmetatable(t, mt) | Attaches metatable mt to table t |
__index | Called when accessing a missing key - enables inheritance |
T.__index = T | Standard pattern - instances inherit methods from class |
| Double cast | (self :: any) :: Type satisfies strict mode |
Next Steps
- Continue to The __index Metamethod
- Need a refresher? Review Quick Reference