Skip to main content

Metatables & __index

AE/JS Syntax Comparison

ConceptAfter EffectsJavaScriptLuau (Rive)
Object creationN/A (no objects){ key: value } or new Class(){ key = value } with setmetatable()
Method inheritanceN/Aclass extends or prototype chain__index metamethod
Property lookupDirect expression refsobj.prop or prototype chainTable lookup → metatable __index
Shared behaviorCopy-paste expressionsClass methods / prototypeMetatable with shared functions
ConstructorN/Aconstructor() or factoryFactory function with setmetatable()
this / selfthisLayer, thisCompthis keywordself parameter (explicit)
Critical Difference: No Built-in Classes or Inheritance

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 __index metamethod 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 advance and draw

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.

Goal

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:

  1. Create the script:

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

  1. Set style.opacity to 0.8
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Create the button with label "Start"
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Create a left instance named "Icon"
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Set width to 5
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Compute length as sqrt(xx + yy)
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Return a string in the form "(x,y)"
  2. 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

Verify Your Answer

Checklist

  • __tostring returns formatted output
  • Output matches the expected line
  • tostring uses the metamethod

Knowledge Check

Q:When is __index consulted?
Q:What does MyClass.__index = MyClass accomplish?
Q:If __index is a function, what arguments does it receive?
Q:Why do methods go on the prototype (class table) rather than in each instance?

Self-Assessment Checklist

  • I can create a metatable and attach it with setmetatable()
  • I understand when __index is triggered (missing keys only)
  • I can use __index with 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 __tostring and __add

Summary

ConceptPurpose
MetatableSpecial table that defines custom behavior for another table
setmetatable(t, mt)Attaches metatable mt to table t
__indexCalled when accessing a missing key - enables inheritance
T.__index = TStandard pattern - instances inherit methods from class
Double cast(self :: any) :: Type satisfies strict mode

Next Steps