Skip to main content

Listeners

This is NOT a Script Protocol

Unlike Node, Layout, Converter, Path Effect, Util, and Test scripts, there is no "Listener" script type. Listener<T> is a function signature used with addListener()/removeListener() methods. This page covers how to use listeners within other scripts (primarily Node Scripts).

AE/JS Syntax Comparison

ConceptAfter EffectsJavaScriptLuau (Rive)
Event listenerLayer markers + timeelement.addEventListener('click', fn)property:addListener(callback)
Pointer eventsN/Aelement.onclick = fnfunction pointerDown(self, event: PointerEvent)
Mouse positionthisComp.layer("Null").positionevent.clientX, event.clientYevent.position: Vector
Drag stateManual with slidersisDragging variableself.isDragging: boolean
Property changesN/AObserver patternproperty:addListener(callback)
Custom eventsN/Anew CustomEvent('name')EventBus pattern via Util
Animation statemarker.key(1).timeState variablesState Machine inputs
Critical Difference: Event-Driven Architecture

After Effects: Events don't exist. Expressions react to time and layer properties. You check time >= marker.key(1).time to "respond" to moments.

JavaScript DOM: Event-driven with bubbling and capturing phases. Events propagate through the DOM tree.

Luau (Rive): Two event mechanisms:

  1. Pointer events - pointerDown, pointerMove, pointerUp, pointerExit receive a PointerEvent parameter
  2. Property listeners - Use property:addListener(callback) to react to ViewModel changes
-- Pointer events receive PointerEvent with position property
function pointerDown(self: MyScript, event: PointerEvent)
local pos = event.position
print("Clicked at: " .. pos.x .. ", " .. pos.y)
end

-- Property listeners use addListener in init()
function init(self: MyScript, context: Context): boolean
local vm = context:viewModel()
local score = vm:getNumber("score")
if score then
score:addListener(function()
print("Score changed to: " .. score.value)
end)
end
return true
end

Rive Context: Beyond Node and Util Scripts

While Node and Util scripts handle most use cases, Rive provides mechanisms for responding to events and state changes.

Topics covered:

  • Property listeners - react to ViewModel property changes via addListener()
  • Pointer events - handle mouse/touch interaction in Node scripts
  • State machine integration - scripts that communicate with state machines
  • Event patterns - custom pub/sub and event bus architectures

Understanding Listener<T>

Important Clarification

Listener<T> is NOT a script return type like Node<T> or Converter<T>.

It's a function type signature used by the addListener() and removeListener() methods on ViewModel properties.

What Listener<T> Actually Is

-- Listener<T> is defined as:
export type Listener<T> = <K>(self: T, obj: K, (K) -> ()) -> ()
& (self: T, () -> ()) -> ()

This is the type signature for callback registration, not a script protocol.

How Scripts Actually Receive Events

Scripts don't have a dedicated "event listener" protocol. Instead, use:

  1. ViewModel Property Listeners - Use addListener() on any Property<T> or PropertyTrigger
  2. Pointer Events - Handle pointerDown, pointerUp, pointerMove, pointerExit in Node<T> scripts

Correct Pattern: Property Listeners

--!strict

export type MyNode = {
context: Context?,
changeCount: number,
}

function init(self: MyNode, context: Context): boolean
self.context = context

local vm = context:viewModel()

-- Listen to a number property
local scoreProperty = vm:getNumber("score")
if scoreProperty then
scoreProperty:addListener(function()
print("Score changed to:", scoreProperty.value)
self.changeCount += 1
end)
end

-- Listen to a trigger property
local onClickTrigger = vm:getTrigger("onClick")
if onClickTrigger then
onClickTrigger:addListener(function()
print("onClick trigger fired!")
end)
end

return true
end

function draw(self: MyNode, renderer: Renderer)
-- Your drawing code
end

return function(): Node<MyNode>
return {
init = init,
draw = draw,
context = nil,
changeCount = 0,
}
end

JavaScript/TypeScript Equivalent:

// Similar to adding event listeners in JavaScript
class MyComponent {
changeCount: number = 0;

constructor(viewModel: ViewModel) {
// Subscribe to property changes
viewModel.score.addListener(() => {
console.log("Score changed to:", viewModel.score.value);
this.changeCount++;
});

// Subscribe to triggers
viewModel.onClick.addListener(() => {
console.log("onClick trigger fired!");
});
}
}

