Skip to main content

Debugging Rive Scripts

Master the tools and techniques for finding and fixing issues in your Rive scripts.

Rive Context

Rive scripts run inside the Rive runtime, not in a traditional development environment. Debugging relies on:

  • Console Panel - View print() output and runtime messages
  • Problems Tab - See pre-execution errors (type mismatches, syntax errors)
  • Strict Mode - Catch type errors before runtime
  • Systematic Logging - Strategic print statements to trace execution

Script Panels

Rive provides four panels for script development: Console, Problems, Changes, and Testing.

Console

Shows all runtime output during playback:

  • print() statements from your scripts
  • Runtime error messages
  • Script lifecycle events

Features:

  • Copy button - Copy console contents to clipboard
  • Clear button - Clear console output
  • Fullscreen - Expand for better visibility
Console Location

Open via View > Console or the panel menu to see print() output from your scripts.

Problems

Shows pre-execution errors that are detected before your script runs:

  • Type mismatches - When types don't match (e.g., assigning string to number)
  • Syntax errors - Typos, missing keywords, invalid Luau syntax
  • Binding errors - Missing or incorrect input/output bindings

Features:

  • Click an error to jump to the affected line in your script
  • Hover for explanations and suggested fixes
  • Errors update in real-time as you type
Problems vs Console
  • Problems: Shows errors detected by the type checker BEFORE running (like TypeScript)
  • Console: Shows errors that occur DURING runtime (like JavaScript console)

Check Problems first — if there are errors there, fix them before running your script.


The print() Function

print() is your primary debugging tool. Output appears in Rive's Console panel.

--!strict

export type DebugDemo = {
counter: number,
}

function init(self: DebugDemo): boolean
print("Script initialized")
print("Counter starts at:", self.counter)
return true
end

function advance(self: DebugDemo, seconds: number): boolean
self.counter += 1

-- Only print occasionally to avoid flooding the console
if self.counter % 60 == 0 then
print(`Tick: {self.counter}, Elapsed: {self.counter / 60:.1f}s`)
end

return true
end

function draw(self: DebugDemo, renderer: Renderer)
end

return function(): Node<DebugDemo>
return {
init = init,
advance = advance,
draw = draw,
counter = 0,
}
end

Expected Console Output:

Script initialized
Counter starts at: 0
Tick: 60, Elapsed: 1.0s
Tick: 120, Elapsed: 2.0s

Common Errors and Fixes

Use these patterns to diagnose the most frequent Rive scripting errors.

1. attempt to index nil with 'x'

Cause: A late() value was never assigned in the editor, or you are accessing a Node that doesn't exist.

Fix:

  • Assign the Input in the Inspector (for Input<T> and late()).
  • Guard access if it can be optional:
if self.target then
self.target.opacity = 1
end

2. Type 'number' does not have key 'value'

Cause: You treated a script Input<number> like a ViewModel property.

Fix:

  • Use self.someInput directly (it is the number).
  • Use context:viewModel() only for ViewModel properties.
-- Input<T> (read-only value)
local speed = self.speed

-- ViewModel (has .value)
local vm = context:viewModel()
local speedProp = vm and vm:getNumber("speed")
if speedProp then
speedProp.value = speedProp.value + 1
end

3. attempt to call a nil value

Cause: You forgot to return a lifecycle function from the factory.

Fix: Make sure the returned table includes the function:

return function(): Node<MyType>
return {
init = init,
draw = draw,
advance = advance,
}
end

4. Type errors in Paint.with(...) or Vector.xy(...)

Cause: Wrong types for fields (e.g., string instead of Color, number instead of Vector).

Fix: Match the API signature exactly:

local paint = Paint.with({ style = "stroke", thickness = 2, color = Color.rgb(255, 0, 0) })
local pos = Vector.xy(100, 50)

Exercise 1: Tracing Execution Flow ⭐

Premise

Understanding when lifecycle functions are called is essential for debugging. init runs once, update runs when inputs change, advance runs every frame.

Goal

By the end of this exercise, you will be able to Add print statements to trace when each lifecycle function is called, including the frame count in advance.

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_Exercise1TracingExecutionFlow
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- Lifecycle tracer to understand when functions are called

export type LifecycleTracer = {
name: Input<string>,
frameCount: number,
path: Path,
paint: Paint,
}

function init(self: LifecycleTracer): boolean
-- TODO 1: Print that init was called, include self.name
-- Example: print(`[{self.name}] init called`)

self.frameCount = 0
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(100, 150, 200) })
return true
end

function update(self: LifecycleTracer)
print(`[{self.name}] update called - input changed`)
end

function advance(self: LifecycleTracer, seconds: number): boolean
self.frameCount += 1

-- TODO 2: Print advance info for first 3 frames only
-- Include: self.name, self.frameCount, seconds (formatted to 4 decimals)
-- Example format: [Tracer] advance #1, dt=0.0167

