Skip to main content

Lesson 3.3: Inheritance & Patterns

Learning Objectives

  • Implement inheritance between classes
  • Use intersection types for typed inheritance
  • Override methods while calling parent implementations
  • Build a complete class hierarchy for drawable objects

AE/JS Syntax Comparison

ConceptAfter EffectsJavaScript/TypeScriptLuau (Rive)
Class inheritanceN/Aclass Child extends Parentsetmetatable(Child, { __index = Parent })
Super callN/Asuper.method()Parent.method(self)
Type extensionN/Ainterface Child extends Parenttype Child = Parent & { ... }
Constructor chainN/Asuper(args)Call Parent.new() then setmetatable()
Method overrideN/AJust define same methodJust define same method
instanceof checkN/Aobj instanceof Classgetmetatable(obj) == Class
Critical Difference from JavaScript

JavaScript: Uses extends keyword and super for inheritance

class Dog extends Animal {
speak() {
super.speak(); // Call parent method
console.log("Woof!");
}
}

Luau: Links prototypes via setmetatable and calls parent with dot notation

setmetatable(Dog, { __index = Animal })

function Dog:speak()
Animal.speak(self) -- Call parent method with DOT and explicit self!
print("Woof!")
end

The dot notation with explicit self is how you call parent methods in Luau. Using colon would pass the wrong self.


Why Inheritance Matters in Rive

In Rive, inheritance is most useful when multiple drawable objects share common behavior (draw, move, update) but differ in geometry or styling. This helps keep Node scripts clean while still producing complex procedural visuals.

When to use inheritance in Rive:

  • Multiple drawables share a base draw or update function
  • You want different rendering styles for the same geometry
  • You need to override behavior without duplicating setup logic

For anything simpler, prefer composition with plain tables.


Prototype Inheritance (Rive Pattern)

Since Luau uses prototype-based OOP, inheritance means linking one prototype to another. When a method isn't found on the child, it looks in the parent.

Child instance -> Child prototype -> Parent prototype

If a method isn't found on the child, Luau looks it up on the parent via __index.

JavaScript/TypeScript equivalent:

// TypeScript has built-in class inheritance
class Animal {
name: string;
speak(): void { console.log("..."); }
}

class Dog extends Animal {
breed: string;
speak(): void { console.log("Woof!"); }
}

Luau equivalent:

local Animal = {}
Animal.__index = Animal

function Animal:speak()
print("...")
end

local Dog = {}
Dog.__index = Dog
setmetatable(Dog, { __index = Animal }) -- Dog inherits from Animal

function Dog:speak()
print("Woof!")
end

Practice Exercises

Exercise 1: Drawable Base + Two Shapes ⭐⭐

Premise

Both shapes inherit draw() from Shape, so the count is 2. This demonstrates polymorphism - different objects responding to the same method call.

Goal

By the end of this exercise, you will be able to Complete the inheritance setup for Triangle so it inherits from Shape, then create both shapes in init.

Use Case

This pattern shows up whenever you build behavior in Rive scripts.

Example scenarios:

  • Debugging script behavior
  • Driving animation logic

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_Exercise1DrawableBaseTwoShapes
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

-- BASE CLASS: Shape
local Shape = {}
Shape.__index = Shape

export type ShapeType = {
position: Vector,
path: Path,
paint: Paint,
}

function Shape:draw(renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end

-- CHILD CLASS: Rect (inherits from Shape)
local Rect = {}
Rect.__index = Rect
setmetatable(Rect, { __index = Shape }) -- Rect inherits from Shape

type RectType = ShapeType & {
width: number,
height: number,
}

function Rect.new(x: number, y: number, w: number, h: number, color: Color): RectType
local self = setmetatable({}, Rect)
self.position = Vector.xy(x, y)
self.width = w
self.height = h
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = color })
-- Build rect path
local hw, hh = w/2, h/2
self.path:moveTo(Vector.xy(x - hw, y - hh))
self.path:lineTo(Vector.xy(x + hw, y - hh))
self.path:lineTo(Vector.xy(x + hw, y + hh))
self.path:lineTo(Vector.xy(x - hw, y + hh))
self.path:close()
return (self :: any) :: RectType
end

