Skip to main content

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

ConceptAfter Effects / JavaScriptLuau (Rive Scripts)
Module file.js file with exportUtil script asset
Export functionsexport function fn() or module.exportsreturn ModuleTable
Import moduleimport { fn } from './utils' or require()require("UtilName")
NamespaceModule objectReturned table (MyUtil.fn())
Shared typesexport type (TypeScript)export type
Module cachingNode.js caches require()Rive caches require()
Critical Difference: No Lifecycle in Util Scripts

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, or update
  • ❌ 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
Key Difference

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

Editor vs Documentation

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.

  1. Assets panel → + → Script → Blank Script
  2. Name your script (e.g., "MathUtils")
  3. Define your module table and functions
  4. 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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_Exercise1MathutilsModule
  2. Attach and run:

    • Attach to any shape and press Play
  3. 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:

  1. Complete the init function to test the MathUtils module. Use lerp, clamp, and remap to calculate values and verify the module works correctly.
  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


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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_Exercise2ExportedTypesFromUtilScripts
  2. Attach and run:

    • Attach to any shape and press Play
  3. 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:

  1. Complete the Node script to use the Palette module. Create swatches using factory functions and verify the exported type works correctly.
  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


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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise3_Exercise3SpringAnimationUtil
  2. Attach and run:

    • Attach to any shape and press Play
  3. 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:

  1. Complete the Node script to use the Spring module. Create a spring, set its target, update it each frame, and detect when it settles.
  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


Best Practices

Keep Util Scripts Focused

Each Util script should do one thing well:

  • MathUtils - math helpers
  • Palette - color schemes
  • Spring - spring physics
  • EasingFunctions - 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

Q:What is the main difference between a Util script and a Node script?
Q:How do you import a Util script in a Node script?
Q:Why should you avoid global state in Util scripts?
Q:What does 'export type' do in a Util script?

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 return a 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