Skip to main content

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

ConceptAfter Effects / JavaScriptLuau (Rive Scripts)
InitializationClass constructor()init(self)
Property change handlerReact's componentDidUpdateupdate(self)
Frame looprequestAnimationFrame(callback)advance(self, seconds)
Render functionCanvas draw() or React's render()draw(self, renderer)
Delta timeperformance.now() differenceseconds parameter in advance
Click handlerelement.onclick = fnpointerDown(self, position)
This referencethis keywordself parameter
Critical Difference: Frame Loop Architecture

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:

  1. init(self, context) - runs once at startup
  2. update(self) - runs when any Input changes (NO context param!)
  3. advance(self, seconds) - runs every animation frame (logic)
  4. 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

CallbackPurposeFrequencyAE/JS Equivalent
initCreate paths, paints, listeners, long-lived objectsOnceconstructor()
updateRespond to input changes; rebuild paths or cached valuesOn input changecomponentDidUpdate()
advanceUpdate time-based state (movement, physics, timers)Every animation framerequestAnimationFrame logic
drawIssue Renderer commands only (no state changes!)On canvas repaintCanvas ctx.draw() calls
Performance Rule

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

MethodReturnsDescription
context:viewModel()ViewModel?Gets the Artboard's ViewModel (if bound)
context:markNeedsUpdate()voidRequests 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
When to Use context:markNeedsUpdate()

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.

Goal

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. 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:

  1. Complete the lifecycle probe that logs when each callback runs and counts advance calls for 2 seconds.
  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: 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.

Goal

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. 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:

  1. Complete the rebuild function and verify it only runs when needed (in init and update, NOT every frame in draw).
  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: 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.

Goal

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. 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:

  1. Complete the advance function to create a pulsing animation. After 2 seconds of animation, print the answer.
  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


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.

Goal

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. 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:

  1. Complete the click counter that tracks clicks and animates a pop effect. Print the answer after 3 clicks.
  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


Knowledge Check

Q:In what order do the main lifecycle callbacks run?
Q:When should you rebuild geometry in a Node script?
Q:What is the purpose of the draw function?
Q:What does returning false from a pointer event handler do?

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