Node Protocol
Learning Objectives
- Understand the complete Node script structure
- Know which functions are required vs optional
- Master the factory function pattern
- Add Node scripts as script nodes in the Artboard hierarchy
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, context) |
| Instance method | this.method() | function method(self) |
| Factory pattern | new MyClass() or createInstance() | return function(): Node<T> |
| Required interface | implements Interface | Must return a factory; implement the lifecycle functions you need |
| 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 lists scripts that return a factory function matching the Node protocol.
Minimum requirements:
- Exported type for script state (
export type) return function(): Node<YourType>factory function- In practice, an
init(self, context)function — every real script needs setup
draw is not required for a script to appear in the add-to-scene menu. Editor-validated (Early Access, 2026-06-11): a Node script with only init and advance compiles cleanly, appears in the add menu, places as a script node, and runs its lifecycle — it just renders nothing. Omit draw for logic-only scripts (timers, data orchestration); this also avoids an unnecessary per-repaint call. Include draw whenever the script renders.
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, context: Context): 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
export type MyNode = {
-- Persistent fields live here
}
function init(self: MyNode, context: Context): 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 | Optional (needed to render) | self, renderer | none | On canvas repaint |
update | Optional | self | none | Input changes |
advance | Optional | self, seconds | boolean | Every playback frame; design-mode settle passes (see below) |
drawCanvas | Optional | self | none | Canvas/GPU render phase before draw |
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 |
pointerExit | Optional | self, event: PointerEvent | none | Pointer leaves node |
Use drawCanvas(self) for Canvas:beginFrame(...) and GPUCanvas:beginRenderPass(...) work. Use draw(self, renderer) afterward to composite the resulting canvas.image or gpuCanvas.image.
advance behaves differently depending on whether the artboard is playing (editor-validated, Early Access, 2026-06-11):
Playback (Play button / runtime): advance is called every frame with real delta time. Return true to keep receiving advance calls; return false to unsubscribe this instance from the advance loop. draw is fully independent — it keeps firing on every repaint either way (a false-returning test node received zero advance calls during ~10 s of playback while drawing ~600 frames).
Design mode (editing, not playing): there is no advance loop and no clock. advance fires as a settle pass with seconds = 0 whenever the artboard changes (moving objects, pointer over the canvas, recompiles), and the return value is ignored. Elapsed time never accumulates at design time.
Practical rules: never divide by seconds or assume it is non-zero; returning false is a playback optimization for nodes that have finished animating, not a way to stop drawing; draw can also fire before the first advance.
How to Run Any Node Script
- Assets panel → + → Script → Node
- Paste or write the script
- Save the script so it recompiles
- Add the script to the artboard
- Position it or nest the script node where its transform and draw order should live
- Open the Console panel
- Press Play
A Node script is added as its own script node in the artboard hierarchy. Nesting that script node under a group or shape affects normal hierarchy behavior such as transform inheritance, opacity, and draw order. Node script callbacks do not receive host NodeData or existing shape PathData; use a Path Effect script when you need the path data of an existing stroke or fill.
print() statements in your script appear in Rive's Console panel, not your browser's developer console.
Practice Exercises
Exercise 1: Hello Rive ⭐
Premise
Node scripts implement lifecycle functions such as init() and draw(). 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 →
-
Add and run:
- Add the script to the artboard, position or nest the script node, then press Play
-
Open the Console:
- View → Console
Starter Code
-- Your first Node script
export type HelloRive = {}
function init(self: HelloRive, context: Context): 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: hello
Verify Your Answer
Checklist
- 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 →
-
Add and run:
- Add the script to the artboard, position or nest the script node, then press Play
-
Open the Console:
- View → Console
Starter Code
-- Track time across frames using advance()
export type FrameTimer = {
elapsed: number,
tick: number,
}
function init(self: FrameTimer, context: Context): 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: 2.00
Verify Your Answer
Checklist
- 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().
The current Rive renderer fills clockwise-wound contours. If you connect the same three points counter-clockwise (top → bottom-left → bottom-right), the script compiles and runs but the triangle renders invisible. Order your points clockwise on screen — and remember that y points down in artboard coordinates. Editor-validated 2026-06-12 with side-by-side CW/CCW triangles; reported by community member belal-sweileh in issue #2.
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 →
-
Add and run:
- Add the script to the artboard, position or nest the script node, then press Play
-
Open the Console:
- View → Console
Starter Code
-- Draw a procedural triangle
export type DrawTriangle = {
path: Path,
paint: Paint,
}
function init(self: DrawTriangle, context: Context): 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
-- Wind the points CLOCKWISE or the fill will not render (see note above):
-- Point 1: (0, -40) - top
-- Point 2: (40, 40) - bottom right
-- Point 3: (-40, 40) - bottom left
-- 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: triangle
Verify Your Answer
Checklist
- 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 →
-
Add and run:
- Add the script to the artboard, position or nest the script node, then press Play
-
Open the Console:
- View → Console
Starter Code
-- A complete script using all major lifecycle functions
export type CompleteDemo = {
time: number,
clickCount: number,
path: Path,
paint: Paint,
}
function init(self: CompleteDemo, context: Context): 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: complete
Verify Your Answer
Checklist
- 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
export type MyNode = {}
function init(self: MyNode, context: Context): 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. Expecting Visuals Without draw
-- VALID but renders nothing: draw-less scripts list, place, and run
return function(): Node<MyNode>
return { init = init, advance = advance } -- Fine for logic-only scripts
end
-- To actually render, include draw:
return function(): Node<MyNode>
return {
init = init,
draw = draw, -- Needed for anything visual
}
end
A script node that "doesn't show up on the canvas" usually isn't broken — it just has no draw (or its draw issues no renderer commands). Omitting draw on purpose is a valid optimization for timers and data-orchestration scripts.
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, context: Context): 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