Skip to main content

ViewModels

Prerequisites

Before this section, complete:

Rive Context: ViewModels Connect Data to Graphics

ViewModels are Rive's data binding system. They let you expose properties (numbers, strings, booleans, colors, enums, lists) that can be controlled from:

  • The Rive runtime (your app)
  • State machines
  • Other Node Scripts

Key concepts:

  • ViewModels define a schema of properties
  • Properties are reactive: changes propagate automatically
  • Scripts can read, write, and listen to ViewModel properties
  • Use context:viewModel() for the Artboard's main ViewModel
  • Use Input<Data.X> for nested ViewModel references

Accessing the Main ViewModel

Every artboard can have a ViewModel bound to it. Access it through the context parameter in init.

--!strict

export type VMReader = {}

function init(self: VMReader, context: Context): boolean
local vm = context:viewModel()
if vm then
print("ViewModel found!")
else
print("No ViewModel bound to this artboard.")
end
return true
end

function draw(self: VMReader, renderer: Renderer)
end

return function(): Node<VMReader>
return {
init = init,
draw = draw,
}
end

ViewModel Property Access Methods

MethodReturnsDescription
vm:getNumber(name)Property<number>?Get a numeric property
vm:getString(name)Property<string>?Get a string property
vm:getBoolean(name)Property<boolean>?Get a boolean property
vm:getColor(name)Property<Color>?Get a color property
vm:getEnum(name)Property<string>?Get an enum property (returns string)
vm:getList(name)List?Get a list property
vm:getTrigger(name)Trigger?Get a trigger

All getters return nil if the property doesn't exist. Always check before using.


Context Type Reference

The Context object is passed to init() and provides access to the script's environment.

function init(self: MyNode, context: Context): boolean
-- Context methods available here
return true
end

Context Methods

MethodReturnsDescription
context:viewModel()ViewModel?Get the artboard's bound ViewModel
context:artboard()ArtboardGet the artboard this script is attached to
context:node()NodeGet the node this script is attached to

Context Usage Patterns

function init(self: MyNode, context: Context): boolean
-- Get the ViewModel for data binding
local vm = context:viewModel()
if vm then
self.score = vm:getNumber("score")
end

-- Get artboard for dimensions or nested components
local artboard = context:artboard()

-- Get the node this script is attached to
local node = context:node()

return true
end
Context is Only Available in init()

The context parameter is only passed to init(). If you need ViewModel access in other callbacks, store the ViewModel reference in your script's state during init.


Trigger Type Reference

Triggers are one-shot signals in ViewModels. Unlike properties, they don't hold a value - they fire an event that listeners respond to.

Getting a Trigger

local trigger = vm:getTrigger("onClick")

Trigger Methods

MethodParametersDescription
trigger:addListener(callback)() -> ()Subscribe to trigger fires
trigger:removeListener(callback)() -> ()Unsubscribe from trigger
trigger:fire()noneManually fire the trigger

Trigger Example

function init(self: MyNode, context: Context): boolean
local vm = context:viewModel()
if not vm then return false end

local onClick = vm:getTrigger("onClick")
if onClick then
onClick:addListener(function()
print("Trigger fired!")
self.clickCount += 1
end)
end

return true
end

When to Use Triggers vs Properties

Use CaseUse TriggerUse Property
Button clickYesNo
Animation completeYesNo
Score valueNoYes (number)
Toggle stateNoYes (boolean)
One-time eventsYesNo
Persistent stateNoYes

List Type Reference

Lists are ordered collections of ViewModel instances. Each item in a list has its own set of properties defined by the list's item schema.

Getting a List

local items = vm:getList("inventory")

List Properties and Methods

Property/MethodReturnsDescription
list.countnumberNumber of items in the list (read-only)
list:item(index)ViewModel?Get item at index (1-based)
list:addListener(callback)() -> ()Listen for list changes
list:removeListener(callback)() -> ()Stop listening

