Skip to main content

Drawing API

Prerequisites

Before this section, complete:

Rive Context: The Renderer Is a State Machine

The Renderer maintains a transform stack and clip state. Think of it as a canvas with layers of transformations. Always pair save() with restore() so transforms and clips don't leak into later draws.

The golden rule: Every save() needs a matching restore().


Renderer Methods

MethodDescription
renderer:save()Push current state onto the stack
renderer:restore()Pop state from the stack
renderer:transform(mat)Apply a transformation matrix
renderer:clipPath(path)Restrict drawing to inside the path
renderer:drawPath(path, paint)Draw a path with the given paint

Mat2D Transforms

Mat2D provides 2D transformation matrices for translation, rotation, and scale.

MethodDescription
Mat2D.withTranslation(x, y)Move position
Mat2D.withRotation(radians)Rotate around origin
Mat2D.withScale(sx, sy)Scale horizontally and vertically

Transforms are applied in order and accumulate. The order matters: translate-then-rotate produces different results than rotate-then-translate.


Exercise 1: Translate and Rotate a Shape ⭐⭐

Premise

The renderer maintains a transform stack. save() pushes state, transform() applies matrices, restore() pops state. Always pair save/restore.

Goal

By the end of this exercise, you will be able to Complete the advance and draw functions to create a rotating rectangle. Increment angle each frame and apply rotation in draw.

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

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

    • View → Console

Starter Code

--!strict
-- Apply translation and rotation transforms

export type TransformDemo = {
path: Path,
paint: Paint,
angle: number,
}

function init(self: TransformDemo): boolean
self.angle = 0

self.path = Path.new()
self.path:moveTo(Vector.xy(-40, -20))
self.path:lineTo(Vector.xy(40, -20))
self.path:lineTo(Vector.xy(40, 20))
self.path:lineTo(Vector.xy(-40, 20))
self.path:close()

self.paint = Paint.with({ style = "fill", color = Color.rgb(255, 140, 80) })

print("Transform demo initialized")
return true
end

function advance(self: TransformDemo, seconds: number): boolean
-- TODO 1: Add seconds to self.angle

-- TODO 2: Every second (when floor changes), print the angle
-- Use: print(`Rotating: angle = {self.angle:.2f}`)

-- TODO 3: After 2 seconds, print the answer

return true
end

function draw(self: TransformDemo, renderer: Renderer)
-- TODO 4: Save renderer state

-- TODO 5: Apply rotation using Mat2D.withRotation(self.angle)
-- renderer:transform(Mat2D.withRotation(self.angle))

-- TODO 6: Draw the path

-- TODO 7: Restore renderer state
end

return function(): Node<TransformDemo>
return {
init = init,
advance = advance,
draw = draw,
path = late(),
paint = late(),
angle = 0,
}
end

Assignment

Complete these tasks:

  1. Complete the advance and draw functions to create a rotating rectangle. Increment angle each frame and apply rotation in draw.
  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:

  • save() preserves the current renderer state
  • Multiple transform() calls accumulate
  • restore() returns to the saved state
  • Animate values in advance, apply them in draw

Exercise 2: Multiple Instances with save/restore ⭐⭐

Premise

save/restore in a loop lets you draw the same path multiple times with different transforms. Each iteration is isolated.

Goal

By the end of this exercise, you will be able to Complete the draw function to render 5 copies of the square at different positions and rotations.

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

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

    • View → Console

Starter Code

--!strict
-- Draw the same path multiple times with different transforms

export type CloneDemo = {
path: Path,
paint: Paint,
}

function init(self: CloneDemo): boolean
self.path = Path.new()
self.path:moveTo(Vector.xy(-20, -20))
self.path:lineTo(Vector.xy(20, -20))
self.path:lineTo(Vector.xy(20, 20))
self.path:lineTo(Vector.xy(-20, 20))
self.path:close()

self.paint = Paint.with({ style = "fill", color = Color.rgb(90, 200, 255) })

print("Clones initialized")
print("Drawing 5 clones...")
print("ANSWER: 5")
return true
end

function draw(self: CloneDemo, renderer: Renderer)
-- TODO 1: Create a for loop from i = 1 to 5
-- TODO 2: Inside the loop:
-- a) Call renderer:save()
-- b) Apply translation: Mat2D.withTranslation(-120 + i * 60, 0)
-- c) Apply rotation: Mat2D.withRotation(i * 0.4)
-- d) Draw the path
-- e) Call renderer:restore()
end

return function(): Node<CloneDemo>
return {
init = init,
draw = draw,
path = late(),
paint = late(),
}
end

Assignment

Complete these tasks:

  1. Complete the draw function to render 5 copies of the square at different positions and rotations.
  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:

  • Each loop iteration gets its own save/restore pair
  • Without restore, transforms would accumulate incorrectly
  • One path object can render multiple times with different transforms

Exercise 3: Animated Polyline ⭐⭐

Premise

Dynamic paths are rebuilt in advance() using reset(). Never call reset() in draw()—it can cause rendering artifacts.

Goal

By the end of this exercise, you will be able to Complete the advance function to rebuild the path as an animated wave 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 Exercise3_Exercise3AnimatedPolyline
  2. Attach and run:

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

    • View → Console

Starter Code

--!strict
-- Update path data dynamically in advance

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