Valid Script Return Types

TypePurpose
Node<T>Drawable nodes with init, draw, advance, pointer events
Layout<T>Layout-aware nodes with measure, resize
Converter<T, I, O>Data converters with convert, reverseConvert
PathEffect<T>Custom path effects
UtilUtility modules (no generic)
TestsTest modules (no generic)

Not a script type:

  • Listener<T> - Function signature for addListener()/removeListener()

Exercises

Exercise 1: Property Change Logger ⭐

Premise

ViewModel properties can be observed using addListener(). When a property's value changes, all registered callbacks are invoked. This is how scripts react to state machine changes, user inputs, and data binding updates.

Goal

By the end of this exercise, you will be able to use property:addListener() to log property changes. After 3 changes, print the answer.

Use Case

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

Example scenarios:

  • Reacting to score changes in a game
  • Updating visuals when settings change
  • Logging analytics events

Setup

In Rive Editor:

  1. Create a ViewModel:

    • Assets panel → + → ViewModel
    • Add a Number property named score
  2. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_PropertyChangeLogger
  3. Attach and run:

    • Attach script to any shape and press Play
    • Change the score value in the ViewModel to trigger events
  4. Open the Console:

    • View → Console

Starter Code

--!strict
-- A Node script that logs ViewModel property changes

export type PropertyLogger = {
context: Context?,
changeCount: number,
lastValue: number,
}

function init(self: PropertyLogger, context: Context): boolean
self.context = context
self.changeCount = 0
self.lastValue = 0

local vm = context:viewModel()
if not vm then
print("ERROR: No ViewModel found!")
return false
end

local scoreProperty = vm:getNumber("score")
if not scoreProperty then
print("ERROR: No 'score' property found in ViewModel!")
return false
end

-- TODO 1: Add a listener to scoreProperty using addListener()
-- The callback should:
-- a) Increment self.changeCount by 1
-- b) Store scoreProperty.value in self.lastValue
-- c) Print "=== Score Changed ==="
-- d) Print "New value: {scoreProperty.value}"
-- e) Print "Total changes: {self.changeCount}"
-- f) When changeCount reaches 3, print "ANSWER: 3"

print("PropertyLogger initialized - change 'score' to trigger events")
return true
end

function draw(self: PropertyLogger, renderer: Renderer)
-- Empty draw - this script just logs
end

return function(): Node<PropertyLogger>
return {
init = init,
draw = draw,
context = nil,
changeCount = 0,
lastValue = 0,
}
end

Assignment

Complete these tasks:

  1. Add a listener to the score property that logs changes
  2. Change the score value 3 times in the ViewModel panel
  3. Copy the ANSWER: line into the validator

Expected Output

PropertyLogger initialized - change 'score' to trigger events
=== Score Changed ===
New value: 10
Total changes: 1
=== Score Changed ===
New value: 20
Total changes: 2
=== Score Changed ===
New value: 30
Total changes: 3
ANSWER: 3

Verify Your Answer

Verify Your Answer

Checklist

  • ViewModel has a score number property
  • Script uses property:addListener(callback) pattern
  • Console output includes the ANSWER: line after 3 changes


Exercise 2: Trigger Handler ⭐⭐

Premise

ViewModel Triggers are special properties that fire events without storing values. They're perfect for button clicks, state transitions, and one-shot events. Use getTrigger() and addListener() to handle them.

Goal

By the end of this exercise, you will be able to handle trigger events from a ViewModel. After 2 trigger fires, print the answer.

Use Case

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

Example scenarios:

  • Handling button clicks from state machines
  • Responding to animation events
  • Triggering sound effects

Setup

In Rive Editor:

  1. Create a ViewModel:

    • Assets panel → + → ViewModel
    • Add a Trigger property named onClick
  2. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_TriggerHandler
  3. Attach and run:

    • Attach script to any shape and press Play
    • Click the trigger in the ViewModel panel to fire events
  4. Open the Console:

    • View → Console

Starter Code

--!strict
-- A Node script that handles ViewModel trigger events

export type TriggerHandler = {
context: Context?,
triggerCount: number,
}

function init(self: TriggerHandler, context: Context): boolean
self.context = context
self.triggerCount = 0

local vm = context:viewModel()
if not vm then
print("ERROR: No ViewModel found!")
return false
end