List Example

function init(self: MyNode, context: Context): boolean
local vm = context:viewModel()
if not vm then return false end

local inventory = vm:getList("items")
if inventory then
print("Inventory has", inventory.count, "items")

-- Iterate through items
for i = 1, inventory.count do
local item = inventory:item(i)
if item then
local name = item:getString("name")
local qty = item:getNumber("quantity")
if name and qty then
print(name.value, "x", qty.value)
end
end
end

-- Listen for changes
inventory:addListener(function()
print("Inventory changed! New count:", inventory.count)
end)
end

return true
end

List Indexing

Lists Use 1-Based Indexing

Like all Luau arrays, list indices start at 1, not 0.

local first = list:item(1)   -- First item
local last = list:item(list.count) -- Last item

Exercise 1: Read and Display ViewModel Data ⭐⭐

Premise

ViewModels provide reactive properties that scripts can read. Use context:viewModel() in init, then access properties with getNumber(), getString(), etc.

Goal

By the end of this exercise, you will be able to Complete the init function to get the ViewModel and score property, then read its value.

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

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

    • View → Console

Starter Code

--!strict
-- Read a score property from the main ViewModel

export type ScoreDisplay = {
vm: ViewModel?,
scoreProp: Property<number>?,
path: Path,
paint: Paint,
barWidth: number,
}

function init(self: ScoreDisplay, context: Context): boolean
self.barWidth = 0

-- TODO 1: Get the ViewModel using context:viewModel()
self.vm = nil -- Replace with context:viewModel()

if self.vm then
print("ViewModel connected!")

-- TODO 2: Get the score property using vm:getNumber("score")
self.scoreProp = nil -- Replace with self.vm:getNumber("score")

if self.scoreProp then
-- TODO 3: Read the value and set barWidth = value * 2
print(`Score property found: {self.scoreProp.value}`)
print("ANSWER: viewmodel")
end
end

self.path = Path.new()
self.paint = Paint.with({ style = "fill", color = Color.rgb(80, 200, 120) })

return true
end

function advance(self: ScoreDisplay, seconds: number): boolean
if self.scoreProp then
self.barWidth = self.scoreProp.value * 2
end
return true
end

function draw(self: ScoreDisplay, renderer: Renderer)
self.path:reset()
self.path:moveTo(Vector.xy(-100, -10))
self.path:lineTo(Vector.xy(-100 + self.barWidth, -10))
self.path:lineTo(Vector.xy(-100 + self.barWidth, 10))
self.path:lineTo(Vector.xy(-100, 10))
self.path:close()

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

return function(): Node<ScoreDisplay>
return {
init = init,
advance = advance,
draw = draw,
vm = nil,
scoreProp = nil,
path = late(),
paint = late(),
barWidth = 0,
}
end

Assignment

Complete these tasks:

  1. Complete the init function to get the ViewModel and score property, then read its value.
  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

Editor setup:

  1. Create a ViewModel with a score number property (default: 50)
  2. Bind the ViewModel to your artboard
Path Reset in Draw

For this introductory exercise, path:reset() is in draw() to keep the code simple. Since the path is rebuilt completely each frame and only uses local state, this approach works fine. For more complex scenarios with path animations, consider moving rebuild logic to advance().

Key points:

  • Access .value to read the property's current value
  • Store the property reference to avoid repeated lookups
  • Check for nil before using properties

Exercise 2: Writing to ViewModel Properties ⭐⭐

Premise

ViewModel properties are read/write. Assign to .value to update them—changes automatically propagate to all bound visual elements.

Goal

By the end of this exercise, you will be able to Complete the advance function to animate the position properties using sine and cosine waves.

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

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

    • View → Console

Starter Code

--!strict
-- Drive ViewModel position properties from script

export type PositionDriver = {
vm: ViewModel?,
xProp: Property<number>?,
yProp: Property<number>?,
time: number,
}

