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
  • Add Node scripts as script nodes in the Artboard hierarchy

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, context)
Instance methodthis.method()function method(self)
Factory patternnew MyClass() or createInstance()return function(): Node<T>
Required interfaceimplements InterfaceMust return a factory; implement the lifecycle functions you need
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 lists scripts that return a factory function matching the Node protocol.

Minimum requirements:

  1. Exported type for script state (export type)
  2. return function(): Node<YourType> factory function
  3. In practice, an init(self, context) function — every real script needs setup
draw is optional

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

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

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

FunctionRequiredParametersReturnCalled When
init✅ Yesself, contextbooleanOnce at startup
drawOptional (needed to render)self, renderernoneOn canvas repaint
updateOptionalselfnoneInput changes
advanceOptionalself, secondsbooleanEvery playback frame; design-mode settle passes (see below)
drawCanvasOptionalselfnoneCanvas/GPU render phase before draw
pointerDownOptionalself, event: PointerEventnoneMouse/touch down
pointerMoveOptionalself, event: PointerEventnoneMouse/touch move
pointerUpOptionalself, event: PointerEventnoneMouse/touch up
pointerExitOptionalself, event: PointerEventnonePointer leaves node
GPU and offscreen canvas callback

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() return value — playback vs design mode

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

  1. Assets panel → + → Script → Node
  2. Paste or write the script
  3. Save the script so it recompiles
  4. Add the script to the artboard
  5. Position it or nest the script node where its transform and draw order should live
  6. Open the Console panel
  7. Press Play
Script node placement

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.

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

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.

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. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play
  3. 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:

  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: hello

Verify Your Answer

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.

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. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play
  3. 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:

  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: 2.00

Verify Your Answer

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().

Wind your points clockwise

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.

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. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play
  3. 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:

  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: triangle

Verify Your Answer

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.

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. Add and run:

    • Add the script to the artboard, position or nest the script node, then press Play
  3. 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:

  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: complete

Verify Your Answer

Verify Your Answer

Checklist

  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Knowledge Check

Q:Is the draw function required for a Node script to appear in the 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
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