-- TODO 1: Get the "onClick" trigger using vm:getTrigger("onClick")

-- TODO 2: Check if the trigger exists (if not onClickTrigger then ... end)

-- TODO 3: Add a listener to the trigger that:
-- a) Increments self.triggerCount by 1
-- b) Prints "🔔 Trigger fired!"
-- c) Prints "Trigger count: {self.triggerCount}"
-- d) When triggerCount reaches 2, prints "ANSWER: 2"

print("TriggerHandler initialized - fire 'onClick' trigger to test")
return true
end

function draw(self: TriggerHandler, renderer: Renderer)
-- Empty draw - this script just handles triggers
end

return function(): Node<TriggerHandler>
return {
init = init,
draw = draw,
context = nil,
triggerCount = 0,
}
end

Assignment

Complete these tasks:

  1. Get and listen to the onClick trigger
  2. Fire the trigger 2 times from the ViewModel panel
  3. Copy the ANSWER: line into the validator

Expected Output

TriggerHandler initialized - fire 'onClick' trigger to test
🔔 Trigger fired!
Trigger count: 1
🔔 Trigger fired!
Trigger count: 2
ANSWER: 2

Verify Your Answer

Verify Your Answer

Checklist

  • ViewModel has an onClick trigger property
  • Script uses vm:getTrigger() and trigger:addListener() pattern
  • Console output includes the ANSWER: line after 2 triggers


Pointer Events in Node Scripts

Node scripts can respond to mouse/touch Input through pointer handlers. This is how you create interactive elements.

Pointer Event Reference

FunctionWhen It's CalledParametersReturn
pointerDownMouse button pressed / touch startevent: PointerEvent(optional)
pointerMoveMouse/touch movedevent: PointerEvent(optional)
pointerUpMouse button released / touch endevent: PointerEvent(optional)
pointerExitPointer left the elementevent: PointerEvent(optional)

PointerEvent Properties:

  • event.position - Vector with x, y coordinates
Pointer Event Signature

Pointer events receive a PointerEvent object, not a raw Vector. Access the position via event.position.

function pointerDown(self: MyScript, event: PointerEvent)
local pos = event.position
print("Clicked at: " .. pos.x .. ", " .. pos.y)
end

Exercise 3: Click Counter with Visual Feedback ⭐⭐

Premise

Pointer events receive a PointerEvent parameter with position information. Combining pointerDown with advance creates interactive elements with visual feedback.

Goal

By the end of this exercise, you will be able to complete the pointerDown and advance functions to create a click counter with pop animation. Print the answer after 3 clicks.

Use Case

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

Example scenarios:

  • Interactive buttons
  • Click tracking
  • Visual feedback animations

Setup

In Rive Editor:

  1. Create the script:

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

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

    • View → Console

Starter Code

--!strict
-- Click counter with visual pop feedback

export type ClickCounter = {
clickCount: number,
displayScale: number,
path: Path,
paint: Paint,
}

function init(self: ClickCounter): boolean
print("init: Button ready")
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)
local position = event.position
-- TODO 1: Check if click is within bounds (|x| <= 60 and |y| <= 30)
-- Use math.abs(position.x) and math.abs(position.y)

-- Inside the bounds check:
-- TODO 2: Increment clickCount by 1

-- TODO 3: Set displayScale to 1.2 (pop effect)

-- TODO 4: Print "Click #{self.clickCount} at ({position.x:.1f}, {position.y:.1f})"

-- TODO 5: When clickCount reaches 3, print "ANSWER: clicked"

end

function advance(self: ClickCounter, seconds: number): boolean
-- TODO 8: If displayScale > 1, animate it back toward 1
-- Use: self.displayScale = math.max(1, self.displayScale - seconds * 4)

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 pointerDown and advance functions to create a click counter with pop animation. 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


Exercise 4: Drag and Drop ⭐⭐⭐

Premise

Drag behavior requires all three pointer events working together: pointerDown to start and capture offset, pointerMove to track movement, pointerUp to finish. The offset calculation ensures smooth dragging from wherever you grab.

Goal

By the end of this exercise, you will be able to Complete all three pointer event handlers to implement drag and drop. Print the answer after the first successful drag.

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_Exercise4DragAndDrop
  2. Attach and run:

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

    • View → Console

Starter Code

--!strict
-- Implement drag and drop with all three pointer events