function init(self: PositionDriver, context: Context): boolean
self.time = 0
self.vm = context:viewModel()

if self.vm then
self.xProp = self.vm:getNumber("posX")
self.yProp = self.vm:getNumber("posY")
print("Position driver initialized")
end

return true
end

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

-- TODO 1: Write to xProp.value using sin(time) * 100
if self.xProp then
-- self.xProp.value = math.sin(self.time) * 100
end

-- TODO 2: Write to yProp.value using cos(time * 1.5) * 50
if self.yProp then
-- self.yProp.value = math.cos(self.time * 1.5) * 50
end

if math.floor(self.time) == 1 and math.floor(self.time - seconds) < 1 then
print("Driving position...")
print("ANSWER: writing")
end

return true
end

function draw(self: PositionDriver, renderer: Renderer)
end

return function(): Node<PositionDriver>
return {
init = init,
advance = advance,
draw = draw,
vm = nil,
xProp = nil,
yProp = nil,
time = 0,
}
end

Assignment

Complete these tasks:

  1. Complete the advance function to animate the position properties using sine and cosine waves.
  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

Editor setup:

  1. Add posX and posY number properties to your ViewModel
  2. Bind these properties to an element's transform in the artboard

Key points:

  • Write to .value to update the property
  • Changes propagate to all bound elements automatically
  • ViewModel properties are the bridge between scripts and visual elements

Exercise 3: Listening for Property Changes ⭐⭐

Premise

Listeners make scripts reactive to external changes. When the runtime or State Machine updates a property, your listener callback fires automatically.

Goal

By the end of this exercise, you will be able to Complete the init function to register a listener that changes color when the mode property changes.

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

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

    • View → Console

Starter Code

--!strict
-- React to ViewModel property changes with listeners

export type ChangeListener = {
vm: ViewModel?,
modeProp: Property<string>?,
currentMode: string,
paint: Paint,
path: Path,
}

function init(self: ChangeListener, context: Context): boolean
self.currentMode = "idle"
self.vm = context:viewModel()

if self.vm then
self.modeProp = self.vm:getString("mode")
if self.modeProp then
self.currentMode = self.modeProp.value

-- TODO: Register a listener using self.modeProp:addListener(function() ... end)
-- Inside the callback:
-- 1. Update self.currentMode = self.modeProp.value
-- 2. Print "Mode changed to: {self.currentMode}"
-- 3. If mode == "active", set paint color to red (255, 100, 100)
-- 4. Otherwise set paint color to blue (100, 100, 255)
-- 5. Print "ANSWER: listener" when mode changes

print("Listener registered for mode")
end
end

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

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

return true
end

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

return function(): Node<ChangeListener>
return {
init = init,
draw = draw,
vm = nil,
modeProp = nil,
currentMode = "idle",
paint = late(),
path = late(),
}
end

Assignment

Complete these tasks:

  1. Complete the init function to register a listener that changes color when the mode property changes.
  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

Editor setup:

  1. Add a mode string property to your ViewModel (default: "idle")
  2. Use a state machine or runtime code to change it to "active"

Key points:

  • addListener() registers a callback that fires on property changes
  • Listeners persist for the lifetime of the script
  • Use listeners for event-driven logic (mode changes, state transitions)

Exercise 4: Firing Triggers ⭐⭐

Premise

Triggers are one-shot events with no persistent value. Use them for notifications like collisions, button presses, or animation completions.

Goal

By the end of this exercise, you will be able to Complete the init function to get the trigger, register a listener, and implement a pointerDown handler to fire it.

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

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

    • View → Console

Starter Code

--!strict
-- Use triggers for one-shot events

export type TriggerDemo = {
vm: ViewModel?,
hitTrigger: Trigger?,
clickCount: number,
path: Path,
paint: Paint,
}

