ViewModels
Before this section, complete:
- Inputs and Data Binding —
Input<T>basics andupdate() - Node Protocol — Factory function and context
- Tables — Luau tables (ViewModels use property access)
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
| Method | Returns | Description |
|---|---|---|
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
| Method | Returns | Description |
|---|---|---|
context:viewModel() | ViewModel? | Get the artboard's bound ViewModel |
context:artboard() | Artboard | Get the artboard this script is attached to |
context:node() | Node | Get 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
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
| Method | Parameters | Description |
|---|---|---|
trigger:addListener(callback) | () -> () | Subscribe to trigger fires |
trigger:removeListener(callback) | () -> () | Unsubscribe from trigger |
trigger:fire() | none | Manually 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 Case | Use Trigger | Use Property |
|---|---|---|
| Button click | Yes | No |
| Animation complete | Yes | No |
| Score value | No | Yes (number) |
| Toggle state | No | Yes (boolean) |
| One-time events | Yes | No |
| Persistent state | No | Yes |
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/Method | Returns | Description |
|---|---|---|
list.count | number | Number 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_Exercise1ReadAndDisplayViewmodelData
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the init function to get the ViewModel and score property, then read its value.
- 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
Editor setup:
- Create a ViewModel with a
scorenumber property (default: 50) - Bind the ViewModel to your artboard
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
.valueto read the property's current value - Store the property reference to avoid repeated lookups
- Check for
nilbefore 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_Exercise2WritingToViewmodelProperties
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the advance function to animate the position properties using sine and cosine waves.
- 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
Editor setup:
- Add
posXandposYnumber properties to your ViewModel - Bind these properties to an element's transform in the artboard
Key points:
- Write to
.valueto 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_Exercise3ListeningForPropertyChanges
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the init function to register a listener that changes color when the mode property changes.
- 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
Editor setup:
- Add a
modestring property to your ViewModel (default: "idle") - 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_Exercise4FiringTriggers
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the init function to get the trigger, register a listener, and implement a pointerDown handler to fire it.
- 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
Editor setup:
- Add an
onHittrigger to your ViewModel - 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_Exercise5NestedViewmodelInputs
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the advance function to animate the character's position using the nested ViewModel properties.
- 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
Editor setup:
- Create a
CharacterViewModel withposXandposYnumber properties - Bind the ViewModel to an element in your artboard
- In the script's input panel, assign the ViewModel instance to the
characterinput
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
| Feature | context:viewModel() | Input<Data.X> |
|---|---|---|
| Scope | Artboard-level (global) | Per-instance |
| Setup | Automatic | Requires editor binding |
| Use case | Shared state across scripts | Component-specific data |
| Access | In init via context | Available immediately |
Knowledge Check
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
- Continue to Pointer Events
- Need a refresher? Review Quick Reference