Util Script Protocol
Learning Objectives
- Create reusable utility modules using Blank Script
- Share functions and types across Node scripts
- Use
require()to import utilities - Avoid common utility script pitfalls
AE/JS Syntax Comparison
| Concept | After Effects / JavaScript | Luau (Rive Scripts) |
|---|---|---|
| Module file | .js file with export | Util script asset |
| Export functions | export function fn() or module.exports | return ModuleTable |
| Import module | import { fn } from './utils' or require() | require("UtilName") |
| Namespace | Module object | Returned table (MyUtil.fn()) |
| Shared types | export type (TypeScript) | export type |
| Module caching | Node.js caches require() | Rive caches require() |
JavaScript Modules: Can have initialization code that runs when imported, side effects, and even maintain state.
Rive Util Scripts: Are PURE modules. They:
- ❌ Have no
init,draw,advance, orupdate - ❌ Cannot attach to nodes
- ❌ Should not have global mutable state
- ✅ Export functions and types only
- ✅ Are shared across ALL scripts that import them
-- Util script structure
local MyUtil = {}
function MyUtil.helper() end
return MyUtil -- Just return a table!
// JavaScript module equivalent
const MyUtil = {
helper: () => {}
};
export default MyUtil;
Rive Context: Where Reusable Logic Lives
Util scripts are Rive's way to share code across Node scripts. They have no lifecycle and simply return a table of functions and types.
Use Util scripts for:
- Math helpers and easing functions
- Geometry builders
- Reusable data structures (springs, timers, palettes)
- Constants and configuration
Unlike Node scripts, Util scripts have no init, draw, or advance functions. They are pure modules that export functions and types.
Util Script Template
--!strict
local MyUtil = {}
export type Range = {
min: number,
max: number,
}
function MyUtil.clamp(value: number, range: Range): number
return math.max(range.min, math.min(range.max, value))
end
return MyUtil
// JavaScript/TypeScript equivalent:
interface Range {
min: number;
max: number;
}
const MyUtil = {
clamp: (value: number, range: Range): number => {
return Math.max(range.min, Math.min(range.max, value));
}
};
export default MyUtil;
export type { Range };
Usage in a Node script:
local MyUtil = require("MyUtil")
local value = MyUtil.clamp(120, { min = 0, max = 100 })
Creating a Util Script
The Rive editor dropdown shows Blank Script—there is no "Util Script" option. To create a Util Script, select Blank Script and follow the pattern below. The official Rive documentation describes this as the "Util Script" protocol.
- Assets panel → + → Script → Blank Script
- Name your script (e.g., "MathUtils")
- Define your module table and functions
- Return the module table
Practice Exercises
Exercise 1: MathUtils Module ⭐⭐
Premise
Util scripts let you define reusable functions once and import them anywhere. The require() function loads the module by name, and functions are called with dot notation like MathUtils.lerp(). Unlike Node scripts, Util scripts have no lifecycle - they just return a table of functions.
By the end of this exercise, you will be able to Complete the init function to test the MathUtils module. Use lerp, clamp, and remap to calculate values and verify the module works correctly.
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_Exercise1MathutilsModule
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Test a MathUtils Util module
-- First, create a Util script named "MathUtils" with this code:
--[[
local MathUtils = {}
function MathUtils.clamp(value: number, min: number, max: number): number
return math.max(min, math.min(max, value))
end
function MathUtils.lerp(a: number, b: number, t: number): number
return a + (b - a) * t
end
function MathUtils.remap(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number
local t = (value - inMin) / (inMax - inMin)
return outMin + (outMax - outMin) * t
end
return MathUtils
]]
-- Then complete this Node script:
-- TODO 1: Require the MathUtils module
local MathUtils = nil -- Replace nil with require("MathUtils")
export type TestUtils = {}
function init(self: TestUtils): boolean
print("Testing MathUtils module...")
-- TODO 2: Use MathUtils.lerp(0, 100, 0.5) and print the result
-- Should print "lerp(0, 100, 0.5) = 50"
-- TODO 3: Use MathUtils.clamp(150, 0, 100) and print the result
-- Should print "clamp(150, 0, 100) = 100"
-- TODO 4: Use MathUtils.remap(50, 0, 100, 0, 200) and print the result
-- Should print "remap(50, 0, 100, 0, 200) = 100"
print("MathUtils working correctly!")
-- TODO 5: Calculate MathUtils.lerp(50, 100, 0.5) and print as ANSWER
-- (This should equal 75)
return true
end
function draw(self: TestUtils, renderer: Renderer)
end
return function(): Node<TestUtils>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Complete the init function to test the MathUtils module. Use lerp, clamp, and remap to calculate values and verify the module works correctly.
- 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
Exercise 2: Exported Types from Util Scripts ⭐⭐
Premise
Util scripts can export types using 'export type'. Other scripts access these types with ModuleName.TypeName syntax (e.g., Palette.Swatch). This ensures type-safe data structures are shared consistently across your entire project.
By the end of this exercise, you will be able to Complete the Node script to use the Palette module. Create swatches using factory functions and verify the exported type works correctly.
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_Exercise2ExportedTypesFromUtilScripts
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Test exported types from a Palette Util module
-- First, create a Util script named "Palette" with this code:
--[[
local Palette = {}
export type Swatch = {
primary: Color,
accent: Color,
neutral: Color,
}
function Palette.warm(): Swatch
return {
primary = Color.rgb(255, 120, 80),
accent = Color.rgb(255, 210, 120),
neutral = Color.rgb(60, 60, 60),
}
end
function Palette.cool(): Swatch
return {
primary = Color.rgb(80, 160, 255),
accent = Color.rgb(120, 220, 200),
neutral = Color.rgb(50, 50, 60),
}
end
function Palette.dark(): Swatch
return {
primary = Color.rgb(40, 40, 50),
accent = Color.rgb(200, 80, 120),
neutral = Color.rgb(30, 30, 35),
}
end
return Palette
]]
-- Then complete this Node script:
-- TODO 1: Require the Palette module
local Palette = nil -- Replace nil with require("Palette")
export type TestPalette = {
-- TODO 2: Add a field "currentSwatch" with type Palette.Swatch
paletteCount: number,
}
function init(self: TestPalette): boolean
print("Testing Palette module...")
self.paletteCount = 0
-- TODO 3: Create a warm swatch using Palette.warm()
-- Store it in a local variable "warm" and print "Created warm swatch"
-- TODO 4: Create a cool swatch using Palette.cool()
-- Store it in a local variable "cool" and print "Created cool swatch"
-- TODO 5: Create a dark swatch using Palette.dark()
-- Store it in a local variable "dark" and print "Created dark swatch"
-- TODO 6: Set self.paletteCount to 3 and print success message
-- Print "All 3 palettes created successfully!"
-- Print "ANSWER: 3"
return true
end
function draw(self: TestPalette, renderer: Renderer)
end
return function(): Node<TestPalette>
return {
init = init,
draw = draw,
paletteCount = 0,
}
end
Assignment
Complete these tasks:
- Complete the Node script to use the Palette module. Create swatches using factory functions and verify the exported type works correctly.
- 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
Exercise 3: Spring Animation Util ⭐⭐⭐
Premise
Spring physics create natural-feeling animations. The Spring util encapsulates the physics calculations (stiffness, damping, velocity) in reusable functions. Each Node script creates its own SpringState, while the physics logic is shared from the Util.
By the end of this exercise, you will be able to Complete the Node script to use the Spring module. Create a spring, set its target, update it each frame, and detect when it settles.
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_Exercise3SpringAnimationUtil
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Test spring physics from a Spring Util module
-- First, create a Util script named "Spring" with this code:
--[[
local Spring = {}
export type SpringState = {
position: number,
velocity: number,
target: number,
stiffness: number,
damping: number,
}
function Spring.new(initial: number, stiffness: number?, damping: number?): SpringState
return {
position = initial,
velocity = 0,
target = initial,
stiffness = stiffness or 150,
damping = damping or 10,
}
end
function Spring.setTarget(spring: SpringState, target: number)
spring.target = target
end
function Spring.update(spring: SpringState, dt: number): number
local displacement = spring.target - spring.position
local springForce = displacement * spring.stiffness
local dampingForce = spring.velocity * spring.damping
local acceleration = springForce - dampingForce
spring.velocity += acceleration * dt
spring.position += spring.velocity * dt
return spring.position
end
function Spring.isSettled(spring: SpringState, threshold: number?): boolean
local thresh = threshold or 0.01
local atTarget = math.abs(spring.target - spring.position) < thresh
local stopped = math.abs(spring.velocity) < thresh
return atTarget and stopped
end
return Spring
]]
-- Then complete this Node script:
-- TODO 1: Require the Spring module
local Spring = nil -- Replace nil with require("Spring")
export type TestSpring = {
spring: Spring.SpringState,
frame: number,
hasSettled: boolean,
}
function init(self: TestSpring): boolean
print("Spring test starting...")
-- TODO 2: Create a spring starting at position 0 using Spring.new(0)
-- Print "Created spring at position 0"
-- TODO 3: Set the spring target to 100 using Spring.setTarget()
-- Print "Target set to 100"
self.frame = 0
self.hasSettled = false
return true
end
function advance(self: TestSpring, seconds: number): boolean
self.frame += 1
-- TODO 4: Update the spring using Spring.update(self.spring, seconds)
-- Store the result in a local variable "pos"
-- TODO 5: On frame 1, print "Frame 1: position ~{pos:.2f}"
-- TODO 6: On frame 60, print "Frame 60: position ~{pos:.2f}"
-- TODO 7: Check if spring isSettled and hasn't printed yet
-- If settled and not self.hasSettled:
-- Print "Spring settled at target!"
-- Print "ANSWER: settled"
-- Set self.hasSettled = true
return true
end
function draw(self: TestSpring, renderer: Renderer)
end
return function(): Node<TestSpring>
return {
init = init,
advance = advance,
draw = draw,
spring = late(),
frame = 0,
hasSettled = false,
}
end
Assignment
Complete these tasks:
- Complete the Node script to use the Spring module. Create a spring, set its target, update it each frame, and detect when it settles.
- 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
Best Practices
Keep Util Scripts Focused
Each Util script should do one thing well:
MathUtils- math helpersPalette- color schemesSpring- spring physicsEasingFunctions- easing curves
Avoid Global State
-- BAD: Global state is shared across ALL script instances
local counter = 0
function MyUtil.increment()
counter += 1
return counter
end
-- GOOD: State is passed in and returned
function MyUtil.increment(counter: number): number
return counter + 1
end
// JavaScript equivalent of the problem:
// BAD: Module-level state shared everywhere
let counter = 0;
export const increment = () => ++counter;
// GOOD: Pure function
export const increment = (counter: number) => counter + 1;
Export Types for Type Safety
-- Types are available to importing scripts
export type Vec2 = { x: number, y: number }
function MyUtil.add(a: Vec2, b: Vec2): Vec2
return { x = a.x + b.x, y = a.y + b.y }
end
Knowledge Check
Common Mistakes
1. Adding Lifecycle Functions to Util Scripts
-- WRONG: Util scripts don't have lifecycle functions
local MyUtil = {}
function init(self) end -- This does nothing!
function MyUtil.helper() end
return MyUtil
-- CORRECT: Just return a table of functions
local MyUtil = {}
function MyUtil.helper() end
return MyUtil
2. Not Returning the Module Table
-- WRONG: Forgot to return
local MyUtil = {}
function MyUtil.helper() end
-- Missing return!
-- CORRECT: Always return the module table
local MyUtil = {}
function MyUtil.helper() end
return MyUtil
3. Mutable Global State
-- WRONG: Shared mutable state
local cache = {}
function MyUtil.getOrCreate(key)
-- This cache is shared by ALL scripts!
end
-- CORRECT: Let each script manage its own cache
function MyUtil.createCache()
return {}
end
function MyUtil.getOrCreate(cache, key)
-- Cache is owned by the calling script
end
Summary
- Util scripts are modules that export functions and types
- They have no lifecycle - just
returna table - Use
require()to import them in Node scripts - Keep them focused and avoid global state
- Export types for better type safety across scripts
Next Steps
- Continue to Listener Protocol
- Need a refresher? Review Quick Reference