function init(self: TriggerDemo, context: Context): boolean
self.clickCount = 0
self.vm = context:viewModel()

if self.vm then
-- TODO 1: Get the trigger using self.vm:getTrigger("onHit")
self.hitTrigger = nil -- Replace with getTrigger

if self.hitTrigger then
-- TODO 2: Register a listener that:
-- 1. Increments clickCount
-- 2. Prints "Hit trigger fired! Count: {count}"
-- 3. Prints "ANSWER: trigger" when count reaches 2

print("Trigger listener registered")
end
end

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

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

return true
end

function pointerDown(self: TriggerDemo, event: PointerEvent)
-- TODO 3: Fire the trigger when clicked
-- if self.hitTrigger then
-- self.hitTrigger:fire()
-- end
event:hit()
end

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

return function(): Node<TriggerDemo>
return {
init = init,
pointerDown = pointerDown,
draw = draw,
vm = nil,
hitTrigger = nil,
clickCount = 0,
path = late(),
paint = late(),
}
end

Assignment

Complete these tasks:

  1. Complete the init function to get the trigger, register a listener, and implement a pointerDown handler to fire it.
  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

Editor setup:

  1. Add an onHit trigger to your ViewModel
  2. Optionally connect the trigger to a state machine transition

Key points:

  • Triggers are one-shot events with no persistent value
  • Use getTrigger() to access, fire() to activate, addListener() to respond
  • Common uses: collision events, button presses, animation completion signals

Exercise 5: Nested ViewModel Inputs ⭐⭐⭐

Premise

Input<Data.X> binds to a specific ViewModel instance assigned in the editor. Access its properties directly using dot notation.

Goal

By the end of this exercise, you will be able to Complete the advance function to animate the character's position using the nested ViewModel properties.

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

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

    • View → Console

Starter Code

--!strict
-- Control a nested ViewModel using script inputs

export type CharacterController = {
character: Input<Data.Character>,
speed: Input<number>,
time: number,
}

function init(self: CharacterController): boolean
self.time = 0
print("Character controller ready")
return true
end

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

-- TODO 1: Calculate horizontal offset using sin(time * speed) * 80
-- TODO 2: Write to self.character.posX.value
-- TODO 3: Calculate vertical offset using cos(time * speed * 0.7) * 40
-- TODO 4: Write to self.character.posY.value

if math.floor(self.time) == 1 and math.floor(self.time - seconds) < 1 then
print("Animating character...")
print("ANSWER: nested")
end

return true
end

function draw(self: CharacterController, renderer: Renderer)
end

return function(): Node<CharacterController>
return {
init = init,
advance = advance,
draw = draw,
character = late(),
speed = 2.0,
time = 0,
}
end

Assignment

Complete these tasks:

  1. Complete the advance function to animate the character's position using the nested ViewModel properties.
  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

Editor setup:

  1. Create a Character ViewModel with posX and posY number properties
  2. Bind the ViewModel to an element in your artboard
  3. In the script's input panel, assign the ViewModel instance to the character input

Key points:

  • Input<Data.X> where X is your ViewModel name
  • Use late() because the value is assigned in the editor
  • Access nested properties directly: self.character.posX.value

ViewModel vs Script Inputs

Featurecontext:viewModel()Input<Data.X>
ScopeArtboard-level (global)Per-instance
SetupAutomaticRequires editor binding
Use caseShared state across scriptsComponent-specific data
AccessIn init via contextAvailable immediately

Knowledge Check

Q:How do you access the artboard's main ViewModel?
Q:What does property:addListener(callback) do?
Q:How do you read the current value of a ViewModel property?
Q:When should you use late() for an input?
Q:What is the difference between a Property and a Trigger?

Key Takeaway

ViewModels are the bridge between your animation data and your scripts. Use context:viewModel() for artboard-level shared data, and Input<Data.X> with late() for component-specific bindings. Listeners make your scripts reactive to external changes.


Next Steps