-- CHILD CLASS: Triangle
local Triangle = {}
Triangle.__index = Triangle
-- TODO 1: Set up Triangle to inherit from Shape
-- Use setmetatable to link Triangle to Shape

type TriangleType = ShapeType & {
size: number,
}

function Triangle.new(x: number, y: number, size: number, color: Color): TriangleType
local self = setmetatable({}, Triangle)
self.position = Vector.xy(x, y)
self.size = size
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = color })
-- Build triangle path
local half = size / 2
self.path:moveTo(Vector.xy(x, y - half))
self.path:lineTo(Vector.xy(x + half, y + half))
self.path:lineTo(Vector.xy(x - half, y + half))
self.path:close()
return (self :: any) :: TriangleType
end

export type ShapeDemo = {
shapes: {ShapeType},
shapeCount: number,
}

function init(self: ShapeDemo): boolean
-- TODO 2: Create a Rect at (-60, 0) with size 80x50, red color
-- TODO 3: Create a Triangle at (60, 0) with size 70, blue color
-- Store both in self.shapes

self.shapes = {} -- Replace with actual shapes

self.shapeCount = #self.shapes
print(`Created {self.shapeCount} shapes`)

-- Verify inheritance works
for i, shape in ipairs(self.shapes) do
print(`Drawing shape {i}`)
-- This will error if Triangle doesn't inherit draw from Shape!
end

print(`ANSWER: {self.shapeCount}`)
return true
end

function draw(self: ShapeDemo, renderer: Renderer)
for _, shape in ipairs(self.shapes) do
shape:draw(renderer)
end
end

return function(): Node<ShapeDemo>
return {
init = init,
draw = draw,
shapes = {},
shapeCount = 0,
}
end

Assignment

Complete these tasks:

  1. Complete the inheritance setup for Triangle so it inherits from Shape, then create both shapes in init.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 2: Override a Method ⭐⭐

Premise

OutlineRect overrides draw() to render both fill and stroke. The child defines its own version of the method, which takes precedence over the parent's.

Goal

By the end of this exercise, you will be able to Complete the OutlineRect class by overriding the draw method to render both fill and stroke.

Use Case

This pattern shows up whenever you build behavior in Rive scripts.

Example scenarios:

  • Debugging script behavior
  • Driving animation logic

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_Exercise2OverrideAMethod
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

-- BASE CLASS: Shape
local Shape = {}
Shape.__index = Shape

export type ShapeType = {
path: Path,
paint: Paint,
}

function Shape:draw(renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end

-- CHILD CLASS: Rect
local Rect = {}
Rect.__index = Rect
setmetatable(Rect, { __index = Shape })

type RectType = ShapeType & {
width: number,
height: number,
}

function Rect.new(x: number, y: number, w: number, h: number, color: Color): RectType
local self = setmetatable({}, Rect)
self.width = w
self.height = h
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = color })
local hw, hh = w/2, h/2
self.path:moveTo(Vector.xy(x - hw, y - hh))
self.path:lineTo(Vector.xy(x + hw, y - hh))
self.path:lineTo(Vector.xy(x + hw, y + hh))
self.path:lineTo(Vector.xy(x - hw, y + hh))
self.path:close()
return (self :: any) :: RectType
end

-- GRANDCHILD CLASS: OutlineRect (extends Rect)
local OutlineRect = {}
OutlineRect.__index = OutlineRect
setmetatable(OutlineRect, { __index = Rect })

type OutlineRectType = RectType & {
stroke: Paint,
}

