Skip to main content

Script Architecture

Organize your Rive scripts for maintainability, reusability, and clarity.

Rive Context

Rive scripts fall into two patterns based on their purpose:

Script PatternPurposeHas LifecycleReturnsEditor Option
Node ScriptVisual behavior attached to Artboard nodesYes (init, advance, draw)Factory functionNode Script
Util ScriptReusable code shared across scriptsNoModule tableBlank Script
Creating Util Scripts

The editor dropdown shows Blank Script—select this to create a Util Script. The Rive docs describe the Util Script pattern you implement.

Understanding when to use each pattern is fundamental to writing clean, maintainable Rive projects.


Node vs Util: Decision Guide

Use a Node Script When:

  • You need to draw something on screen
  • You need per-frame updates (advance)
  • You need to respond to inputs
  • The code represents a visual behavior

Use a Util Script When:

  • You have math helpers or easing functions
  • You want to share types across scripts
  • You need configuration or constants
  • The code has no visual component

Exercise 1: Single Responsibility Principle ⭐⭐

Premise

Single Responsibility Principle keeps code maintainable. Util scripts hold pure functions with no side effects, while Node scripts handle visual behavior and state.

Goal

By the end of this exercise, you will be able to complete the Easing Util script by adding a lerp function, then use Easing.easeInOutQuad and Easing.lerp in the Node script's advance function.

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

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

    • View → Console

Starter Code

--!strict
-- File 1: Easing (Util Script)
-- Reusable easing functions with no visual component

local Easing = {}

function Easing.easeInQuad(t: number): number
return t * t
end

function Easing.easeOutQuad(t: number): number
return 1 - (1 - t) * (1 - t)
end

function Easing.easeInOutQuad(t: number): number
if t < 0.5 then
return 2 * t * t
else
return 1 - ((-2 * t + 2) ^ 2) / 2
end
end

-- TODO 1: Add a lerp function that interpolates between a and b
-- lerp(a, b, t) should return a + (b - a) * t

return Easing

--[[
File 2: AnimatedSquare (Node Script)
Visual behavior that uses the Easing util
]]

-- local Easing = require("Easing") -- In Rive, this imports the util

export type AnimatedSquare = {
duration: Input<number>,
startX: number,
endX: number,
elapsed: number,
x: number,
path: Path,
paint: Paint,
}

function init(self: AnimatedSquare): boolean
self.elapsed = 0
self.x = self.startX
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(100, 180, 255) })
print("Animation started")
print(`Position: {self.x:.2f} (eased: 0.00)`)
return true
end

function advance(self: AnimatedSquare, seconds: number): boolean
self.elapsed += seconds
local t = math.min(self.elapsed / self.duration, 1)

-- TODO 2: Calculate eased value using Easing.easeInOutQuad(t)
-- TODO 3: Calculate self.x using Easing.lerp(self.startX, self.endX, eased)

if t >= 1 then
print(`Position: {self.x:.2f} (eased: 1.00)`)
print("ANSWER: separation")
end

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

return true
end

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

return function(): Node<AnimatedSquare>
return {
init = init,
advance = advance,
draw = draw,
duration = 2,
startX = -100,
endX = 100,
elapsed = 0,
x = -100,
path = Path.new(),
paint = Paint.new(),
}
end

Assignment

Complete these tasks:

  1. Complete the Easing Util script by adding a lerp function, then use Easing.easeInOutQuad and Easing.lerp in the Node script's advance function.
  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:

  • The Node script handles visual behavior and state
  • The Util script provides pure functions with no side effects
  • Logic is testable and reusable

Exercise 2: State Management Patterns ⭐⭐

Premise

Union types and lookup tables make state machines type-safe and maintainable. The compiler catches invalid states, and configuration changes require updating only the lookup tables.

Goal

By the end of this exercise, you will be able to Complete the setState function to update targetScale and Paint using the lookup tables when the button state changes.

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

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

    • View → Console

Starter Code

--!strict
-- Interactive button with state machine pattern

export type ButtonState = "idle" | "hover" | "pressed"

export type InteractiveButton = {
state: ButtonState,
scale: number,
targetScale: number,
path: Path,
paint: Paint,
}

-- Lookup tables for state-specific values
local SCALE_BY_STATE: { [ButtonState]: number } = {
idle = 1.0,
hover = 1.1,
pressed = 0.95,
}

