Protocols Overview
Learning Objectives
- Understand when each lifecycle callback runs
- Know where to put different types of logic
- Optimize performance by using the right callback
- Handle pointer events for user interaction
AE/JS Syntax Comparison
| Concept | After Effects / JavaScript | Luau (Rive Scripts) |
|---|---|---|
| Initialization | Class constructor() | init(self) |
| Property change handler | React's componentDidUpdate | update(self) |
| Frame loop | requestAnimationFrame(callback) | advance(self, seconds) |
| Render function | Canvas draw() or React's render() | draw(self, renderer) |
| Delta time | performance.now() difference | seconds parameter in advance |
| Click handler | element.onclick = fn | pointerDown(self, position) |
| This reference | this keyword | self parameter |
After Effects: Expressions are evaluated when the playhead moves. You don't control the frame loop—AE handles it and just asks "what value at this time?"
JavaScript (Canvas/WebGL): You call requestAnimationFrame() and manage your own frame loop. The browser tells you "it's time to draw" and you do everything.
Rive Scripts: Rive manages the frame loop automatically. You implement callbacks (init, update, advance, draw) and Rive calls them at the right time. You DON'T call requestAnimationFrame—Rive does that for you.
-- Rive handles the loop, you just implement callbacks:
function advance(self: MyNode, seconds: number): boolean
-- Called every frame automatically
self.time += seconds
return true
end
// JavaScript - you manage the loop yourself:
function loop(timestamp) {
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
advance(deltaTime / 1000);
draw();
requestAnimationFrame(loop); // You call this!
}
requestAnimationFrame(loop);
Rive Context: When Your Code Runs
Rive calls your script functions in a specific order. Understanding this lets you put logic in the right place and avoid unnecessary work every frame.
Lifecycle order:
init(self, context)- runs once at startupupdate(self)- runs when any Input changes (NO context param!)advance(self, seconds)- runs every animation frame (logic)draw(self, renderer)- runs on canvas repaint (rendering, keep pure!)
Pointer event handlers (pointerDown, pointerMove, etc.) run when the user interacts with the script node.
What Goes Where
| Callback | Purpose | Frequency | AE/JS Equivalent |
|---|---|---|---|
| init | Create paths, paints, listeners, long-lived objects | Once | constructor() |
| update | Respond to input changes; rebuild paths or cached values | On input change | componentDidUpdate() |
| advance | Update time-based state (movement, physics, timers) | Every animation frame | requestAnimationFrame logic |
| draw | Issue Renderer commands only (no state changes!) | On canvas repaint | Canvas ctx.draw() calls |
Put expensive work in update or init, not in draw or advance. Rebuilding geometry in advance (called every frame) wastes resources if inputs haven't changed.
The Context Object
The context parameter passed to lifecycle functions provides access to the runtime environment. Store it in init to use later.
Context API
| Method | Returns | Description |
|---|---|---|
context:viewModel() | ViewModel? | Gets the Artboard's ViewModel (if bound) |
context:markNeedsUpdate() | void | Requests a redraw on the next frame |
Common Usage
export type MyNode = {
context: Context, -- Store context for later use
}
function init(self: MyNode, context: Context): boolean
self.context = context -- Save for use in listeners
-- Access ViewModel through context
local vm = context:viewModel()
if vm then
local prop = vm:getNumber("score")
if prop then
prop:addListener(function()
-- Request redraw when data changes
self.context:markNeedsUpdate()
end)
end
end
return true
end
Call markNeedsUpdate() when external changes (like ViewModel property updates) need to trigger a redraw. This is more efficient than constantly redrawing - it tells Rive "something changed, please redraw next frame."
Lifecycle Flow Diagram
Practice Exercises
Exercise 1: Lifecycle Probe ⭐
Premise
Understanding when each callback runs is crucial: init once at start, update when inputs change, advance every frame. This helps you put code in the right place.
By the end of this exercise, you will be able to Complete the lifecycle probe that logs when each callback runs and counts advance calls for 2 seconds.
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_Exercise1LifecycleProbe
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- A probe to see when each lifecycle callback runs
export type LifecycleProbe = {
elapsed: number,
advanceCount: number,
}
function init(self: LifecycleProbe): boolean
-- TODO 1: Print "init: Starting probe"
self.elapsed = 0
self.advanceCount = 0
return true
end
function advance(self: LifecycleProbe, seconds: number): boolean
self.elapsed += seconds
-- TODO 2: Increment advanceCount
-- TODO 3: Every second (when floor(elapsed) changes), print "advance tick {count}"
-- TODO 4: When elapsed >= 2, print "ANSWER: probe" (only once)
return true
end
function draw(self: LifecycleProbe, renderer: Renderer)
end
return function(): Node<LifecycleProbe>
return {
init = init,
advance = advance,
draw = draw,
elapsed = 0,
advanceCount = 0,
}
end
Assignment
Complete these tasks:
- Complete the lifecycle probe that logs when each callback runs and counts advance calls for 2 seconds.
- 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: Rebuild Only on Updates ⭐⭐
Premise
Put expensive work in update, not draw. The update callback only runs when inputs change, saving CPU cycles compared to rebuilding every frame.
By the end of this exercise, you will be able to Complete the rebuild function and verify it only runs when needed (in init and update, NOT every frame in draw).
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_Exercise2RebuildOnlyOnUpdates
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Rebuild geometry only when inputs change
export type OnDemandShape = {
width: Input<number>,
height: Input<number>,
rebuildCount: number,
path: Path,
paint: Paint,
}
local function rebuild(self: OnDemandShape)
-- TODO 1: Increment rebuildCount
-- TODO 2: Calculate halfW and halfH from width/height
-- TODO 3: Build rectangle path using reset, moveTo, lineTo, close
-- Print rebuild info
print(`Rebuild #{self.rebuildCount}: {self.width} x {self.height}`)
-- TODO 4: When rebuildCount reaches 2, print "ANSWER: efficient"
end
function init(self: OnDemandShape): boolean
print("init: Creating shape")
self.rebuildCount = 0
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(120, 220, 140) })
rebuild(self)
return true
end
function update(self: OnDemandShape)
rebuild(self)
end
function draw(self: OnDemandShape, renderer: Renderer)
-- Note: NO rebuild here - that would waste resources!
renderer:drawPath(self.path, self.paint)
end
return function(): Node<OnDemandShape>
return {
init = init,
update = update,
draw = draw,
width = 120,
height = 80,
rebuildCount = 0,
path = late(),
paint = late(),
}
end
Assignment
Complete these tasks:
- Complete the rebuild function and verify it only runs when needed (in init and update, NOT every frame in draw).
- 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: Combining Lifecycle Callbacks ⭐⭐
Premise
Combining update (for input changes) and advance (for animation) lets you build components that are both configurable AND animated. This is the foundation for interactive game elements.
By the end of this exercise, you will be able to Complete the advance function to create a pulsing animation. After 2 seconds of animation, print the answer.
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_Exercise3CombiningLifecycleCallbacks
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- A shape that pulses over time
export type AnimatedShape = {
baseSize: number,
pulseSpeed: number,
time: number,
path: Path,
paint: Paint,
}
local function rebuildPath(self: AnimatedShape, size: number)
local half = size / 2
self.path:reset()
self.path:moveTo(Vector.xy(-half, -half))
self.path:lineTo(Vector.xy(half, -half))
self.path:lineTo(Vector.xy(half, half))
self.path:lineTo(Vector.xy(-half, half))
self.path:close()
end
function init(self: AnimatedShape): boolean
print("init: Creating animated shape")
self.time = 0
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(255, 120, 80) })
rebuildPath(self, self.baseSize)
return true
end
function advance(self: AnimatedShape, seconds: number): boolean
-- TODO 1: Add seconds to self.time
-- TODO 2: Calculate pulse using: math.sin(self.time * self.pulseSpeed) * 0.2 + 1
-- This creates a value between 0.8 and 1.2
local pulse = 1
-- TODO 3: Calculate currentSize as baseSize * pulse
local currentSize = self.baseSize
-- TODO 4: Rebuild the path with currentSize
-- Log pulse every second
if math.floor(self.time) > math.floor(self.time - seconds) and self.time > 0 then
print(`advance: pulse = {string.format("%.2f", pulse)}`)
end
-- TODO 5: After 2 seconds, print "ANSWER: pulsing"
return true
end
function draw(self: AnimatedShape, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end
return function(): Node<AnimatedShape>
return {
init = init,
advance = advance,
draw = draw,
baseSize = 60,
pulseSpeed = 2,
time = 0,
path = late(),
paint = late(),
}
end
Assignment
Complete these tasks:
- Complete the advance function to create a pulsing animation. After 2 seconds of animation, print the answer.
- 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
Pointer Events
Node scripts can also respond to pointer interactions. These are like DOM event handlers in JavaScript.
function pointerDown(self: MyNode, event: PointerEvent)
local pos = event.position
print(`Clicked at {pos.x}, {pos.y}`)
end
function pointerMove(self: MyNode, event: PointerEvent)
-- Handle mouse/touch movement
end
function pointerUp(self: MyNode, event: PointerEvent)
-- Handle mouse/touch release
end
// JavaScript equivalent - DOM event handlers:
element.addEventListener('mousedown', (e) => {
console.log(`Clicked at ${e.clientX}, ${e.clientY}`);
e.stopPropagation(); // Like returning true
});
element.addEventListener('mousemove', (e) => {
// Not calling stopPropagation = like returning false
});
Include these in your factory return table to enable them:
return function(): Node<MyNode>
return {
init = init,
draw = draw,
pointerDown = pointerDown,
pointerMove = pointerMove,
pointerUp = pointerUp,
}
end
Exercise 4: Click Counter ⭐⭐
Premise
Pointer events (pointerDown, pointerMove, pointerUp) enable interactivity. Return true to consume an event, false to let it propagate. This is the foundation of buttons, toggles, and draggable elements.
By the end of this exercise, you will be able to Complete the click counter that tracks clicks and animates a pop effect. Print the answer after 3 clicks.
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_Exercise4ClickCounter
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- A clickable shape that counts clicks
export type ClickCounter = {
clickCount: number,
displayScale: number,
path: Path,
paint: Paint,
}
function init(self: ClickCounter): boolean
self.clickCount = 0
self.displayScale = 1
self.path = Path.new()
self.path:moveTo(Vector.xy(-60, -30))
self.path:lineTo(Vector.xy(60, -30))
self.path:lineTo(Vector.xy(60, 30))
self.path:lineTo(Vector.xy(-60, 30))
self.path:close()
self.paint = Paint.with({ style = "fill", color = Color.rgb(80, 160, 255) })
return true
end
function pointerDown(self: ClickCounter, event: PointerEvent)
-- TODO 1: Increment clickCount
-- TODO 2: Set displayScale to 1.2 for pop effect
-- TODO 3: Print "Click #{clickCount}"
-- TODO 4: When clickCount reaches 3, print "ANSWER: interactive"
end
function advance(self: ClickCounter, seconds: number): boolean
-- TODO 6: Animate displayScale back to 1
-- Use: self.displayScale = math.max(1, self.displayScale - seconds * 2)
return true
end
function draw(self: ClickCounter, renderer: Renderer)
renderer:save()
renderer:transform(Mat2D.withScale(self.displayScale, self.displayScale))
renderer:drawPath(self.path, self.paint)
renderer:restore()
end
return function(): Node<ClickCounter>
return {
init = init,
pointerDown = pointerDown,
advance = advance,
draw = draw,
clickCount = 0,
displayScale = 1,
path = late(),
paint = late(),
}
end
Assignment
Complete these tasks:
- Complete the click counter that tracks clicks and animates a pop effect. Print the answer after 3 clicks.
- 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
Knowledge Check
Common Mistakes
1. Creating Objects in draw()
-- WRONG: Creating objects every frame wastes resources
function draw(self: MyNode, renderer: Renderer)
local path = Path.new() -- DON'T do this!
addCircle(path, Vector.xy(0, 0), 50) -- Rebuilding every frame!
renderer:drawPath(path, self.paint)
end
-- CORRECT: Create in init, draw only issues commands
function init(self: MyNode): boolean
self.path = Path.new()
addCircle(self.path, Vector.xy(0, 0), 50)
return true
end
function draw(self: MyNode, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end
-- Helper: Draw circle using cubic bezier approximation
local function addCircle(path: Path, center: Vector, radius: number)
local k = 0.5522847498 -- Bezier circle constant
local cx, cy, r = center.x, center.y, radius
path:moveTo(Vector.xy(cx, cy - r))
path:cubicTo(Vector.xy(cx + r*k, cy - r), Vector.xy(cx + r, cy - r*k), Vector.xy(cx + r, cy))
path:cubicTo(Vector.xy(cx + r, cy + r*k), Vector.xy(cx + r*k, cy + r), Vector.xy(cx, cy + r))
path:cubicTo(Vector.xy(cx - r*k, cy + r), Vector.xy(cx - r, cy + r*k), Vector.xy(cx - r, cy))
path:cubicTo(Vector.xy(cx - r, cy - r*k), Vector.xy(cx - r*k, cy - r), Vector.xy(cx, cy - r))
path:close()
end
2. Rebuilding Geometry in advance() When Not Animated
-- WRONG: Rebuilding every frame when size is static
function advance(self: MyNode, seconds: number): boolean
rebuildPath(self, self.size) -- Wasteful!
return true
end
-- CORRECT: Rebuild only when size input changes
function update(self: MyNode)
rebuildPath(self, self.size)
end
3. Using Wrong Pointer Event Signature
-- WRONG: Old Vector parameter pattern
function pointerDown(self: MyNode, position: Vector): boolean
print("Clicked!")
return true
end
-- CORRECT: Use PointerEvent parameter
function pointerDown(self: MyNode, event: PointerEvent)
local pos = event.position
print("Clicked at: " .. pos.x .. ", " .. pos.y)
end
Rive Takeaway
Put expensive work in update, not in draw or advance. Understanding the lifecycle helps you write efficient scripts that only do work when necessary.
Next Steps
- Continue to Layout Script Protocol
- Need a refresher? Review Quick Reference