function OutlineRect.new(x: number, y: number, w: number, h: number, fill: Color, strokeColor: Color): OutlineRectType
local base = Rect.new(x, y, w, h, fill)
local self = setmetatable(base, OutlineRect) :: any
self.stroke = Paint.with({ style = "stroke", thickness = 5, color = strokeColor })
return self
end

-- TODO: Override the draw method to render BOTH fill AND stroke
-- The fill uses self.paint (inherited), the stroke uses self.stroke
function OutlineRect:draw(renderer: Renderer)
-- Replace this with code that draws both fill and stroke
renderer:drawPath(self.path, self.paint) -- This only draws fill
end

export type OutlineDemo = {
plainRect: RectType,
outlineRect: OutlineRectType,
hasStroke: string,
}

function init(self: OutlineDemo): boolean
self.plainRect = Rect.new(-70, 0, 80, 60, Color.rgb(255, 120, 90))
self.outlineRect = OutlineRect.new(70, 0, 80, 60, Color.rgb(80, 180, 255), Color.rgb(40, 80, 120))

print("Plain rect: fill only")
print("Outline rect: fill + stroke")

-- Check if stroke property exists and is used
self.hasStroke = if self.outlineRect.stroke then "stroke" else "none"
print(`ANSWER: {self.hasStroke}`)

return true
end

function draw(self: OutlineDemo, renderer: Renderer)
self.plainRect:draw(renderer)
self.outlineRect:draw(renderer)
end

return function(): Node<OutlineDemo>
return {
init = init,
draw = draw,
plainRect = Rect.new(0, 0, 1, 1, Color.rgb(0, 0, 0)),
outlineRect = OutlineRect.new(0, 0, 1, 1, Color.rgb(0, 0, 0), Color.rgb(0, 0, 0)),
hasStroke = "",
}
end

Assignment

Complete these tasks:

  1. Complete the OutlineRect class by overriding the draw method to render both fill and stroke.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 3: Calling Parent Methods ⭐⭐

Premise

After nextPhase (damage 25 -> 35), and another nextPhase (35 -> 45), the final damage is 45. The boss's attack method calls the parent's attack using Enemy.attack(self).

Goal

By the end of this exercise, you will be able to Complete the Boss class by calling parent methods correctly using dot notation and explicit self.

Use Case

This pattern shows up whenever you build behavior in Rive scripts.

Example scenarios:

  • Debugging script behavior
  • Driving animation logic

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise3_Exercise3CallingParentMethods
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

-- BASE CLASS: Enemy
local Enemy = {}
Enemy.__index = Enemy

type EnemyType = {
name: string,
health: number,
damage: number,
}

function Enemy.new(name: string, health: number, damage: number): EnemyType
local self = setmetatable({}, Enemy)
self.name = name
self.health = health
self.damage = damage
return (self :: any) :: EnemyType
end

function Enemy:attack()
print(`{self.name} attacks for {self.damage} damage!`)
end

function Enemy:describe()
print(`{self.name}: {self.health} HP, {self.damage} DMG`)
end

-- CHILD CLASS: Boss (extends Enemy)
local Boss = {}
Boss.__index = Boss
setmetatable(Boss, { __index = Enemy })

type BossType = EnemyType & {
phase: number,
specialAttackName: string,
}

function Boss.new(name: string, health: number, damage: number, special: string): BossType
local base = Enemy.new(name, health, damage)
base.phase = 1
base.specialAttackName = special
return setmetatable(base, Boss) :: any :: BossType
end

-- TODO 1: Override attack() to call parent's attack FIRST, then add special if phase >= 2
-- CRITICAL: Use DOT notation with explicit self: Enemy.attack(self)
function Boss:attack()
-- Call parent's attack method here (use DOT notation!)
-- Then if phase >= 2, print special attack message
print("Boss attack not implemented")
end

