Skip to main content

Procedural Geometry

Prerequisites

Before this section, complete:

Rive Context: Drawing Shapes with Code

Procedural geometry means creating shapes dynamically through code rather than importing static assets. In Rive Node Scripts, you build paths point-by-point using mathematical functions, enabling:

  • Shapes that respond to data or user Input
  • Animated geometry (pulsing circles, flowing waves)
  • Particle systems and effects
  • Visualizations driven by external data

The pattern: Build or update paths in advance, draw them in draw.


Path Building Basics

MethodDescription
path:moveTo(vec)Move pen to position (start a new subpath)
path:lineTo(vec)Draw straight line to position
path:cubicTo(c1, c2, end)Draw cubic bezier curve
path:close()Connect back to subpath start
path:reset()Clear all path data

Exercise 1: Procedural Polygon ⭐⭐

Premise

Regular polygons are built by distributing points evenly around a circle. The angle offset (-π/2) makes the first vertex point upward.

Goal

By the end of this exercise, you will be able to Complete the buildPolygon function to generate a regular polygon with N sides using trigonometry.

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_Exercise1ProceduralPolygon
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- Generate a regular polygon with N sides

export type Polygon = {
sides: Input<number>,
radius: Input<number>,
path: Path,
fill: Paint,
stroke: Paint,
}

local function buildPolygon(self: Polygon)
local n = math.floor(self.sides)
if n < 3 then n = 3 end
local r = self.radius

self.path:reset()

-- TODO 1: Loop from i = 0 to n - 1
-- TODO 2: Calculate angle = (i / n) * math.pi * 2 - math.pi / 2
-- TODO 3: Calculate x = math.cos(angle) * r, y = math.sin(angle) * r
-- TODO 4: If i == 0, moveTo; otherwise lineTo

self.path:close()
print(`Building {n}-sided polygon...`)
print("Polygon built!")
print("ANSWER: polygon")
end

function init(self: Polygon): boolean
buildPolygon(self)
return true
end

function update(self: Polygon)
buildPolygon(self)
end

function draw(self: Polygon, renderer: Renderer)
renderer:drawPath(self.path, self.fill)
renderer:drawPath(self.path, self.stroke)
end

return function(): Node<Polygon>
return {
init = init,
update = update,
draw = draw,
sides = 6,
radius = 80,
path = late(),
fill = Paint.with({ style = "fill", color = Color.rgb(100, 180, 255) }),
stroke = Paint.with({ style = "stroke", thickness = 3, color = Color.rgb(40, 80, 140) }),
}
end

Assignment

Complete these tasks:

  1. Complete the buildPolygon function to generate a regular polygon with N sides using trigonometry.
  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:

  • Calculate vertex positions using trigonometry
  • update() rebuilds when inputs change
  • Start angle offset (- math.pi / 2) points the first vertex upward

Exercise 2: Animated Star ⭐⭐

Premise

Stars alternate between outer and inner radius. Animating the innerRatio creates a pulsing effect as the path rebuilds each frame.

Goal

By the end of this exercise, you will be able to Complete the advance function to animate the star's inner radius and rebuild the path each frame.

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_Exercise2AnimatedStar
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- Animated star with pulsing inner radius

export type AnimatedStar = {
points: Input<number>,
outerRadius: Input<number>,
path: Path,
paint: Paint,
time: number,
innerRatio: number,
}

function init(self: AnimatedStar): boolean
self.time = 0
self.innerRatio = 0.5
print("Star initialized")
return true
end

function advance(self: AnimatedStar, seconds: number): boolean
-- TODO 1: Add seconds to time
self.time += seconds

-- TODO 2: Animate innerRatio = 0.3 + math.sin(self.time * 2) * 0.2

local n = math.floor(self.points)
if n < 3 then n = 3 end
local outer = self.outerRadius
local inner = outer * self.innerRatio

self.path:reset()

-- TODO 3: Loop from i = 0 to n * 2 - 1
-- TODO 4: Calculate angle, use outer radius for even i, inner for odd
-- TODO 5: moveTo for first point, lineTo for rest

self.path:close()

if math.floor(self.time) == 1 and math.floor(self.time - seconds) < 1 then
print("Star pulsing...")
print("ANSWER: star")
end
return true
end

