Procedural Geometry
Before this section, complete:
- Core Types — Path, Paint, Renderer basics
- Drawing API — Transforms and clipping
- Functions — Math functions (sin, cos, floor)
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
| Method | Description |
|---|---|
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_Exercise1ProceduralPolygon
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the buildPolygon function to generate a regular polygon with N sides using trigonometry.
- 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:
- 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_Exercise2AnimatedStar
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the advance function to animate the star's inner radius and rebuild the path each frame.
- 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:
- Alternate between outer and inner radius for star points
- Animate
innerRatioinadvancefor 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_Exercise3SineWavePath
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the advance function to build an animated sine wave path.
- 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:
- Add
self.timeto 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_Exercise4BezierCurves
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the advance function to create an animated cubic bezier curve.
- 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:
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_Exercise5SimpleParticleSystem
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the advance function to spawn particles, update physics, and remove dead particles.
- 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
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 indraw - 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_Exercise6CircularProgressRing
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the buildArc helper function to generate arc paths based on start and end angles.
- 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:
- 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise7_Exercise7DataDrivenBarChart
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the advance function to animate bar values toward their targets using lerp interpolation.
- 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:
- 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
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
- Continue to Architecture
- Need a refresher? Review Quick Reference