export type Draggable = {
isDragging: boolean,
offsetX: number,
offsetY: number,
posX: number,
posY: number,
hasDragged: boolean,
size: number,
path: Path,
paint: Paint,
}

local function rebuildPath(self: Draggable)
local half = self.size / 2
self.path:reset()
self.path:moveTo(Vector.xy(self.posX - half, self.posY - half))
self.path:lineTo(Vector.xy(self.posX + half, self.posY - half))
self.path:lineTo(Vector.xy(self.posX + half, self.posY + half))
self.path:lineTo(Vector.xy(self.posX - half, self.posY + half))
self.path:close()
end

function init(self: Draggable): boolean
print("init: Draggable ready")
self.isDragging = false
self.offsetX = 0
self.offsetY = 0
self.posX = 0
self.posY = 0
self.hasDragged = false
self.size = 80

self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(255, 120, 80) })
rebuildPath(self)
return true
end

function pointerDown(self: Draggable, event: PointerEvent)
local position = event.position
local half = self.size / 2
local dx = math.abs(position.x - self.posX)
local dy = math.abs(position.y - self.posY)

if dx <= half and dy <= half then
-- TODO 1: Set isDragging to true

-- TODO 2: Calculate offset (where user clicked relative to center)
-- self.offsetX = position.x - self.posX
-- self.offsetY = position.y - self.posY

-- TODO 3: Change color to highlight (255, 180, 120)

-- TODO 4: Print "Drag started"
end
end

function pointerMove(self: Draggable, event: PointerEvent)
local position = event.position
-- TODO 5: If not isDragging, return early

-- TODO 6: Update position using offset
-- self.posX = position.x - self.offsetX
-- self.posY = position.y - self.offsetY

-- TODO 7: Call rebuildPath(self)
end

function pointerUp(self: Draggable, event: PointerEvent)
local position = event.position
-- TODO 8: If not isDragging, return early

-- TODO 9: Set isDragging to false

-- TODO 10: Reset color to normal (255, 120, 80)

-- TODO 11: Print "Dropped at ({self.posX:.1f}, {self.posY:.1f})"

-- TODO 12: If not hasDragged, set it true and print "ANSWER: dragged"
end

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

return function(): Node<Draggable>
return {
init = init,
pointerDown = pointerDown,
pointerMove = pointerMove,
pointerUp = pointerUp,
draw = draw,
isDragging = false,
offsetX = 0,
offsetY = 0,
posX = 0,
posY = 0,
hasDragged = false,
size = 80,
path = late(),
paint = late(),
}
end

Assignment

Complete these tasks:

  1. Complete all three pointer event handlers to implement drag and drop. Print the answer after the first successful drag.
  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


State Machine Integration

Scripts can communicate with Rive's state machine through Inputs. This creates powerful bidirectional data flow.

Exercise 5: Input-Driven State Visualization ⭐⭐

Premise

Scripts can respond to state machine inputs through the update() callback. This creates bidirectional data flow where state machines control 'what' happens and scripts control 'how' it looks.

Goal

By the end of this exercise, you will be able to Complete the rebuildVisual helper and update function to create a progress bar that changes color based on isActive input.

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 Exercise5_Exercise5InputDrivenStateVisualization
  2. Attach and run:

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

    • View → Console

Starter Code

--!strict
-- A visual that responds to state machine inputs

export type StateBridge = {
isActive: Input<boolean>,
progress: Input<number>,
updateCount: number,
path: Path,
paint: Paint,
}

local function rebuildVisual(self: StateBridge)
-- TODO 1: Calculate width from progress (0-1 maps to 0-200)
local width = 0 -- Replace with: 200 * self.progress

-- TODO 2: Choose color based on isActive
-- Green (80, 200, 120) when active, Red (200, 80, 80) when inactive
-- Use: if self.isActive then ... else ... end
local color = Color.rgb(100, 100, 100)

-- TODO 3: Rebuild the progress bar path
-- Start at (-100, -20), width determined by progress
self.path:reset()
-- self.path:moveTo(Vector.xy(-100, -20))
-- self.path:lineTo(Vector.xy(-100 + width, -20))
-- self.path:lineTo(Vector.xy(-100 + width, 20))
-- self.path:lineTo(Vector.xy(-100, 20))
-- self.path:close()

-- TODO 4: Set paint color
-- self.paint.color = color
end

