Skip to main content

The __index Metamethod

Learning Objectives​

  • Understand when and how __index is triggered
  • Use __index as both a table and a function
  • Master __newindex for controlling property assignment
  • Build lookup chains for inheritance hierarchies
  • Implement computed properties and lazy initialization

AE/JS Syntax Comparison​

ConceptAfter EffectsJavaScriptLuau (Rive)
Property getterN/Aget prop() { return x; }__index function
Property setterN/Aset prop(v) { x = v; }__newindex function
Prototype chainN/AObject.getPrototypeOf()getmetatable(t).__index
Default valuesN/Aobj.prop ?? 'default'__index table with defaults
Computed propertyN/Aget area() { return w*h; }__index function returning computed value
Critical Difference: __index Only Fires for Missing Keys

In JavaScript, getters run every time you access a property. In Luau, __index is ONLY called when the key doesn't exist in the table.

local t = { x = 10 }
setmetatable(t, { __index = function() print("called!") end })
print(t.x) -- prints 10, NOT "called!"
print(t.y) -- NOW it prints "called!" because y doesn't exist

This is crucial for performance—methods and existing properties are fast.


Rive Context​

In Rive Node Scripts, __index is your primary tool for:

  • Sharing methods between drawable objects (particles, sprites, UI elements)
  • Default values for style configuration (colors, thicknesses, fonts)
  • Computed properties like bounding boxes derived from position and size
  • Lazy loading expensive resources only when first accessed

Understanding __index deeply lets you build efficient, reusable Rive components.


When __index Fires​

The __index metamethod is only called when a key doesn't exist in the table:

local t = { existing = "found" }
setmetatable(t, {
__index = function(tbl, key)
print(`Looking up: {key}`)
return "fallback"
end
})

print(t.existing) -- "found" (no __index call)
print(t.missing) -- prints "Looking up: missing", returns "fallback"

Key insight: If the key exists in the table, __index is never consulted.


Exercises​

Exercise 1: __index Defaults ⭐​

Premise​

__index can point to a defaults table. This gives you fallback values without copying them into every instance.

Goal

By the end of this exercise, you will use __index as a table for defaults.

Use Case​

You want default properties for UI elements, but allow per-instance overrides for specific values.

Example scenarios:

  • Theme defaults
  • Shared style settings

Setup​

In Rive Editor:​

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_IndexDefaults
  2. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play

Starter Code​

export type Exercise1 = {}

function init(self: Exercise1): boolean
local defaults = {
opacity = 0.4,
blur = 2,
}

-- TODO: Override opacity to 0.9 and use defaults for blur
local style = {
opacity = 0,
}
setmetatable(style, { __index = defaults })

print(`ANSWER: opacity={style.opacity},blur={style.blur}`)
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.9
  2. Keep __index pointing to defaults

Expected Output​

Your console output should display the opacity value (overridden) and the blur value (from defaults via __index).


Verify Your Answer​

Verify Your Answer

Checklist​

  • Defaults supply blur
  • Opacity override works
  • Output matches the expected line

Exercise 2: __index Computed ⭐⭐​

Premise​

__index can be a function that computes values on demand. This is useful for derived properties.

Goal

By the end of this exercise, you will compute a property inside __index.

Use Case​

You need to compute a derived value like length without storing it. __index can compute it only when needed.

Example scenarios:

  • Vector length
  • Derived UI measurements

Setup​

In Rive Editor:​

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_IndexComputed
  2. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play

Starter Code​

export type Exercise2 = {}

function init(self: Exercise2): boolean
local vec = { x = 6, y = 8 }

-- TODO: Compute length when key == "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: Exercise2, renderer: Renderer)
end

return function(): Node<Exercise2>
return {
init = init,
draw = draw,
}
end

Assignment​

Complete these tasks:

  1. Return sqrt(xx + yy)
  2. Keep the computed value inside __index

Expected Output​

Your console output should display the computed length from the __index function (the hypotenuse of a 6-8-10 triangle).


Verify Your Answer​

Verify Your Answer

Checklist​

  • __index computes length
  • Length equals 10
  • Output matches the expected line

Exercise 3: Lookup Chain ⭐⭐​

Premise​

The __index chain can span multiple tables. This lets you build inheritance hierarchies for shared behavior.

Goal

By the end of this exercise, you will use a multi-level __index chain.

Use Case​

You have a base widget with shared methods, then derived widgets add their own behavior. The lookup chain finds the right method.

Example scenarios:

  • Base and derived UI widgets
  • Shared helper methods

Setup​

In Rive Editor:​

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise3_LookupChain
  2. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play

Starter Code​

export type Exercise3 = {}

local Base = { kind = "Base" }
function Base:describe(): string
return `Kind={self.kind}`
end

local Derived = { kind = "Derived" }
setmetatable(Derived, { __index = Base })

function init(self: Exercise3): boolean
local instance = setmetatable({}, { __index = Derived })
-- TODO: Change Derived.kind to "Widget"