if self.frameCount == 3 then
print("ANSWER: lifecycle")
end

return true
end

function draw(self: LifecycleTracer, renderer: Renderer)
-- Note: Avoid printing in draw - it runs frequently on repaint!
self.path:reset()
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()
renderer:drawPath(self.path, self.paint)
end

return function(): Node<LifecycleTracer>
return {
init = init,
update = update,
advance = advance,
draw = draw,
name = "Tracer",
frameCount = 0,
path = Path.new(),
paint = Paint.new(),
}
end

Assignment

Complete these tasks:

  1. Add print statements to trace when each lifecycle function is called, including the frame count in advance.
  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

Key points:

  • Print in init to confirm initialization
  • Limit prints in advance to avoid console spam
  • Never print in draw - it runs frequently on repaint

Exercise 2: Debugging State Changes ⭐⭐

Premise

Tracking state changes over time helps identify issues like values not updating, updating incorrectly, or oscillating unexpectedly. Helper functions ensure consistent formatting.

Goal

By the end of this exercise, you will be able to Complete the logState helper function and add calls to track state in init and during significant movement.

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_Exercise2DebuggingStateChanges
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- State debugger to track how values change over time

export type StateDebugger = {
targetX: Input<number>,
currentX: number,
velocity: number,
moveCount: number,
path: Path,
paint: Paint,
}

-- TODO 1: Complete the logState helper function
local function logState(label: string, self: StateDebugger)
-- Print format: [{label}] target={targetX}, current={currentX:.2f}, vel={velocity:.2f}
-- TODO: Use print with string interpolation
end

function init(self: StateDebugger): boolean
self.currentX = 0
self.velocity = 0
self.moveCount = 0
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(80, 180, 120) })

-- TODO 2: Call logState with "INIT" label
return true
end

function update(self: StateDebugger)
logState("INPUT CHANGED", self)
end

function advance(self: StateDebugger, seconds: number): boolean
local oldX = self.currentX

-- Spring physics
local displacement = self.targetX - self.currentX
local springForce = displacement * 100
local dampingForce = self.velocity * 10

self.velocity += (springForce - dampingForce) * seconds
self.currentX += self.velocity * seconds

-- TODO 3: Log significant changes only (when deltaX > 1)
local deltaX = math.abs(self.currentX - oldX)
-- If deltaX > 1, call logState with "MOVING" label and increment moveCount

if self.moveCount == 2 then
print("ANSWER: statetrack")
end

-- Rebuild path
self.path:reset()
local size = 20
self.path:moveTo(Vector.xy(self.currentX - size, -size))
self.path:lineTo(Vector.xy(self.currentX + size, -size))
self.path:lineTo(Vector.xy(self.currentX + size, size))
self.path:lineTo(Vector.xy(self.currentX - size, size))
self.path:close()

return true
end

