Node Protocol
Learning Objectives
- Understand the complete Node script structure
- Know which functions are required vs optional
- Master the factory function pattern
- Attach scripts to Artboard nodes
AE/JS Syntax Comparison
| Concept | After Effects / JavaScript | Luau (Rive Scripts) |
|---|---|---|
| Script file | .jsx or .js file | Script asset in Rive |
| Class definition | class MyClass { } | export type MyNode = { } |
| Constructor | constructor() { } | init(self) |
| Instance method | this.method() | function method(self) |
| Factory pattern | new MyClass() or createInstance() | return function(): Node<T> |
| Required interface | implements Interface | Must have init and draw |
| Export | export default or module.exports | return function(): Node<T> |
JavaScript/TypeScript: You define a class and instantiate it with new:
class MyComponent { }
const instance = new MyComponent();
Rive Scripts: The script RETURNS a factory function. Rive calls this factory to create instances:
return function(): Node<MyNode>
return {
init = init,
draw = draw,
-- properties with defaults or late()
}
end
Why? This pattern lets Rive:
- Create multiple instances of your script (one per attached node)
- Control when initialization happens (factory first, then
init()) - Validate your script structure before running
Rive Context: A Node Script Is Not a Normal Luau File
Node scripts run inside Rive and must follow a protocol. The editor only lists scripts that return a factory function with at least init and draw.
Minimum requirements:
--!strictat the top- Exported type for script state (
export type) init(self)anddraw(self, renderer)functionsreturn function(): Node<YourType>factory function
If draw is missing, the script will not appear in the add-to-scene menu. This is a common source of confusion for beginners.
The late() Initializer
When your script needs objects that are created in init() (like Path, Paint, or ViewModel references), use late() as a placeholder in the factory return.
Why late() Exists
The factory function runs before init(). At that point, you can't call Path.new() or access context. But Luau's type system needs to know that these fields exist. late() tells Rive: "This field will be assigned later in init()."
export type MyNode = {
path: Path, -- Will be created in init()
paint: Paint, -- Will be created in init()
score: number, -- Has a default value
}
function init(self: MyNode): boolean
self.path = Path.new() -- Assign the late() fields
self.paint = Paint.new()
return true
end
return function(): Node<MyNode>
return {
init = init,
draw = draw,
path = late(), -- Placeholder for Path
paint = late(), -- Placeholder for Paint
score = 0, -- Regular default value
}
end
When to Use late() vs Default Values
| Scenario | Use | Example |
|---|---|---|
| Numbers, booleans, strings with defaults | Default value | speed = 1.5 |
| Objects created in init() | late() | path = late() |
| ViewModel properties | late() | scoreProp = late() |
| Editor-assigned inputs | late() | artboard = late() |
| Optional fields (may be nil) | nil | callback = nil |
Common late() Use Cases
export type MyNode = {
-- Objects created in init()
path: Path,
paint: Paint,
-- ViewModel references
vm: ViewModel?,
scoreProp: Property<number>?,
-- Editor inputs (Artboard references)
component: Input<Artboard<Data.MyVM>>,
-- Regular defaults (not late)
time: number,
isActive: boolean,
}
return function(): Node<MyNode>
return {
init = init,
draw = draw,
path = late(),
paint = late(),
vm = nil,
scoreProp = nil,
component = late(), -- Assigned by editor
time = 0,
isActive = true,
}
end
If a field's value comes from calling a constructor or accessing context, use late(). If it has a simple default value, just assign that value.
Standard Node Script Template
--!strict
export type MyNode = {
-- Persistent fields live here
}
function init(self: MyNode): boolean
return true
end
function draw(self: MyNode, renderer: Renderer)
end
return function(): Node<MyNode>
return {
init = init,
draw = draw,
}
end
// JavaScript/TypeScript mental model:
interface MyNode {
// Persistent fields
}
class MyNodeScript implements NodeProtocol {
init(): boolean {
return true;
}
draw(renderer: Renderer): void {
// Render commands
}
}
// Factory function
export default function createMyNode(): MyNode {
return new MyNodeScript();
}
Every Node script you write will follow this structure. The exported type defines what data your script instance holds, the lifecycle functions define behavior, and the factory function creates new instances.
Protocol Functions Reference
| Function | Required | Parameters | Return | Called When |
|---|---|---|---|---|
init | ✅ Yes | self, context | boolean | Once at startup |
draw | ✅ Yes | self, renderer | none | On canvas repaint |
update | Optional | self | none | Input changes |
advance | Optional | self, seconds | boolean | Every animation frame |
pointerDown | Optional | self, event: PointerEvent | none | Mouse/touch down |
pointerMove | Optional | self, event: PointerEvent | none | Mouse/touch move |
pointerUp | Optional | self, event: PointerEvent | none | Mouse/touch up |
How to Run Any Node Script
- Assets panel → + → Script → Node
- Paste the script
- Drag it onto a Node in the artboard
- Open the Console panel
- Press Play
print() statements in your script appear in Rive's Console panel, not your browser's developer console.
Practice Exercises
Exercise 1: Hello Rive ⭐
Premise
Every Node script must have init() and draw() functions. init() runs once when the script starts and is the place to print debug messages and set up initial state.
By the end of this exercise, you will be able to Complete the init function to print a greeting message and the answer. The script structure (export type, factory function) is already provided.
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_Exercise1HelloRive
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Your first Node script
export type HelloRive = {}
function init(self: HelloRive): boolean
-- TODO 1: Print "Hello from a Node Script!"
-- TODO 2: Print "Script initialized successfully"
-- TODO 3: Print "ANSWER: hello"
-- TODO 4: Return true to indicate successful initialization
return false
end
function draw(self: HelloRive, renderer: Renderer)
end
return function(): Node<HelloRive>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Complete the init function to print a greeting message and the answer. The script structure (export type, factory function) is already provided.
- 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: Frame Timer ⭐
Premise
The advance() function runs every frame and receives delta time (seconds since last frame). You must accumulate time yourself since Rive doesn't provide a global time value like AE does.
By the end of this exercise, you will be able to Complete the advance function to track elapsed time and frame count. When you reach frame 120, print the elapsed time (which should be approximately 2 seconds at 60fps).
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_Exercise2FrameTimer
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Track time across frames using advance()
export type FrameTimer = {
elapsed: number,
tick: number,
}
function init(self: FrameTimer): boolean
print("Timer started!")
self.elapsed = 0
self.tick = 0
return true
end
function advance(self: FrameTimer, seconds: number): boolean
-- TODO 1: Add seconds to self.elapsed
-- TODO 2: Increment self.tick by 1
-- TODO 3: When tick is 60, print "Frame 60: ~{elapsed:.2f}s elapsed"
-- TODO 4: When tick is 120, print "Frame 120: ~{elapsed:.2f}s elapsed"
-- Also print "ANSWER: {elapsed:.2f}"
return true
end
function draw(self: FrameTimer, renderer: Renderer)
end
return function(): Node<FrameTimer>
return {
init = init,
advance = advance,
draw = draw,
elapsed = 0,
tick = 0,
}
end
Assignment
Complete these tasks:
- Complete the advance function to track elapsed time and frame count. When you reach frame 120, print the elapsed time (which should be approximately 2 seconds at 60fps).
- 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: Draw a Simple Shape ⭐⭐
Premise
Procedural graphics in Rive use the Path API (moveTo, lineTo, close) combined with Paint for styling. Create these objects in init(), then use Renderer:drawPath() in draw().
By the end of this exercise, you will be able to Complete the init function to create a triangle path (3 points) and a green fill paint. The draw function should render the path.
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_Exercise3DrawASimpleShape
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Draw a procedural triangle
export type DrawTriangle = {
path: Path,
paint: Paint,
}
function init(self: DrawTriangle): boolean
print("Creating triangle...")
-- TODO 1: Create a new Path using Path.new()
self.path = nil -- Replace with Path.new()
-- TODO 2: Build a triangle using moveTo, lineTo, lineTo, close
-- Point 1: (0, -40) - top
-- Point 2: (-40, 40) - bottom left
-- Point 3: (40, 40) - bottom right
-- TODO 3: Create a Paint with green fill color
-- Use Paint.with({ style = "fill", color = Color.rgb(80, 200, 120) })
self.paint = nil -- Replace with Paint.with(...)
print("Triangle created!")
print("ANSWER: triangle")
return true
end
function draw(self: DrawTriangle, renderer: Renderer)
-- TODO 4: Draw the path with the paint
-- Use renderer:drawPath(self.path, self.paint)
end
return function(): Node<DrawTriangle>
return {
init = init,
draw = draw,
path = late(),
paint = late(),
}
end
Assignment
Complete these tasks:
- Complete the init function to create a triangle path (3 points) and a green fill paint. The draw function should render the path.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Exercise 4: Complete Protocol Example ⭐⭐⭐
Premise
Production scripts combine multiple lifecycle functions. init() creates objects, advance() animates, update() responds to input changes, pointerDown() handles interaction, and draw() renders. This is the full Node protocol in action.
By the end of this exercise, you will be able to Complete this script that combines all lifecycle functions. Track clicks and print 'ANSWER: complete' when the user clicks 3 times.
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_Exercise4CompleteProtocolExample
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- A complete script using all major lifecycle functions
export type CompleteDemo = {
time: number,
clickCount: number,
path: Path,
paint: Paint,
}
function init(self: CompleteDemo): boolean
print("init: Script ready")
self.time = 0
self.clickCount = 0
self.path = Path.new()
self.path:moveTo(Vector.xy(-40, -40))
self.path:lineTo(Vector.xy(40, -40))
self.path:lineTo(Vector.xy(40, 40))
self.path:lineTo(Vector.xy(-40, 40))
self.path:close()
self.paint = Paint.with({ style = "fill", color = Color.rgb(80, 180, 255) })
return true
end
function advance(self: CompleteDemo, seconds: number): boolean
-- TODO 1: Add seconds to self.time
-- TODO 2: Every second (when floor of time changes), print "advance: Time {time:.2f}s"
return true
end
function pointerDown(self: CompleteDemo, event: PointerEvent)
-- TODO 3: Increment clickCount
-- TODO 4: Print "Click #{clickCount}"
-- TODO 5: When clickCount reaches 3, print "ANSWER: complete"
end
function draw(self: CompleteDemo, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end
return function(): Node<CompleteDemo>
return {
init = init,
advance = advance,
pointerDown = pointerDown,
draw = draw,
time = 0,
clickCount = 0,
path = late(),
paint = late(),
}
end
Assignment
Complete these tasks:
- Complete this script that combines all lifecycle functions. Track clicks and print 'ANSWER: complete' when the user clicks 3 times.
- 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. Missing the Factory Function
-- WRONG: Not returning a factory function
--!strict
export type MyNode = {}
function init(self: MyNode): boolean return true end
function draw(self: MyNode, renderer: Renderer) end
-- Script won't be recognized!
-- CORRECT: Return a factory function
return function(): Node<MyNode>
return { init = init, draw = draw }
end
2. Forgetting draw (Even If Empty)
-- WRONG: No draw function
return function(): Node<MyNode>
return { init = init } -- Script won't appear in menu!
end
-- CORRECT: Include draw even if empty
return function(): Node<MyNode>
return {
init = init,
draw = draw, -- Required!
}
end
3. Factory vs init() Object Creation
-- WORKS but not recommended: Creating in factory
return function(): Node<MyNode>
return {
init = init,
draw = draw,
path = Path.new(), -- Works, but shared across all instances!
}
end
-- RECOMMENDED: Use late() and initialize in init()
return function(): Node<MyNode>
return {
init = init,
draw = draw,
path = late(), -- Type-safe placeholder
}
end
function init(self: MyNode): boolean
self.path = Path.new() -- Each instance gets its own path
return true
end
Why init() is preferred: Objects created in the factory are shared. Creating in init() ensures each node instance gets unique objects.
Key Takeaway
Every Node script follows the same protocol. Once that structure is correct, you can focus on logic, inputs, and rendering. The protocol ensures Rive knows how to instantiate and run your script.
Next Steps
- Continue to Node Lifecycle
- Need a refresher? Review Quick Reference