Skip to main content

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 update instead of draw
  • Avoiding per-frame allocations in hot loops

Optimization Checklist

  • Build Path and Paint once in init
  • Use advance to update numbers, not to allocate objects
  • Rebuild geometry in update when 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.

Goal

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:

  1. Create the script:

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

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

  1. Move object creation from the factory to init, and rebuild geometry in update without allocating new objects.
  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:

  • Path and Paint are created once in init
  • Geometry is rebuilt in update using path:reset()
  • draw only 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.

Goal

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:

  1. Create the script:

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

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

  1. Complete the rebuild helper function and call it from both init and update to only rebuild when necessary.
  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

How it works:

  1. rebuild is a helper that reconstructs the path geometry
  2. init calls rebuild once for the initial state
  3. update is called when inputs change and rebuilds the geometry
  4. draw remains 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 draw and advance (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 in update
  • Keep draw as lightweight as possible
  • Avoid allocations in hot loops
  • Use context:markNeedsUpdate() to signal the system when redraw is needed

Q:Where should you create Path and Paint objects for best performance?

Next Steps