-- TODO 2: Override describe() to call parent's describe FIRST, then add phase info
function Boss:describe()
-- Call parent's describe method here (use DOT notation!)
-- Then print phase and special attack info
print("Boss describe not implemented")
end

function Boss:nextPhase()
self.phase += 1
self.damage += 10
print(`{self.name} enters phase {self.phase}!`)
end

export type BossDemo = {
boss: BossType,
}

function init(self: BossDemo): boolean
self.boss = Boss.new("Dragon", 500, 25, "Fire Breath")

self.boss:describe()

print("Phase 1 attack:")
self.boss:attack()

self.boss:nextPhase() -- Phase 2, damage = 35
self.boss:nextPhase() -- Phase 3, damage = 45

print("Phase 3 attack:")
self.boss:attack()

print(`ANSWER: {self.boss.damage}`)

return true
end

function draw(self: BossDemo, renderer: Renderer)
end

return function(): Node<BossDemo>
return {
init = init,
draw = draw,
boss = Boss.new("", 0, 0, ""),
}
end

Assignment

Complete these tasks:

  1. Complete the Boss class by calling parent methods correctly using dot notation and explicit self.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 4: Complete Drawable Hierarchy ⭐⭐⭐

Premise

3 objects are created (2 boxes + 1 circle), all stored as DrawableType. The hierarchy chains: Circle/Box -> DrawableObject -> GameObject.

Goal

By the end of this exercise, you will be able to Complete the 3-level hierarchy by setting up the inheritance chain and creating shapes in init.

Use Case

This pattern shows up whenever you build behavior in Rive scripts.

Example scenarios:

  • Debugging script behavior
  • Driving animation logic

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise4_Exercise4CompleteDrawableHierarchy
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

-- LEVEL 1: Base GameObject
local GameObject = {}
GameObject.__index = GameObject

type GameObjectType = {
id: number,
x: number,
y: number,
active: boolean,
}

local nextId = 1

function GameObject.new(x: number, y: number): GameObjectType
local self = setmetatable({}, GameObject)
self.id = nextId
nextId += 1
self.x = x
self.y = y
self.active = true
return (self :: any) :: GameObjectType
end

function GameObject:destroy()
self.active = false
end

-- LEVEL 2: DrawableObject (extends GameObject)
local DrawableObject = {}
DrawableObject.__index = DrawableObject
-- TODO 1: Set up DrawableObject to inherit from GameObject

type DrawableType = GameObjectType & {
path: Path,
paint: Paint,
}

function DrawableObject.new(x: number, y: number, color: Color): DrawableType
local base = GameObject.new(x, y)
base.path = Path.new()
base.paint = Paint.with({ style = "fill", color = color })
return setmetatable(base, DrawableObject) :: any :: DrawableType
end

function DrawableObject:draw(renderer: Renderer)
renderer:save()
renderer:transform(Mat2D.withTranslation(self.x, self.y))
renderer:drawPath(self.path, self.paint)
renderer:restore()
end

-- LEVEL 3: Box (extends DrawableObject)
local Box = {}
Box.__index = Box
-- TODO 2: Set up Box to inherit from DrawableObject

type BoxType = DrawableType & {
width: number,
height: number,
}

function Box.new(x: number, y: number, w: number, h: number, color: Color): BoxType
local base = DrawableObject.new(x, y, color)
base.width = w
base.height = h
local hw, hh = w/2, h/2
base.path:moveTo(Vector.xy(-hw, -hh))
base.path:lineTo(Vector.xy(hw, -hh))
base.path:lineTo(Vector.xy(hw, hh))
base.path:lineTo(Vector.xy(-hw, hh))
base.path:close()
return setmetatable(base, Box) :: any :: BoxType
end

-- LEVEL 3: Circle (extends DrawableObject)
local Circle = {}
Circle.__index = Circle
-- TODO 3: Set up Circle to inherit from DrawableObject

type CircleType = DrawableType & {
radius: number,
}