local COLOR_BY_STATE: { [ButtonState]: Color } = {
idle = Color.rgb(80, 140, 220),
hover = Color.rgb(100, 160, 240),
pressed = Color.rgb(60, 120, 200),
}

local function rebuildPath(self: InteractiveButton)
local halfSize = 40 * self.scale
self.path:reset()
self.path:moveTo(Vector.xy(-halfSize, -halfSize * 0.5))
self.path:lineTo(Vector.xy(halfSize, -halfSize * 0.5))
self.path:lineTo(Vector.xy(halfSize, halfSize * 0.5))
self.path:lineTo(Vector.xy(-halfSize, halfSize * 0.5))
self.path:close()
end

-- TODO: Complete the setState function
local function setState(self: InteractiveButton, newState: ButtonState)
self.state = newState
-- TODO 1: Set self.targetScale using SCALE_BY_STATE lookup table
-- TODO 2: Set self.paint using Paint.with and COLOR_BY_STATE lookup table

print(`State changed to: {newState}`)
print(`Scale target: {self.targetScale:.2f}, Color updated`)

if newState == "pressed" then
print("ANSWER: statemachine")
end
end

function init(self: InteractiveButton): boolean
self.state = "idle"
self.scale = 1.0
self.targetScale = 1.0
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = COLOR_BY_STATE.idle })
rebuildPath(self)
print(`Button initialized: {self.state}`)

-- Simulate state changes for demonstration
setState(self, "hover")
setState(self, "pressed")
return true
end

function advance(self: InteractiveButton, seconds: number): boolean
self.scale += (self.targetScale - self.scale) * math.min(seconds * 10, 1)
rebuildPath(self)
return true
end

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

return function(): Node<InteractiveButton>
return {
init = init,
advance = advance,
draw = draw,
state = "idle",
scale = 1.0,
targetScale = 1.0,
path = Path.new(),
paint = Paint.new(),
}
end

Assignment

Complete these tasks:

  1. Complete the setState function to update targetScale and paint using the lookup tables when the button state changes.
  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 union types ("idle" | "hover" | "pressed") for finite states
  • Store configuration in lookup tables
  • Keep state transitions in dedicated functions

Exercise 3: Layered Architecture ⭐⭐⭐

Premise

Layered architecture separates concerns into modules: Config for constants, Data modules for pure logic, and Node scripts for orchestration. Each layer can be tested and modified independently.

Goal

By the end of this exercise, you will be able to Complete the Particle.update function to apply physics, then complete the advance function to update all particles and remove dead ones.

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

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

    • View → Console

Starter Code

--!strict
-- Layered particle system with separate Config, Particle, and System modules

-- LAYER 1: Config (constants and configuration)
local Config = {
PARTICLE_COUNT = 50,
GRAVITY = 200,
SPAWN_RATE = 0.1,
}

-- LAYER 2: Particle (data structure and pure update logic)
local Particle = {}

export type ParticleData = {
x: number,
y: number,
vx: number,
vy: number,
life: number,
maxLife: number,
}

function Particle.new(x: number, y: number): ParticleData
local angle = math.random() * math.pi * 2
local speed = 50 + math.random() * 100
return {
x = x,
y = y,
vx = math.cos(angle) * speed,
vy = math.sin(angle) * speed,
life = 0,
maxLife = 1 + math.random() * 2,
}
end

-- TODO 1: Complete the update function
function Particle.update(p: ParticleData, dt: number, gravity: number): boolean
-- TODO 1a: Update position: p.x += p.vx * dt, p.y += p.vy * dt
-- TODO 1b: Apply gravity to velocity: p.vy += gravity * dt
-- TODO 1c: Increment lifetime: p.life += dt
-- TODO 1d: Return true if particle is still alive (p.life < p.maxLife)
return false -- Replace with correct return
end

function Particle.alpha(p: ParticleData): number
return 1 - (p.life / p.maxLife)
end

-- LAYER 3: ParticleSystem (orchestration and rendering)
export type ParticleSystem = {
particles: { ParticleData },
spawnTimer: number,
spawnCount: number,
path: Path,
paint: Paint,
}

function init(self: ParticleSystem): boolean
self.particles = {}
self.spawnTimer = 0
self.spawnCount = 0
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(255, 200, 100) })
print("Particle system initialized")
return true
end