print(`ANSWER: {instance: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. Set Derived.kind to "Widget"
  2. Keep the lookup chain intact

Expected Output​

Your console output should display the describe method result showing the kind value from the lookup chain.


Verify Your Answer​

Verify Your Answer

Checklist​

  • Lookup chain uses Base then Derived
  • Kind resolves to Widget
  • Output matches the expected line

Exercise 4: __newindex Validation ⭐⭐​

Premise​

__newindex lets you intercept assignments and enforce rules. This is useful for clamping or validation.

Goal

By the end of this exercise, you will validate assignments with __newindex.

Use Case​

You want to ensure a value never exceeds a max threshold. __newindex can clamp values as they are assigned.

Example scenarios:

  • Clamped style values
  • Safe configuration assignment

Setup​

In Rive Editor:​

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise4_NewIndex
  2. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play

Starter Code​

export type Exercise4 = {}

function init(self: Exercise4): boolean
local config = {}
local maxValue = 10

setmetatable(config, {
__newindex = function(t, key, value)
-- TODO: Clamp numeric values to maxValue
rawset(t, key, value)
end,
})

config.power = 15
print(`ANSWER: power={config.power}`)
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. If value is a number above maxValue, store maxValue
  2. Otherwise store the original value

Expected Output​

Your console output should display the clamped power value enforced by the __newindex metamethod.


Verify Your Answer​

Verify Your Answer

Checklist​

  • __newindex clamps values
  • power equals 10
  • Output matches the expected line

Exercise 5: Lazy Initialization ⭐⭐​

Premise​

You can lazily create values when they are first accessed. __index is a common tool for this pattern.

Goal

By the end of this exercise, you will lazily initialize a missing key.

Use Case​

You want to create an entry the first time it is requested, without pre-allocating everything.

Example scenarios:

  • Lazy caches
  • On-demand table entries

Setup​

In Rive Editor:​

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise5_LazyInit
  2. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play

Starter Code​

export type Exercise5 = {}

function init(self: Exercise5): boolean
local cache = {}

setmetatable(cache, {
__index = function(t, key)
-- TODO: Create and store a default value for missing keys
return nil
end,
})

local value = cache["token"]
print(`ANSWER: {value}`)
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. When a key is missing, store "created" in the table
  2. Return the stored value

Expected Output​

Your console output should display the lazily initialized value that was created and stored on first access.


Verify Your Answer​

Verify Your Answer

Checklist​

  • Missing keys are initialized
  • Value is stored and returned
  • Output matches the expected line

Exercise 6: Read-Only Table ⭐⭐​

Premise​

__newindex only fires for assignments to keys that are missing in the target table. To make config effectively read-only (including existing keys), expose a proxy table:

  • __index reads from a backing source table
  • __newindex ignores writes on the proxy
Goal

By the end of this exercise, you will build a read-only proxy with __index and __newindex.

Use Case​

You want runtime code to read config values but never mutate them directly. The proxy pattern prevents changes to both existing and new keys.

Example scenarios:

  • Immutable configuration
  • Read-only settings

Setup​

In Rive Editor:​

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise6_ReadOnly
  2. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play

Starter Code​

export type Exercise6 = {}

function init(self: Exercise6): boolean
local source = {
mode = "locked",
}

local config = {}

setmetatable(config, {
-- TODO: Read from source
__index = nil,
-- TODO: Ignore writes on the proxy
__newindex = function(t, key, value)
end,
})

-- Existing key in source: should still be blocked
config.mode = "open"
-- New key on proxy: should also be blocked
config.debug = true

print(`ANSWER: mode={config.mode}`)
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. Set __index to source
  2. Make __newindex ignore writes
  3. Keep the original mode value

Expected Output​

Your console output should display the original mode value, proving that writes were blocked by __newindex.


Verify Your Answer​

Verify Your Answer

Checklist​

  • Proxy reads from source via __index
  • Writes are ignored by __newindex
  • Mode remains locked
  • Output matches the expected line

Knowledge Check​

Q:When is __index called?
Q:What's the difference between __index as a table vs a function?
Q:Why use rawset inside __newindex?
Q:What happens when __index points to a table that also has __index?

Common Mistakes​

1. Forgetting rawset in __newindex​

-- WRONG: Infinite recursion
setmetatable(t, {
__newindex = function(tbl, key, value)
tbl[key] = value -- This calls __newindex again!
end
})

-- CORRECT: Use rawset
setmetatable(t, {
__newindex = function(tbl, key, value)
rawset(tbl, key, value) -- Bypasses metamethod
end
})

2. Expecting __index for Existing Keys​

-- WRONG assumption
local t = { name = "existing" }
setmetatable(t, {
__index = function() print("called!") end
})
print(t.name) -- Prints "existing", NOT "called!"
-- __index is NOT called because 'name' exists

3. Confusing __index Direction​

-- WRONG: Parent points to child (backwards!)
local Parent = {}
local Child = {}
setmetatable(Parent, { __index = Child }) -- Wrong!

-- CORRECT: Child's metatable points to parent
local Parent = {}
local Child = {}
setmetatable(Child, { __index = Parent }) -- Right!
-- Now Child inherits from Parent

4. Over-using Function __index​

-- OVERKILL: Using function when table works
setmetatable(obj, {
__index = function(t, k)
if k == "default1" then return 10
elseif k == "default2" then return 20
end
end
})

-- SIMPLER: Just use a table
local defaults = { default1 = 10, default2 = 20 }
setmetatable(obj, { __index = defaults })

5. Expecting __newindex for Existing Keys on the Same Table​

-- WRONG assumption: existing key bypasses __newindex
local t = { mode = "locked" }
setmetatable(t, {
__newindex = function() print("blocked") end
})
t.mode = "open" -- __newindex NOT called

-- CORRECT: use a proxy table
local source = { mode = "locked" }
local proxy = {}
setmetatable(proxy, {
__index = source,
__newindex = function() end, -- ignore writes
})
proxy.mode = "open" -- blocked

Self-Assessment Checklist​

  • I understand when __index is triggered (missing keys only)
  • I can use __index with both tables and functions
  • I can implement computed properties with function __index
  • I understand how lookup chains work through multiple levels
  • I can use __newindex for validation
  • I know when to use rawget and rawset
  • I can implement lazy initialization patterns

Next Steps​