Performance Optimization
Write efficient Rive scripts by understanding when and where to do work.
Rive Context
Rive scripts run every frame. The biggest performance wins come from:
- Reusing objects (Path, Paint, tables)
- Doing heavy work in
updateinstead ofdraw - Avoiding per-frame allocations in hot loops
Optimization Checklist
- Build
PathandPaintonce ininit - Use
advanceto update numbers, not to allocate objects - Rebuild geometry in
updatewhen inputs change - Avoid
print()in tight loops - Limit per-frame loops (especially if you spawn many instances)
Exercise 1: Cache and Reuse Objects ⭐⭐
Premise
Creating objects every frame is expensive. Allocating Path and Paint once in init and reusing them eliminates per-frame allocation overhead.
By the end of this exercise, you will be able to Move object creation from the factory to init, and rebuild geometry in update without allocating new objects.
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_Exercise1CacheAndReuseObjects
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Optimize by caching Path and Paint objects
export type CachedCircle = {
radius: Input<number>,
initialized: boolean,
path: Path,
paint: Paint,
}
function init(self: CachedCircle): boolean
-- TODO 1: Create Path and Paint objects here (allocate once)
-- self.path = Path.new()
-- self.paint = Paint.with({ style = "fill", color = Color.rgb(80, 200, 120) })
self.initialized = true
print("Objects cached in init")
return true
end
function update(self: CachedCircle)
local r = self.radius
-- TODO 2: Rebuild geometry using path:reset() (no new allocation)
-- self.path:reset()
-- 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()
print(`Path rebuilt with radius: {r}`)
end
function draw(self: CachedCircle, renderer: Renderer)
if self.initialized then
print("Drawing with cached objects")
print("ANSWER: cached")
self.initialized = false -- Only print once
end
renderer:drawPath(self.path, self.paint)
end
return function(): Node<CachedCircle>
return {
init = init,
update = update,
draw = draw,
radius = 50,
initialized = false,
-- Objects start as late() - will be created in init
path = late(),
paint = late(),
}
end
Assignment
Complete these tasks:
- Move object creation from the factory to init, and rebuild geometry in update without allocating new objects.
- 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:
PathandPaintare created once ininit- Geometry is rebuilt in
updateusingpath:reset() drawonly renders, no allocations
Exercise 2: Update-Driven Rendering ⭐⭐
Premise
Update-driven rendering only rebuilds geometry when inputs change, not every frame. This pattern is essential for complex shapes that are expensive to rebuild.
By the end of this exercise, you will be able to Complete the rebuild helper function and call it from both init and update to only rebuild when necessary.
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_Exercise2UpdateDrivenRendering
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
Open the Console:
- View → Console
Starter Code
--!strict
-- Only rebuild geometry when inputs change
export type EfficientRect = {
width: Input<number>,
height: Input<number>,
buildCount: number,
path: Path,
paint: Paint,
}
-- TODO 1: Complete the rebuild helper function
local function rebuild(self: EfficientRect)
local halfW = self.width / 2
local halfH = self.height / 2
-- TODO: Reset path and build rectangle
-- self.path:reset()
-- self.path:moveTo(Vector.xy(-halfW, -halfH))
-- self.path:lineTo(Vector.xy(halfW, -halfH))
-- self.path:lineTo(Vector.xy(halfW, halfH))
-- self.path:lineTo(Vector.xy(-halfW, halfH))
-- self.path:close()
self.buildCount += 1
end
function init(self: EfficientRect): boolean
self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(120, 160, 255) })
self.buildCount = 0
-- TODO 2: Call rebuild for initial state
print(`Initial build: {self.width}x{self.height}`)
return true
end
function update(self: EfficientRect)
-- TODO 3: Call rebuild when inputs change
print(`Geometry rebuilt on input change: {self.width}x{self.height}`)
end
function draw(self: EfficientRect, renderer: Renderer)
if self.buildCount == 2 then
print("Drawing efficiently")
print("ANSWER: efficient")
self.buildCount += 1 -- Prevent repeated prints
end
renderer:drawPath(self.path, self.paint)
end
return function(): Node<EfficientRect>
return {
init = init,
update = update,
draw = draw,
width = 140,
height = 80,
buildCount = 0,
path = late(),
paint = late(),
}
end
Assignment
Complete these tasks:
- Complete the rebuild helper function and call it from both init and update to only rebuild when necessary.
- 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
How it works:
rebuildis a helper that reconstructs the path geometryinitcallsrebuildonce for the initial stateupdateis called when inputs change and rebuilds the geometrydrawremains lightweight, only rendering the cached path
Memory Management
Rive scripts run in a managed Luau VM with automatic garbage collection. You still need to control object lifetimes to avoid leaks and stutters.
Guidelines:
- Reuse objects in
drawandadvance(Path, Paint, tables). - Clear references when an object is no longer needed:
self.enemy = nil
self.tempPoints = {} - Avoid unbounded tables (lists that only grow). Prune or recycle entries.
- Use object pools for frequently spawned items (particles, bullets, stars).
- Prefer short-lived locals inside functions over storing long-lived globals.
Rule of thumb: If something is created every frame, it should be reused or pooled.
Performance Anti-Patterns
Avoid: Creating objects in draw
-- BAD: Creates new Path every frame
function draw(self, renderer: Renderer)
local path = Path.new() -- Allocation every frame!
path:moveTo(Vector.xy(0, 0))
-- ...
end
Avoid: Printing in loops
-- BAD: Console output every frame
function advance(self, seconds: number): boolean
for i, item in ipairs(self.items) do
print("Processing item " .. i) -- Slow!
end
return true
end
Prefer: Batch operations
-- GOOD: Single loop, minimal work
function advance(self, seconds: number): boolean
local dt = seconds
for _, item in ipairs(self.items) do
item.x = item.x + item.vx * dt
item.y = item.y + item.vy * dt
end
return true
end
Key Takeaways
- Fast scripts are about when and where you do work
- Cache objects in
init, rebuild on demand inupdate - Keep
drawas lightweight as possible - Avoid allocations in hot loops
- Use
context:markNeedsUpdate()to signal the system when redraw is needed
Next Steps
- Continue to Debugging
- Need a refresher? Review Quick Reference