function advance(self: ParticleSystem, seconds: number): boolean
-- Spawn new particles
self.spawnTimer += seconds
while self.spawnTimer >= Config.SPAWN_RATE do
self.spawnTimer -= Config.SPAWN_RATE
if #self.particles < Config.PARTICLE_COUNT and self.spawnCount < 3 then
table.insert(self.particles, Particle.new(0, 0))
self.spawnCount += 1
print(`Spawned particle {self.spawnCount}`)
end
end

-- TODO 2: Update existing particles and keep only alive ones
local alive = {}
-- TODO 2a: Loop through self.particles with ipairs
-- TODO 2b: Call Particle.update(p, seconds, Config.GRAVITY)
-- TODO 2c: If update returns true, insert p into alive table
self.particles = alive

if self.spawnCount >= 3 and #self.particles > 0 then
print(`Updating {#self.particles} particles...`)
print(`Particles alive: {#self.particles}`)
print("ANSWER: layered")
end

return true
end

function draw(self: ParticleSystem, renderer: Renderer)
for _, p in ipairs(self.particles) do
self.path:reset()
local size = 4
self.path:moveTo(Vector.xy(p.x - size, p.y - size))
self.path:lineTo(Vector.xy(p.x + size, p.y - size))
self.path:lineTo(Vector.xy(p.x + size, p.y + size))
self.path:lineTo(Vector.xy(p.x - size, p.y + size))
self.path:close()

local alpha = Particle.alpha(p)
self.paint = Paint.with({
style = "fill",
color = Color.rgba(255, 200, 100, alpha),
})
renderer:drawPath(self.path, self.paint)
end
end

return function(): Node<ParticleSystem>
return {
init = init,
advance = advance,
draw = draw,
particles = {},
spawnTimer = 0,
spawnCount = 0,
path = Path.new(),
paint = Paint.new(),
}
end

Assignment

Complete these tasks:

  1. Complete the Particle.update function to apply physics, then complete the advance function to update all particles and remove dead ones.
  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

Architecture breakdown:

  • Config - Constants and configuration types
  • Particle - Data structure and pure update logic
  • ParticleSystem - Orchestrates particles and handles rendering

Common Patterns

Factory Functions in Util Scripts

--!strict

local Timer = {}

export type TimerState = {
elapsed: number,
duration: number,
looping: boolean,
}

function Timer.new(duration: number, looping: boolean?): TimerState
return {
elapsed = 0,
duration = duration,
looping = looping or false,
}
end

function Timer.tick(timer: TimerState, dt: number): boolean
timer.elapsed += dt
if timer.elapsed >= timer.duration then
if timer.looping then
timer.elapsed = timer.elapsed % timer.duration
end
return true
end
return false
end

return Timer

Type-Safe Event Tables

--!strict

export type EventType = "spawn" | "destroy" | "collect"

export type Event = {
type: EventType,
timestamp: number,
data: { [string]: any }?,
}

local Events = {}

function Events.create(eventType: EventType, data: { [string]: any }?): Event
return {
type = eventType,
timestamp = os.clock(),
data = data,
}
end

return Events

Anti-Patterns to Avoid

Mixing Concerns in Node Scripts

-- BAD: Math utilities embedded in Node script
function advance(self, seconds: number): boolean
-- Don't define helpers inline
local function lerp(a, b, t)
return a + (b - a) * t
end
self.x = lerp(0, 100, self.t)
return true
end

God Util Scripts

-- BAD: One massive Util script with everything
local Utils = {}
Utils.lerp = ...
Utils.clamp = ...
Utils.createParticle = ...
Utils.parseColor = ...
Utils.formatTime = ...
-- 500 more functions...
return Utils

Circular Dependencies

-- BAD: A requires B, B requires A
-- File: A.lua
local B = require("B") -- B requires A!

-- GOOD: Extract shared code to a third module
-- File: Shared.lua (no dependencies)
-- File: A.lua requires Shared
-- File: B.lua requires Shared

Key Takeaways

  • Node scripts are for visual behavior with lifecycle
  • Util scripts are for pure functions and shared types
  • Keep scripts focused on a single responsibility
  • Use typed lookup tables for state management
  • Layer complex features: Config, Data, Orchestration
  • Avoid circular dependencies by extracting shared code

Q:When should you use a Util script instead of a Node script?
Q:What is the recommended way to handle finite states in a Node script?
Q:What is the problem with defining helper functions inside Node script lifecycle functions?
Q:How should you structure a complex feature with multiple concerns?

Next Steps