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

-- 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: polygon

Verify Your Answer

Verify Your Answer

Checklist

  • 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

-- 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: star

Verify Your Answer

Verify Your Answer

Checklist

  • 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

-- 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: wave

Verify Your Answer

Verify Your Answer

Checklist

  • 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

-- 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: bezier

Verify Your Answer

Verify Your Answer

Checklist

  • 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

-- 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: particles

Verify Your Answer

Verify Your Answer

Checklist

  • 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

-- 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: ring

Verify Your Answer

Verify Your Answer

Checklist

  • 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

-- 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: bars

Verify Your Answer

Verify Your Answer

Checklist

  • 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

Path Winding and Fill Rules

When you build multiple contours into one Path, fill behavior follows an even-odd rule. Points with an odd number of contour crossings are filled. Points with an even number are unfilled.

If contour winding direction flips between clockwise (CW) and counter-clockwise (CCW), overlapping regions can cancel out and appear invisible.

Why Loops Can Break Winding

In radial loops (spikes, rays, bursts), the perpendicular offset (-sin(angle), cos(angle)) changes sign across quadrants. If you always emit vertices in the same local order, some contours end up CW and others CCW.

-- This can break: winding may flip across the loop
for i = 0, 9 do
local angle = (i / 10) * math.pi * 2
local ax = math.cos(angle)
local ay = math.sin(angle)
local px = -ay
local py = ax

path:moveTo(Vector.xy(cx + ax * 80 + px * 30, cy + ay * 80 + py * 30))
path:lineTo(Vector.xy(cx + ax * 350, cy + ay * 350))
path:lineTo(Vector.xy(cx + ax * 80 - px * 30, cy + ay * 80 - py * 30))
path:close()
end

Robust Fix: Cross Product Winding Check

Before you emit each contour, compute the 2D cross product of two triangle edges. If winding is CW, swap base vertices to enforce a consistent direction.

for i = 0, 9 do
local angle = (i / 10) * math.pi * 2
local ax = math.cos(angle)
local ay = math.sin(angle)
local px = -ay
local py = ax

local b1x = cx + ax * 80 + px * 30
local b1y = cy + ay * 80 + py * 30
local tx = cx + ax * 350
local ty = cy + ay * 350
local b2x = cx + ax * 80 - px * 30
local b2y = cy + ay * 80 - py * 30

-- (tip - b1) x (b2 - b1)
local cross = (tx - b1x) * (b2y - b1y) - (ty - b1y) * (b2x - b1x)

if cross > 0 then
-- Already CCW
path:moveTo(Vector.xy(b1x, b1y))
path:lineTo(Vector.xy(tx, ty))
path:lineTo(Vector.xy(b2x, b2y))
else
-- CW detected, swap base points
path:moveTo(Vector.xy(b2x, b2y))
path:lineTo(Vector.xy(tx, ty))
path:lineTo(Vector.xy(b1x, b1y))
end
path:close()
end

Choosing the Right Strategy

ApproachWinding Matters?Draw CallsBest For
Single path, multiple contoursYes, enforce consistent winding1Batched geometry and static effects
Separate path per shapeNo (single contour each draw)NPer-shape styling or per-shape animation

When This Applies

  • Loop-generated triangles or polygons at computed angles
  • Radial spike/ray/burst effects
  • Directional particle geometry
  • Any procedural mesh where vertices rotate around a center

When This Usually Does Not Apply

  • Axis-aligned rectangles with fixed vertex order
  • Circle/ellipse approximations built symmetrically
  • Single-contour shapes with no overlap interactions
Common Trap

This failure is usually silent: no warnings, no runtime errors, just missing fills. If a contour draws fine alone but disappears in a generated loop, validate winding direction first.


Exercise 8: Fix Conflicting Winding in a Spike Burst ⭐⭐⭐

Premise

A radial spike burst is generated as multiple triangle contours in one path. The geometry is valid, but inconsistent winding causes some spikes to disappear.

Goal

By the end of this exercise, you will be able to enforce consistent contour winding in a loop-generated burst using a cross-product check.

Use Case

This pattern shows up whenever you build behavior in Rive scripts.

Example scenarios:

  • Procedural sunburst effects
  • Audio-reactive radial shapes
  • Geometry generated from polar coordinates

Setup

In Rive Editor:

  1. Create the script:

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

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

    • View → Console

Starter Code

-- Fix disappearing spikes caused by inconsistent contour winding

export type WindingBurst = {
spikes: Input<number>,
baseRadius: Input<number>,
tipRadius: Input<number>,
halfWidth: Input<number>,
path: Path,
fill: Paint,
}

local function addSpike(path: Path, b1x: number, b1y: number, tx: number, ty: number, b2x: number, b2y: number)
-- Cross product: (tip - b1) x (b2 - b1)
local cross = (tx - b1x) * (b2y - b1y) - (ty - b1y) * (b2x - b1x)

-- TODO 1:
-- If cross > 0, emit b1 -> tip -> b2
-- Otherwise emit b2 -> tip -> b1 (swap to keep winding consistent)
-- Remember to close the contour
end

local function rebuildBurst(self: WindingBurst)
local n = math.max(3, math.floor(self.spikes))
local cx, cy = 0, 0

self.path:reset()

for i = 0, n - 1 do
local angle = (i / n) * math.pi * 2
local ax = math.cos(angle)
local ay = math.sin(angle)
local px = -ay
local py = ax

local b1x = cx + ax * self.baseRadius + px * self.halfWidth
local b1y = cy + ay * self.baseRadius + py * self.halfWidth
local tx = cx + ax * self.tipRadius
local ty = cy + ay * self.tipRadius
local b2x = cx + ax * self.baseRadius - px * self.halfWidth
local b2y = cy + ay * self.baseRadius - py * self.halfWidth

-- TODO 2: Route each triangle through addSpike(...)
end

print(`Built {n} spikes with consistent winding`)
print("ANSWER: winding-fixed")
end

function init(self: WindingBurst): boolean
self.path = Path.new()
self.fill = Paint.with({ style = "fill", color = Color.rgb(255, 190, 70) })
rebuildBurst(self)
return true
end

function update(self: WindingBurst)
rebuildBurst(self)
end

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

return function(): Node<WindingBurst>
return {
init = init,
update = update,
draw = draw,
spikes = 10,
baseRadius = 80,
tipRadius = 340,
halfWidth = 28,
path = late(),
fill = late(),
}
end

Assignment

Complete these tasks:

  1. Implement addSpike so every contour uses consistent winding.
  2. Call addSpike from the loop using the computed vertices.
  3. Run the script and verify the console output.
  4. Copy the ANSWER: line into the validator.

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: winding-fixed

Verify Your Answer

Verify Your Answer

Checklist

  • All TODOs are replaced with working code
  • Contours render consistently around the full circle
  • Console output includes the ANSWER: line

Key points:

  • Even-odd fill can cancel overlapping contours with mixed winding
  • Cross-product checks let you enforce contour order deterministically
  • This bug appears most often in radial loop-generated geometry

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