function init(self: StateBridge): boolean
print("init: StateBridge ready")
self.updateCount = 0
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(100, 100, 100) })
rebuildVisual(self)
return true
end

function update(self: StateBridge)
self.updateCount += 1

-- TODO 5: Print "State changed: active={self.isActive}, progress={self.progress}"

-- TODO 6: Call rebuildVisual(self)

-- TODO 7: When updateCount reaches 2, print "ANSWER: reactive"

end

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

return function(): Node<StateBridge>
return {
init = init,
update = update,
draw = draw,
isActive = false,
progress = 0.5,
updateCount = 0,
path = late(),
paint = late(),
}
end

Assignment

Complete these tasks:

  1. Complete the rebuildVisual helper and update function to create a progress bar that changes color based on isActive input.
  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


Custom Event Patterns

When you need scripts to communicate with each other, create an EventBus using a Util script.

Exercise 6: Publisher-Subscriber Pattern ⭐⭐⭐

Premise

The pub/sub pattern enables decoupled component communication. Util scripts with module-level state can act as shared event buses. Publishers don't know about subscribers - they just emit events.

Goal

By the end of this exercise, you will be able to Complete the EventBus Util functions and the Subscriber's init to create a working pub/sub system. Print the answer after receiving 2 events.

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 Exercise6_Exercise6PublisherSubscriberPattern
  2. Attach and run:

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

    • View → Console

Starter Code

--!strict
-- Publisher-Subscriber pattern with EventBus

-- UTIL SCRIPT: EventBus (create this as a separate Util script)
--[[
local EventBus = {}

export type Subscription = {
eventName: string,
callback: (data: any) -> (),
}

local subscriptions: { Subscription } = {}

function EventBus.subscribe(eventName: string, callback: (data: any) -> ())
-- TODO 1: Insert subscription into table
-- table.insert(subscriptions, { eventName = eventName, callback = callback })

-- TODO 2: Print "EventBus: Subscribed to \"{eventName}\""

end

function EventBus.publish(eventName: string, data: any)
-- TODO 3: Print "EventBus: Publishing \"{eventName}\""

-- TODO 4: Loop through subscriptions and call matching callbacks
-- for _, sub in ipairs(subscriptions) do
-- if sub.eventName == eventName then
-- sub.callback(data)
-- end
-- end

end

return EventBus
]]

-- NODE SCRIPT: Subscriber (uses the EventBus)
local EventBus = require("EventBus")

export type Subscriber = {
receivedClicks: number,
scale: number,
path: Path,
paint: Paint,
}

function init(self: Subscriber): boolean
self.receivedClicks = 0
self.scale = 1

-- TODO 5: Subscribe to "click" events
-- EventBus.subscribe("click", function(data)
-- self.receivedClicks += 1
-- self.scale = 1.5
-- print("Subscriber received click #" .. self.receivedClicks)
-- if self.receivedClicks == 2 then
-- print("ANSWER: pubsub")
-- end
-- end)

self.path = Path.new()
self.path:moveTo(Vector.xy(-30, -30))
self.path:lineTo(Vector.xy(30, -30))
self.path:lineTo(Vector.xy(30, 30))
self.path:lineTo(Vector.xy(-30, 30))
self.path:close()

self.paint = Paint.with({ style = "fill", color = Color.rgb(100, 200, 100) })
return true
end

function advance(self: Subscriber, seconds: number): boolean
if self.scale > 1 then
self.scale = math.max(1, self.scale - seconds * 3)
end
return true
end

function draw(self: Subscriber, renderer: Renderer)
renderer:save()
renderer:transform(Mat2D.withTranslation(100, 0))
renderer:transform(Mat2D.withScale(self.scale, self.scale))
renderer:drawPath(self.path, self.paint)
renderer:restore()
end

return function(): Node<Subscriber>
return {
init = init,
advance = advance,
draw = draw,
receivedClicks = 0,
scale = 1,
path = late(),
paint = late(),
}
end

Assignment

Complete these tasks:

  1. Complete the EventBus Util functions and the Subscriber's init to create a working pub/sub system. Print the answer after receiving 2 events.
  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


Best Practices

Choose the Right Protocol

Use CaseProtocolWhy
Drawing and animationNode scriptHas draw, advance for rendering
Reusable logicUtil scriptNo lifecycle, shared via require()
Responding to property changesNode script with addListener()Subscribe to ViewModel changes
User interactionNode script with pointer handlersHas pointerDown/Move/Up/Exit
Cross-script communicationUtil script as EventBusShared state across scripts

