Script Architecture
Organize your Rive scripts for maintainability, reusability, and clarity.
Rive Context
Rive scripts fall into two patterns based on their purpose:
| Script Pattern | Purpose | Has Lifecycle | Returns | Editor Option |
|---|---|---|---|---|
| Node Script | Visual behavior attached to Artboard nodes | Yes (init, advance, draw) | Factory function | Node Script |
| Util Script | Reusable code shared across scripts | No | Module table | Blank Script |
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_Exercise1SingleResponsibilityPrinciple
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the Easing Util script by adding a lerp function, then use Easing.easeInOutQuad and Easing.lerp in the Node script's advance function.
- 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: <your result>
Verify Your Answer
Checklist
-
--!strictis 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_Exercise2StateManagementPatterns
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the setState function to update targetScale and paint using the lookup tables when the button state changes.
- 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: <your result>
Verify Your Answer
Checklist
-
--!strictis 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_Exercise3LayeredArchitecture
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the Particle.update function to apply physics, then complete the advance function to update all particles and remove dead ones.
- 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: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Architecture breakdown:
Config- Constants and configuration typesParticle- Data structure and pure update logicParticleSystem- 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
Next Steps
- Continue to Performance
- Need a refresher? Review Quick Reference