Skip to main content

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

ConceptAfter Effects / JavaScriptLuau (Rive Scripts)
Script file.jsx or .js fileScript asset in Rive
Class definitionclass MyClass { }export type MyNode = { }
Constructorconstructor() { }init(self)
Instance methodthis.method()function method(self)
Factory patternnew MyClass() or createInstance()return function(): Node<T>
Required interfaceimplements InterfaceMust have init and draw
Exportexport default or module.exportsreturn function(): Node<T>
Critical Difference: The Factory Function Pattern

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:

  1. Create multiple instances of your script (one per attached node)
  2. Control when initialization happens (factory first, then init())
  3. 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:

  1. --!strict at the top
  2. Exported type for script state (export type)
  3. init(self) and draw(self, renderer) functions
  4. return function(): Node<YourType> factory function
Important

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

ScenarioUseExample
Numbers, booleans, strings with defaultsDefault valuespeed = 1.5
Objects created in init()late()path = late()
ViewModel propertieslate()scoreProp = late()
Editor-assigned inputslate()artboard = late()
Optional fields (may be nil)nilcallback = 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
Rule of Thumb

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

FunctionRequiredParametersReturnCalled When
init✅ Yesself, contextbooleanOnce at startup
draw✅ Yesself, renderernoneOn canvas repaint
updateOptionalselfnoneInput changes
advanceOptionalself, secondsbooleanEvery animation frame
pointerDownOptionalself, event: PointerEventnoneMouse/touch down
pointerMoveOptionalself, event: PointerEventnoneMouse/touch move
pointerUpOptionalself, event: PointerEventnoneMouse/touch up

How to Run Any Node Script

  1. Assets panel → + → Script → Node
  2. Paste the script
  3. Drag it onto a Node in the artboard
  4. Open the Console panel
  5. Press Play
Console Output

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.

Goal

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:

  1. Create the script:

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

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

  1. Complete the init function to print a greeting message and the answer. The script structure (export type, factory function) is already provided.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 2: 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.

Goal

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:

  1. Create the script:

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

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

  1. 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).
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 3: 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().

Goal

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:

  1. Create the script:

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

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

  1. Complete the init function to create a triangle path (3 points) and a green fill paint. The draw function should render the path.
  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: 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.

Goal

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:

  1. Create the script:

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

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

  1. Complete this script that combines all lifecycle functions. Track clicks and print 'ANSWER: complete' when the user clicks 3 times.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Knowledge Check

Q:What is the minimum set of functions required for a Node script to appear in Rive's add-to-scene menu?
Q:Where should you create Path and Paint objects in a Node script?
Q:What does the advance function's 'seconds' parameter represent?
Q:What happens if you return false from the init function?

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