Event Handling Patterns

-- GOOD: Check bounds in pointer handler
function pointerDown(self: MyScript, event: PointerEvent)
local pos = event.position
if self:isWithinBounds(pos) then
self:handleClick()
end
end

-- Using property listeners for ViewModel changes
function init(self: MyScript, context: Context): boolean
local vm = context:viewModel()
local score = vm:getNumber("score")
if score then
score:addListener(function()
print("Score changed!")
end)
end
return true
end

Event Naming Conventions

-- GOOD: Clear, descriptive event names
EventBus.publish("PlayerScored", { points = 100 })
EventBus.publish("LevelCompleted", { level = 3, time = 45.2 })
EventBus.publish("ItemPurchased", { itemId = "sword_01", price = 500 })

-- BAD: Vague or generic names
EventBus.publish("event1", data)
EventBus.publish("thing", stuff)
EventBus.publish("e", {})

Common Mistakes

Mistake 1: Wrong Pointer Event Signature

-- WRONG: Using Vector directly (old pattern)
function pointerDown(self: MyScript, position: Vector): boolean
print("Clicked!")
return true
end

-- RIGHT: Use PointerEvent parameter
function pointerDown(self: MyScript, event: PointerEvent)
local pos = event.position
print("Clicked at: " .. pos.x .. ", " .. pos.y)
end

Mistake 2: Thinking Listener<T> is a Script Type

-- WRONG: Listener<T> is NOT a valid script return type!
return function(): Listener<MyType> -- This will cause compile errors!
return {
onEvent = onEvent, -- This pattern doesn't exist
}
end

-- RIGHT: Use Node<T> with property:addListener()
return function(): Node<MyType>
return {
init = init,
draw = draw,
-- Use property:addListener() in init() to listen for changes
}
end

Mistake 3: Forgetting to Unsubscribe (Memory Leak)

-- PROBLEM: Subscriptions accumulate if script is reloaded
function init(self: MyScript): boolean
EventBus.subscribe("click", function(data)
-- This callback persists even after script reload!
end)
return true
end

-- SOLUTION: Clear subscriptions appropriately
-- Option 1: Call EventBus.clear() when appropriate
-- Option 2: Track subscriptions and remove them
-- Option 3: Design your EventBus with weak references

Mistake 4: Using Node Lifecycle in Util Scripts

-- WRONG: Util scripts don't have lifecycle functions!
-- This init function will NEVER be called
function init(self): boolean
print("This never runs!")
return true
end

return { init = init }

-- RIGHT: Util scripts just export functions/data
local MyUtil = {}

function MyUtil.doSomething()
print("This works!")
end

return MyUtil

Knowledge Check

Q:What is Listener<T> in Rive scripting?
Q:Which pointer events would you use together to implement drag behavior?
Q:How do you listen for ViewModel property changes in Rive?
Q:Why use a Util script as an EventBus instead of putting the code in a Node script?
Q:In the drag implementation, why do we calculate offsetX = position.x - self.posX?

Summary

Script TypePurposeKey Feature
Node<T>Rendering, animation, interactioninit, draw, advance, pointer events
UtilShared code, EventBus patternsrequire(), module-level state
Listener<T>(Not a script type!)Function signature for addListener()

Key Patterns Learned

  1. Property listeners - Use property:addListener(callback) to react to ViewModel changes
  2. Pointer events - Use PointerEvent parameter with event.position
  3. EventBus pattern - Util scripts enable decoupled component communication
  4. State machine inputs - Create bidirectional data flow with inputs
  5. Drag behavior - Requires pointerDown, pointerMove, and pointerUp together

What You've Learned

You now understand the complete Rive scripting system:

  1. Environment - How scripts run in the Rive sandbox
  2. Protocols - The lifecycle and callback structure
  3. Inputs - Editor-exposed values with Input<T>
  4. Node Scripts - Rendering, animation, and interaction
  5. Util Scripts - Reusable code modules
  6. Other Protocols - Events, listeners, pointer handling, and communication patterns

With this foundation, you can create sophisticated interactive animations that respond to user input, communicate between components, and integrate with Rive's state machines.

Next: Learn about Object-Oriented Programming patterns in Luau with metatables and classes.

Next Steps