function draw(self: StateDebugger, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end

return function(): Node<StateDebugger>
return {
init = init,
update = update,
advance = advance,
draw = draw,
targetX = 100,
currentX = 0,
velocity = 0,
moveCount = 0,
path = Path.new(),
paint = Paint.new(),
}
end

Assignment

Complete these tasks:

  1. Complete the logState helper function and add calls to track state in init and during significant movement.
  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

Key points:

  • Use a helper function (logState) for consistent formatting
  • Add labels to identify where the log came from
  • Only log significant changes to avoid console spam

Common Errors and Fixes

Error: Script Not Appearing in Menu

Symptom: Your script does not show up when trying to add it to a node.

Cause: Missing draw function or incorrect return type.

-- BAD: Missing draw function
return function(): Node<MyType>
return {
init = init,
-- No draw!
}
end

-- GOOD: Include draw even if empty
return function(): Node<MyType>
return {
init = init,
draw = draw,
}
end

Error: "attempt to index nil"

Symptom: Runtime error when accessing a property.

Cause: Field not initialized or wrong name.

-- BAD: Accessing uninitialized field
function init(self: MyNode): boolean
self.path:reset() -- self.path is nil!
return true
end

-- GOOD: Initialize first
function init(self: MyNode): boolean
self.path = Path.new() -- Create it
self.path:reset() -- Now safe
return true
end

Error: Type Mismatch

Symptom: Strict mode error about incompatible types.

Cause: Wrong type annotation or assignment.

-- BAD: Type mismatch
export type MyNode = {
count: number,
}

function init(self: MyNode): boolean
self.count = "hello" -- Error: string vs number
return true
end

-- GOOD: Correct type
function init(self: MyNode): boolean
self.count = 0 -- Correct: number
return true
end

Error: "Unknown global"

Symptom: Strict mode error about undefined variable.

Cause: Typo in variable name or missing declaration.

-- BAD: Typo in variable name
function advance(self: MyNode, seconds: number): boolean
self.elasped += seconds -- Typo: "elasped" vs "elapsed"
return true
end

-- GOOD: Correct spelling
function advance(self: MyNode, seconds: number): boolean
self.elapsed += seconds -- Correct
return true
end

Exercise 3: Debugging a Broken Script ⭐⭐

Premise

Common bugs include uninitialized fields (nil errors), typos in property names, and accessing undefined fields. Strict mode catches many of these at edit time.

Goal

By the end of this exercise, you will be able to Find and fix the three bugs in this script: missing initialization, wrong property name, and incorrect field access.

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_Exercise3DebuggingABrokenScript
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- This script has 3 bugs - find and fix them!

export type BuggyCircle = {
radius: Input<number>,
path: Path,
paint: Paint,
}

function init(self: BuggyCircle): boolean
-- BUG 1: self.path methods are called but path may not be initialized
-- TODO: Add self.path = Path.new() before using it
self.path:reset()

local r = self.radius
self.path:moveTo(Vector.xy(-r, 0))
self.path:quadTo(Vector.xy(0, -r), Vector.xy(r, 0))
self.path:quadTo(Vector.xy(0, r), Vector.xy(-r, 0))
self.path:close()

-- BUG 2: "colour" is not a field in our type - should be "paint"
-- TODO: Fix the property name and use Paint.with({...})
self.colour = Color.rgb(255, 100, 100)

print(`Circle initialized with radius: {r}`)
return true
end

function advance(self: BuggyCircle, seconds: number): boolean
-- BUG 3: "rad" is not a field - should be "radius"
-- TODO: Fix the field name
local r = self.rad

if r == self.radius then
print("Radius check passed")
print("All bugs fixed!")
print("ANSWER: debugged")
end
return true
end

function draw(self: BuggyCircle, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end

return function(): Node<BuggyCircle>
return {
init = init,
advance = advance,
draw = draw,
radius = 50,
path = Path.new(),
paint = Paint.new(),
}
end

Assignment

Complete these tasks:

  1. Find and fix the three bugs in this script: missing initialization, wrong property name, and incorrect field access.
  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


Strict Mode Errors

Strict mode catches errors at edit time. Here are common ones:

Unknown Global

--!strict

function init(self: MyNode): boolean
prnt("hello") -- Error: Unknown global 'prnt'
print("hello") -- Correct
return true
end

Missing Type Annotation

--!strict

-- Error: Missing type annotation
function helper(x)
return x * 2
end

-- Correct: With types
function helper(x: number): number
return x * 2
end

Incompatible Assignment

--!strict

export type Counter = {
count: number,
}

function init(self: Counter): boolean
self.count = nil -- Error: nil incompatible with number
self.count = 0 -- Correct
return true
end

Debugging Workflow

Step 1: Identify the Symptom

  • Script not appearing? Check for draw function.
  • Runtime error? Check the error message for line number.
  • Wrong behavior? Add print statements.

Step 2: Add Strategic Logging

function advance(self: MyNode, seconds: number): boolean
print("advance START")
print(" seconds:", seconds)
print(" self.value:", self.value)

-- Your logic here

print("advance END, result:", self.value)
return true
end

Step 3: Isolate the Problem

Comment out sections until the error disappears:

function init(self: MyNode): boolean
print("Step 1")
self.path = Path.new()

print("Step 2")
-- self.path:moveTo(Vector.xy(0, 0)) -- Comment out

print("Step 3")
-- self.path:close() -- Comment out

return true
end

Step 4: Fix and Verify

After fixing, remove debug prints or guard them:

local DEBUG = false

function advance(self: MyNode, seconds: number): boolean
if DEBUG then
print("advance:", seconds)
end
return true
end

Performance Debugging

Detecting Slow Code

--!strict

export type PerfTest = {
frameCount: number,
totalTime: number,
}

function init(self: PerfTest): boolean
self.frameCount = 0
self.totalTime = 0
return true
end

function advance(self: PerfTest, seconds: number): boolean
self.frameCount += 1
self.totalTime += seconds

-- Report average frame time every 60 frames
if self.frameCount % 60 == 0 then
local avgMs = (self.totalTime / self.frameCount) * 1000
print(`Average frame time: {avgMs:.2f}ms`)
end

return true
end

function draw(self: PerfTest, renderer: Renderer)
end

return function(): Node<PerfTest>
return {
init = init,
advance = advance,
draw = draw,
frameCount = 0,
totalTime = 0,
}
end

Key Takeaways

  • Use print() strategically - too much output hides important messages
  • Add labels to identify where logs come from
  • Use strict mode to catch type errors early
  • Initialize all fields before using them
  • Comment out code to isolate problems
  • Remove debug prints before finalizing
  • Set Debug Level to "Full" during development for detailed error messages (see Script Compilation Settings)

Q:Where does print() output appear in Rive?
Q:Why should you avoid calling print() inside the draw function?
Q:What is the most common cause of 'attempt to index nil' errors?
Q:What is the best way to isolate a bug when you don't know where it is?

Next Steps