function Circle.new(x: number, y: number, radius: number, color: Color): CircleType
local base = DrawableObject.new(x, y, color)
base.radius = radius
local segments = 24
for i = 0, segments - 1 do
local angle = (i / segments) * math.pi * 2
local px = math.cos(angle) * radius
local py = math.sin(angle) * radius
if i == 0 then
base.path:moveTo(Vector.xy(px, py))
else
base.path:lineTo(Vector.xy(px, py))
end
end
base.path:close()
return setmetatable(base, Circle) :: any :: CircleType
end

export type HierarchyDemo = {
objects: {DrawableType},
objectCount: number,
}

function init(self: HierarchyDemo): boolean
print("Level 1: GameObject (position, active)")
print("Level 2: DrawableObject (path, paint, draw)")
print("Level 3: Box/Circle (specific geometry)")

-- TODO 4: Create objects array with:
-- - Box at (-60, 0), size 50x40, red
-- - Box at (60, 0), size 40x50, green
-- - Circle at (0, 0), radius 30, blue
self.objects = {} -- Replace with actual objects

self.objectCount = #self.objects
print(`Created {self.objectCount} objects`)

-- Count active objects
local activeCount = 0
for _, obj in ipairs(self.objects) do
if obj.active then
activeCount += 1
end
end
print(`Active objects: {activeCount}`)
print(`ANSWER: {activeCount}`)

return true
end

function draw(self: HierarchyDemo, renderer: Renderer)
for _, obj in ipairs(self.objects) do
if obj.active then
obj:draw(renderer)
end
end
end

return function(): Node<HierarchyDemo>
return {
init = init,
draw = draw,
objects = {},
objectCount = 0,
}
end

Assignment

Complete these tasks:

  1. Complete the 3-level hierarchy by setting up the inheritance chain and creating shapes in init.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


When to Use Inheritance in Rive

Good use cases:

  • Multiple drawables share a base draw or update function
  • You want different rendering styles for the same geometry
  • You need to override behavior without duplicating setup logic

Prefer composition when:

  • Objects don't share meaningful behavior
  • The relationship is "has-a" rather than "is-a"
  • You need more flexibility than single inheritance provides

Common Mistakes

-- WRONG: Only instance has metatable
local Dog = {}
Dog.__index = Dog

function Dog.new()
return setmetatable({}, Dog)
end
-- Dog doesn't inherit from Animal!

-- CORRECT: Link class to parent
local Dog = {}
Dog.__index = Dog
setmetatable(Dog, { __index = Animal }) -- THIS IS REQUIRED!

Mistake 2: Wrong Syntax for Parent Calls

-- WRONG: Passes Animal as self
function Dog:speak()
Animal:speak() -- Animal becomes self!
end

-- CORRECT: Pass self explicitly
function Dog:speak()
Animal.speak(self) -- Use dot, pass self
end

Mistake 3: Not Using Intersection Types

-- WRONG: Loses parent type information
type Dog = {
breed: string,
}

-- CORRECT: Extends parent type
type Dog = Animal & {
breed: string,
}

Mistake 4: Deep Hierarchies

Keep inheritance shallow - Rive scripts work best with 2-3 levels max:

-- TOO DEEP (hard to maintain):
Entity -> GameObject -> DrawableObject -> AnimatedObject -> Character -> Player

-- BETTER (manageable):
GameObject -> DrawableObject -> Player

Knowledge Check

Q:How does Luau's prototype chain implement inheritance?
Q:How do you call a parent's method from a child class?
Q:What type syntax extends a parent type with additional properties?

Self-Assessment Checklist

  • I can implement class inheritance with setmetatable
  • I can use intersection types (Parent & { ... }) for typed inheritance
  • I can call parent methods using Parent.method(self) (dot notation)
  • I can override methods while extending parent behavior
  • I can build complete drawable hierarchies (2-3 levels)
  • I know when to use inheritance vs composition

Next Steps