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
| Concept | After Effects | JavaScript/TypeScript | Luau (Rive) |
|---|---|---|---|
| Class inheritance | N/A | class Child extends Parent | setmetatable(Child, { __index = Parent }) |
| Super call | N/A | super.method() | Parent.method(self) |
| Type extension | N/A | interface Child extends Parent | type Child = Parent & { ... } |
| Constructor chain | N/A | super(args) | Call Parent.new() then setmetatable() |
| Method override | N/A | Just define same method | Just define same method |
| instanceof check | N/A | obj instanceof Class | getmetatable(obj) == Class |
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
draworupdatefunction - 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_Exercise1DrawableBaseTwoShapes
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the inheritance setup for Triangle so it inherits from Shape, then create both shapes in init.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_Exercise2OverrideAMethod
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the OutlineRect class by overriding the draw method to render both fill and stroke.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis 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).
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_Exercise3CallingParentMethods
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the Boss class by calling parent methods correctly using dot notation and explicit self.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_Exercise4CompleteDrawableHierarchy
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the 3-level hierarchy by setting up the inheritance chain and creating shapes in init.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis 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
draworupdatefunction - 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
Mistake 1: Forgetting to Link Prototypes
-- 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
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
- Continue to 3.7 Encapsulation
- Need a refresher? Review Quick Reference