function init(self: WavyLine): boolean
self.time = 0
self.path = Path.new()
self.paint = Paint.with({ style = "stroke", thickness = 3, color = Color.rgb(255, 220, 80) })
print("Wavy line initialized")
print("Wave animating...")
print("ANSWER: wavy")
return true
end

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

-- TODO 2: Reset the path to clear previous frame's geometry
-- self.path:reset()

-- TODO 3: Loop from i = 0 to 12 to create 13 points
-- For each point:
-- Calculate x = -150 + i * 25
-- Calculate y = math.sin(self.time * 2 + i) * 20
-- If i == 0: use moveTo, otherwise use lineTo

return true
end

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

return function(): Node<WavyLine>
return {
init = init,
advance = advance,
draw = draw,
time = 0,
path = late(),
paint = late(),
}
end

Assignment

Complete these tasks:

  1. Complete the advance function to rebuild the path as an animated wave 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

Important

path:reset() should only be called in advance, never in draw. Calling reset in draw can cause rendering artifacts because the path data changes during the render pass.

Key points:

  • Rebuild path geometry in advance, not in draw
  • path:reset() clears all existing path commands
  • Use conditionals to handle the first point (moveTo) vs subsequent points (lineTo)

Exercise 4: Clipping a Region ⭐⭐

Premise

clipPath() restricts all subsequent drawing to inside the clip region. The clip is part of renderer state and cleared by restore().

Goal

By the end of this exercise, you will be able to Complete the draw function to clip content to the square region, then draw a stroke around the clip boundary.

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

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

    • View → Console

Starter Code

--!strict
-- Use clipPath to restrict drawing to a specific area

export type ClipDemo = {
clipPath: Path,
contentPath: Path,
fill: Paint,
stroke: Paint,
}

function init(self: ClipDemo): boolean
-- Clip region: 160x160 square
self.clipPath = Path.new()
self.clipPath:moveTo(Vector.xy(-80, -80))
self.clipPath:lineTo(Vector.xy(80, -80))
self.clipPath:lineTo(Vector.xy(80, 80))
self.clipPath:lineTo(Vector.xy(-80, 80))
self.clipPath:close()

-- Content: wider rectangle that extends beyond clip
self.contentPath = Path.new()
self.contentPath:moveTo(Vector.xy(-140, -20))
self.contentPath:lineTo(Vector.xy(140, -20))
self.contentPath:lineTo(Vector.xy(140, 20))
self.contentPath:lineTo(Vector.xy(-140, 20))
self.contentPath:close()

self.fill = Paint.with({ style = "fill", color = Color.rgb(255, 200, 60) })
self.stroke = Paint.with({ style = "stroke", thickness = 4, color = Color.rgb(60, 60, 60) })

print("Clip demo initialized")
print("Content clipped to region!")
print("ANSWER: clipped")
return true
end

function draw(self: ClipDemo, renderer: Renderer)
-- TODO 1: Save renderer state
-- TODO 2: Apply clip using renderer:clipPath(self.clipPath)
-- TODO 3: Draw the content path (it will be clipped)
-- TODO 4: Restore renderer state (clears the clip)

-- TODO 5: Draw the clip boundary stroke (outside save/restore)
end

return function(): Node<ClipDemo>
return {
init = init,
draw = draw,
clipPath = late(),
contentPath = late(),
fill = late(),
stroke = late(),
}
end

Assignment

Complete these tasks:

  1. Complete the draw function to clip content to the square region, then draw a stroke around the clip boundary.
  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:

  • clipPath restricts all subsequent drawing to the clipped area
  • The clip is part of the renderer state and is cleared by restore()
  • Draw the clip boundary stroke outside the save/restore to show the frame

Transform Order Matters

Transforms apply in the order you call them. Consider these two approaches:

-- Approach 1: Translate, then rotate
renderer:transform(Mat2D.withTranslation(100, 0))
renderer:transform(Mat2D.withRotation(math.pi / 4))
-- Shape moves right 100px, THEN rotates around its new position

-- Approach 2: Rotate, then translate
renderer:transform(Mat2D.withRotation(math.pi / 4))
renderer:transform(Mat2D.withTranslation(100, 0))
-- Shape rotates first, THEN moves in the rotated direction

These produce very different results. When in doubt, use save/restore to isolate transform groups.


Common Patterns

Center-Rotate Pattern

To rotate around a shape's center:

renderer:save()
renderer:transform(Mat2D.withTranslation(centerX, centerY))
renderer:transform(Mat2D.withRotation(angle))
renderer:transform(Mat2D.withTranslation(-centerX, -centerY))
renderer:drawPath(self.path, self.paint)
renderer:restore()

Scale from Center Pattern

To scale from a shape's center:

renderer:save()
renderer:transform(Mat2D.withTranslation(centerX, centerY))
renderer:transform(Mat2D.withScale(scaleX, scaleY))
renderer:transform(Mat2D.withTranslation(-centerX, -centerY))
renderer:drawPath(self.path, self.paint)
renderer:restore()

Knowledge Check

Q:What happens if you forget to call renderer:restore() after renderer:save()?
Q:Where should you call path:reset() for dynamic paths?
Q:What is the purpose of renderer:clipPath()?
Q:If you apply translate(100, 0) then rotate(PI/4), where does the shape end up?
Q:How do you draw the same path 5 times with different rotations?

Key Takeaway

Use save() and restore() for every isolated transform or clip. It keeps your rendering predictable and clean. Build paths in init or advance, and keep draw focused solely on issuing renderer commands.


Next Steps