Listeners
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
| Concept | After Effects | JavaScript | Luau (Rive) |
|---|---|---|---|
| Event listener | Layer markers + time | element.addEventListener('click', fn) | property:addListener(callback) |
| Pointer events | N/A | element.onclick = fn | function pointerDown(self, event: PointerEvent) |
| Mouse position | thisComp.layer("Null").position | event.clientX, event.clientY | event.position: Vector |
| Drag state | Manual with sliders | isDragging variable | self.isDragging: boolean |
| Property changes | N/A | Observer pattern | property:addListener(callback) |
| Custom events | N/A | new CustomEvent('name') | EventBus pattern via Util |
| Animation state | marker.key(1).time | State variables | State Machine inputs |
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:
- Pointer events -
pointerDown,pointerMove,pointerUp,pointerExitreceive aPointerEventparameter - 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>
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:
- ViewModel Property Listeners - Use
addListener()on anyProperty<T>orPropertyTrigger - Pointer Events - Handle
pointerDown,pointerUp,pointerMove,pointerExitinNode<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
| Type | Purpose |
|---|---|
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 |
Util | Utility modules (no generic) |
Tests | Test modules (no generic) |
Not a script type:
Listener<T>- Function signature foraddListener()/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.
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:
-
Create a ViewModel:
- Assets panel →
+→ ViewModel - Add a Number property named
score
- Assets panel →
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_PropertyChangeLogger
- Assets panel →
-
Attach and run:
- Attach script to any shape and press Play
- Change the
scorevalue in the ViewModel to trigger events
-
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:
- Add a listener to the score property that logs changes
- Change the
scorevalue 3 times in the ViewModel panel - 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
Checklist
- ViewModel has a
scorenumber 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.
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:
-
Create a ViewModel:
- Assets panel →
+→ ViewModel - Add a Trigger property named
onClick
- Assets panel →
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_TriggerHandler
- Assets panel →
-
Attach and run:
- Attach script to any shape and press Play
- Click the trigger in the ViewModel panel to fire events
-
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:
- Get and listen to the
onClicktrigger - Fire the trigger 2 times from the ViewModel panel
- 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
Checklist
- ViewModel has an
onClicktrigger property - Script uses
vm:getTrigger()andtrigger: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
| Function | When It's Called | Parameters | Return |
|---|---|---|---|
pointerDown | Mouse button pressed / touch start | event: PointerEvent | (optional) |
pointerMove | Mouse/touch moved | event: PointerEvent | (optional) |
pointerUp | Mouse button released / touch end | event: PointerEvent | (optional) |
pointerExit | Pointer left the element | event: PointerEvent | (optional) |
PointerEvent Properties:
event.position-Vectorwith x, y coordinates
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_ClickCounter
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the pointerDown and advance functions to create a click counter with pop animation. 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_Exercise4DragAndDrop
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete all three pointer event handlers to implement drag and drop. Print the answer after the first successful drag.
- 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_Exercise5InputDrivenStateVisualization
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the rebuildVisual helper and update function to create a progress bar that changes color based on isActive input.
- 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_Exercise6PublisherSubscriberPattern
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the EventBus Util functions and the Subscriber's init to create a working pub/sub system. Print the answer after receiving 2 events.
- 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
Best Practices
Choose the Right Protocol
| Use Case | Protocol | Why |
|---|---|---|
| Drawing and animation | Node script | Has draw, advance for rendering |
| Reusable logic | Util script | No lifecycle, shared via require() |
| Responding to property changes | Node script with addListener() | Subscribe to ViewModel changes |
| User interaction | Node script with pointer handlers | Has pointerDown/Move/Up/Exit |
| Cross-script communication | Util script as EventBus | Shared 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
Summary
| Script Type | Purpose | Key Feature |
|---|---|---|
Node<T> | Rendering, animation, interaction | init, draw, advance, pointer events |
Util | Shared code, EventBus patterns | require(), module-level state |
Listener<T> | (Not a script type!) | Function signature for addListener() |
Key Patterns Learned
- Property listeners - Use
property:addListener(callback)to react to ViewModel changes - Pointer events - Use
PointerEventparameter withevent.position - EventBus pattern - Util scripts enable decoupled component communication
- State machine inputs - Create bidirectional data flow with inputs
- Drag behavior - Requires
pointerDown,pointerMove, andpointerUptogether
What You've Learned
You now understand the complete Rive scripting system:
- Environment - How scripts run in the Rive sandbox
- Protocols - The lifecycle and callback structure
- Inputs - Editor-exposed values with
Input<T> - Node Scripts - Rendering, animation, and interaction
- Util Scripts - Reusable code modules
- 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
- Continue to Test Script Protocol
- Need a refresher? Review Quick Reference