function draw(self: AnimatedStar, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end

return function(): Node<AnimatedStar>
return {
init = init,
advance = advance,
draw = draw,
points = 5,
outerRadius = 100,
path = late(),
paint = Paint.with({ style = "fill", color = Color.rgb(255, 200, 60) }),
time = 0,
innerRatio = 0.5,
}
end

Assignment

Complete these tasks:

  1. Complete the advance function to animate the star's inner radius and rebuild the path each frame.
  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:

  • Alternate between outer and inner radius for star points
  • Animate innerRatio in advance for pulsing effect
  • Path rebuilt every frame when animating

Exercise 3: Sine Wave Path ⭐⭐

Premise

Adding time to the sine phase creates animation. More segments means smoother curves. Use stroke style for open paths.

Goal

By the end of this exercise, you will be able to Complete the advance function to build an animated sine wave path.

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_Exercise3SineWavePath
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- Animated sine wave path

export type SineWave = {
amplitude: Input<number>,
frequency: Input<number>,
segments: Input<number>,
path: Path,
paint: Paint,
time: number,
}

function init(self: SineWave): boolean
self.time = 0
print("Sine wave initialized")
return true
end

function advance(self: SineWave, seconds: number): boolean
self.time += seconds

local amp = self.amplitude
local freq = self.frequency
local segs = math.floor(self.segments)
local width = 300

self.path:reset()

-- TODO: Loop from i = 0 to segs
-- Calculate t = i / segs
-- Calculate x = -width/2 + t * width
-- Calculate y = math.sin((t * freq + self.time) * math.pi * 2) * amp
-- moveTo for first, lineTo for rest

if math.floor(self.time) == 1 and math.floor(self.time - seconds) < 1 then
print("Wave flowing...")
print("ANSWER: wave")
end
return true
end

function draw(self: SineWave, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end

return function(): Node<SineWave>
return {
init = init,
advance = advance,
draw = draw,
amplitude = 40,
frequency = 2,
segments = 50,
path = late(),
paint = Paint.with({ style = "stroke", thickness = 4, color = Color.rgb(120, 220, 180), cap = "round" }),
time = 0,
}
end

Assignment

Complete these tasks:

  1. Complete the advance function to build an animated sine wave path.
  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:

  • Add self.time to the phase for animation
  • More segments = smoother curve
  • Use stroke style for line-based paths

Exercise 4: Bezier Curves ⭐⭐

Premise

cubicTo creates smooth curves using two control points. Animating the control points creates elegant flowing motion.

Goal

By the end of this exercise, you will be able to Complete the advance function to create an animated cubic bezier curve.

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 Exercise4_Exercise4BezierCurves
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- Animated cubic bezier curve

export type BezierDemo = {
path: Path,
paint: Paint,
time: number,
}

function init(self: BezierDemo): boolean
self.time = 0
print("Bezier demo initialized")
return true
end

function advance(self: BezierDemo, seconds: number): boolean
self.time += seconds

-- TODO 1: Calculate offset = math.sin(self.time * 1.5) * 30
local offset = 0 -- Replace with animated value

-- TODO 2: Reset path and build curve
-- moveTo(-120, 0)
-- cubicTo(Vector(-60, -80 + offset), Vector(60, 80 - offset), Vector(120, 0))
self.path:reset()

if math.floor(self.time) == 1 and math.floor(self.time - seconds) < 1 then
print("Curve flowing...")
print("ANSWER: bezier")
end
return true
end

function draw(self: BezierDemo, renderer: Renderer)
renderer:drawPath(self.path, self.paint)
end

return function(): Node<BezierDemo>
return {
init = init,
advance = advance,
draw = draw,
path = late(),
paint = Paint.with({ style = "stroke", thickness = 5, color = Color.rgb(255, 100, 150), cap = "round" }),
time = 0,
}
end

Assignment

Complete these tasks:

  1. Complete the advance function to create an animated cubic bezier curve.
  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:

  • cubicTo(control1, control2, end) creates smooth curves
  • Animate control points for flowing motion
  • Bezier curves are resolution-independent

Exercise 5: Simple Particle System ⭐⭐⭐

Premise

Particle systems spawn objects, update their physics, and remove dead ones. Filter arrays to efficiently remove particles.

Goal

By the end of this exercise, you will be able to Complete the advance function to spawn particles, update physics, and remove dead particles.

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 Exercise5_Exercise5SimpleParticleSystem
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- Basic particle system with spawning and aging

type Particle = {
x: number, y: number,
vx: number, vy: number,
life: number, maxLife: number,
}

export type ParticleSystem = {
particles: { Particle },
spawnRate: Input<number>,
path: Path,
paint: Paint,
spawnTimer: number,
}

local function spawnParticle(): Particle
local angle = math.random() * math.pi * 2
local speed = 40 + math.random() * 60
return {
x = 0, y = 0,
vx = math.cos(angle) * speed,
vy = math.sin(angle) * speed,
life = 0,
maxLife = 1.0 + math.random() * 0.5,
}
end

function init(self: ParticleSystem): boolean
self.particles = {}
self.spawnTimer = 0
print("Particle system started")
return true
end

function advance(self: ParticleSystem, seconds: number): boolean
self.spawnTimer += seconds
local spawnInterval = 1 / self.spawnRate

-- TODO 1: Spawn particles when timer exceeds interval
-- while self.spawnTimer >= spawnInterval do
-- self.spawnTimer -= spawnInterval
-- if #self.particles < 100 then
-- table.insert(self.particles, spawnParticle())
-- end
-- end

-- TODO 2: Update particles and filter out dead ones
-- For each particle: p.life += seconds
-- If still alive: update position (x += vx*s, y += vy*s), add gravity (vy += 50*s)
-- Keep only alive particles in a new array

if #self.particles > 5 then
print("Particles spawning...")
print("ANSWER: particles")
end
return true
end

function draw(self: ParticleSystem, renderer: Renderer)
for _, p in ipairs(self.particles) do
local alpha = 1 - (p.life / p.maxLife)
local size = 4 * alpha
self.path:reset()
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()
self.paint.color = Color.rgba(255, 180, 80, math.floor(alpha * 255))
renderer:drawPath(self.path, self.paint)
end
end

return function(): Node<ParticleSystem>
return {
init = init,
advance = advance,
draw = draw,
particles = {},
spawnRate = 20,
path = late(),
paint = Paint.with({ style = "fill", color = Color.rgb(255, 180, 80) }),
spawnTimer = 0,
}
end

Assignment

Complete these tasks:

  1. Complete the advance function to spawn particles, update physics, and remove dead particles.
  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

Performance Note

This example modifies the path in draw for simplicity. For production particle systems, consider creating a pool of paths or using instanced rendering techniques.

Key points:

  • Store particle data in a table
  • Update physics in advance, render in draw
  • Remove dead particles by filtering the array
  • Fade alpha based on remaining life

Exercise 6: Circular Progress Ring ⭐⭐

Premise

Progress rings are built as arcs. The foreground arc length is based on progress (0-1), starting from -π/2 (top) and sweeping clockwise.

Goal

By the end of this exercise, you will be able to Complete the buildArc helper function to generate arc paths based on start and end angles.

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 Exercise6_Exercise6CircularProgressRing
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- Circular progress ring with background and foreground arcs

export type ProgressRing = {
progress: Input<number>,
radius: Input<number>,
thickness: Input<number>,
bgPath: Path,
fgPath: Path,
bgPaint: Paint,
fgPaint: Paint,
}

local function buildArc(path: Path, radius: number, startAngle: number, endAngle: number, segments: number)
path:reset()

-- TODO 1: Loop from i = 0 to segments
-- TODO 2: Calculate t = i / segments
-- TODO 3: Calculate angle = startAngle + (endAngle - startAngle) * t
-- TODO 4: Calculate x = cos(angle) * radius, y = sin(angle) * radius
-- TODO 5: moveTo for first point, lineTo for rest
end

function init(self: ProgressRing): boolean
local r = self.radius
buildArc(self.bgPath, r, 0, math.pi * 2, 64)
print("Progress ring initialized")
print(`Progress at {math.floor(self.progress * 100)}%`)
print("ANSWER: ring")
return true
end

function update(self: ProgressRing)
local r = self.radius
local prog = math.clamp(self.progress, 0, 1)
local startAngle = -math.pi / 2
local endAngle = startAngle + prog * math.pi * 2

buildArc(self.bgPath, r, 0, math.pi * 2, 64)
buildArc(self.fgPath, r, startAngle, endAngle, math.floor(64 * prog) + 1)

self.bgPaint.thickness = self.thickness
self.fgPaint.thickness = self.thickness
end

function draw(self: ProgressRing, renderer: Renderer)
renderer:drawPath(self.bgPath, self.bgPaint)
renderer:drawPath(self.fgPath, self.fgPaint)
end

return function(): Node<ProgressRing>
return {
init = init,
update = update,
draw = draw,
progress = 0.7,
radius = 60,
thickness = 8,
bgPath = late(),
fgPath = late(),
bgPaint = Paint.with({ style = "stroke", thickness = 8, color = Color.rgba(255, 255, 255, 40), cap = "round" }),
fgPaint = Paint.with({ style = "stroke", thickness = 8, color = Color.rgb(100, 200, 255), cap = "round" }),
}
end

Assignment

Complete these tasks:

  1. Complete the buildArc helper function to generate arc paths based on start and end angles.
  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:

  • Build arcs by calculating points around a circle
  • Use stroke style with cap = "round" for smooth ends
  • Segment count affects smoothness vs performance

Exercise 7: Data-Driven Bar Chart ⭐⭐⭐

Premise

Bar charts interpolate toward target values for smooth animation. Each bar's x position is calculated from its index, and colors are generated procedurally.

Goal

By the end of this exercise, you will be able to Complete the advance function to animate bar values toward their targets using lerp interpolation.

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 Exercise7_Exercise7DataDrivenBarChart
  2. Attach and run:

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict
-- Animated bar chart with data-driven values

export type BarChart = {
values: { number },
targetValues: { number },
barWidth: Input<number>,
maxHeight: Input<number>,
path: Path,
paint: Paint,
animating: boolean,
}

function init(self: BarChart): boolean
self.values = { 0, 0, 0, 0, 0 }
self.targetValues = { 60, 80, 45, 90, 70 }
self.animating = true
print(`Bar chart initialized with {#self.targetValues} bars`)
return true
end

function advance(self: BarChart, seconds: number): boolean
local speed = 3

-- TODO 1: Loop through targetValues with ipairs
-- TODO 2: Get current value: self.values[i] or 0
-- TODO 3: Lerp toward target: current + (target - current) * min(1, seconds * speed)
-- TODO 4: Store the new value back in self.values[i]

-- Check if animation is mostly complete (values close to targets)
if self.animating then
local done = true
for i, target in ipairs(self.targetValues) do
if math.abs((self.values[i] or 0) - target) > 1 then
done = false
break
end
end
if done then
self.animating = false
print("Bars animating...")
print("ANSWER: bars")
end
end

return true
end

function draw(self: BarChart, renderer: Renderer)
local barW = self.barWidth
local maxH = self.maxHeight
local gap = 10
local totalWidth = #self.values * (barW + gap) - gap
local startX = -totalWidth / 2

for i, val in ipairs(self.values) do
local height = (val / 100) * maxH
local x = startX + (i - 1) * (barW + gap)
local y = maxH / 2

self.path:reset()
self.path:moveTo(Vector.xy(x, y))
self.path:lineTo(Vector.xy(x + barW, y))
self.path:lineTo(Vector.xy(x + barW, y - height))
self.path:lineTo(Vector.xy(x, y - height))
self.path:close()

-- Generate rainbow colors from index
local hue = (i - 1) / #self.values
local r = math.floor(255 * (0.5 + 0.5 * math.sin(hue * math.pi * 2)))
local g = math.floor(255 * (0.5 + 0.5 * math.sin(hue * math.pi * 2 + 2)))
local b = math.floor(255 * (0.5 + 0.5 * math.sin(hue * math.pi * 2 + 4)))
self.paint.color = Color.rgb(r, g, b)

renderer:drawPath(self.path, self.paint)
end
end

return function(): Node<BarChart>
return {
init = init,
advance = advance,
draw = draw,
values = {},
targetValues = {},
barWidth = 30,
maxHeight = 120,
path = late(),
paint = Paint.with({ style = "fill", color = Color.rgb(100, 180, 255) }),
animating = true,
}
end

Assignment

Complete these tasks:

  1. Complete the advance function to animate bar values toward their targets using lerp interpolation.
  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:

  • Animate values toward targets for smooth transitions
  • Calculate positions based on array index
  • Generate colors procedurally from index

Optimization Tips

1. Minimize Path Rebuilds

If geometry doesn't change, build it once in init:

function init(self: Static): boolean
self.path = Path.new()
self.path:moveTo(Vector.xy(-50, -50))
self.path:lineTo(Vector.xy(50, -50))
self.path:lineTo(Vector.xy(50, 50))
self.path:lineTo(Vector.xy(-50, 50))
self.path:close()
return true
end

2. Use Transforms Instead of Rebuilding

Instead of changing path coordinates, use renderer transforms:

function draw(self: Spinning, renderer: Renderer)
renderer:save()
renderer:transform(Mat2D.withRotation(self.angle))
renderer:drawPath(self.path, self.paint)
renderer:restore()
end

3. Limit Particle Counts

Set maximum limits and remove particles efficiently:

if #self.particles < MAX_PARTICLES then
table.insert(self.particles, newParticle)
end

Knowledge Check

Q:Where should you rebuild dynamic path geometry?
Q:What does path:cubicTo(c1, c2, end) create?
Q:How do you animate a star shape's inner radius?
Q:What is the best way to move a static shape around the screen?
Q:How do you remove dead particles from a particle system?

Key Takeaway

Procedural geometry gives you complete control over shapes through code. Build paths using trigonometry and math functions, update them in advance, and render them in draw. For moving or rotating static shapes, prefer renderer transforms over rebuilding geometry.


Next Steps