The late() Initializer
Learning Objectives
- Understand why
late()exists in Rive's type system - Know when to use
late()vs direct values - Apply
late()correctly with Input types - Avoid common
late()mistakes
AE/JS Syntax Comparison
| Concept | After Effects / JavaScript | Luau (Rive Scripts) |
|---|---|---|
| Uninitialized variable | let x; (undefined) | Not allowed in strict mode |
| Declare now, assign later | let x; x = 5; | property = late() then set in init() |
| Default parameter | function f(x = 10) | speed: Input<number> with default 10 |
| Null reference | null or undefined | nil |
| Factory pattern | new Class() or createThing() | Factory function returns Node<T> |
| Constructor | constructor() { this.x = new Path(); } | init(self) lifecycle method |
Understanding factory vs init() timing
In JavaScript/TypeScript:
class MyComponent {
path = new Path(); // Created immediately when class is instantiated
}
In Rive Node Scripts:
-- Factory runs FIRST
return function(): Node<MyScript>
return {
path = late(), -- Type-safe placeholder for strict mode
}
end
-- init() runs SECOND (full context available)
function init(self: MyScript): boolean
self.path = Path.new() -- Create the path here
return true
end
Note: You CAN create Path.new() directly in the factory—it works! However, placing object creation in init() is the recommended pattern because:
- It keeps factory functions focused on defaults
- It ensures each instance gets its own objects
- It makes the initialization order explicit
Rive Context
In Rive Node Scripts, objects are created in two phases:
- Factory phase: The factory function returns a table with property values
- Init phase: The
init()function runs and can set up additional state
Some values are best created in init() rather than in the factory:
- Engine-provided: Values the Rive engine supplies (like Artboard-bound inputs)
- Instance-specific: Values that should be unique per node instance
The late() function is a type-safe placeholder for --!strict mode. It tells the type checker: "This value will be set later."
At runtime, late() and nil behave identically. The difference is type safety:
late()— Satisfies the type checker in strict modenil— Works at runtime, but may cause type warnings in strict mode
Both approaches work! Use late() when you want strict type checking.
--!strict
export type MyScript = {
path: Path,
paint: Paint,
}
return function(): Node<MyScript>
return {
init = init,
draw = draw,
-- late() provides type safety in strict mode
path = late(),
paint = late(),
}
end
// JavaScript/TypeScript equivalent concept:
// In JS, you'd just create objects in the constructor
class MyScript {
path: Path;
paint: Paint;
constructor() {
// JS allows this - Luau's factory phase does NOT
this.path = new Path();
this.paint = new Paint();
}
}
When to Use late()
late() is recommended (not required) for type safety in --!strict mode.
Recommended: Use late() for:
- Rive objects created in init():
Path,Paint,Font, etc. - Computed values: Values calculated from inputs or other data in init()
- External data: Values loaded from elsewhere during initialization
Use direct values for:
- Input defaults:
Input<T>properties get direct default values - Constants: Values known at compile time
- Factory-provided defaults: Values that don't change
Alternative: Use nil
If you're not using --!strict mode (or using optional types like Path?), you can use nil instead:
-- Both work at runtime:
path = late(), -- Type-safe for strict mode
path = nil, -- Works, but requires Path? type in strict mode
Practice Exercises
Exercise 1: Computed Total ⭐
Premise
late() lets you declare a value now and assign it later in init. This is ideal for values computed from other data.
By the end of this exercise, you will use late() for a computed number.
Use Case
You compute a sum from a list of values, but the calculation happens in init. late() tells the type system the field will be assigned later.
Example scenarios:
- Precomputed totals
- Aggregated values for HUDs
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_ComputedTotal
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise1 = {
total: number,
}
function init(self: Exercise1): boolean
local values = {3, 4, 5}
local sum = values[1] + values[2] + values[3]
-- TODO: Assign sum to self.total
self.total = 0
print(`ANSWER: total={self.total}`)
return true
end
function draw(self: Exercise1, renderer: Renderer)
end
return function(): Node<Exercise1>
return {
init = init,
draw = draw,
total = late(),
}
end
Assignment
Complete these tasks:
- Set
self.totalto the computed sum - Keep
totalas alate()placeholder in the factory
Expected Output
Your console output should display the total value computed and assigned to the late-initialized field.
Verify Your Answer
Checklist
- late() is used for total
- total equals 12
- Output matches the expected line
Exercise 2: Late Path Creation ⭐⭐
Premise
Rive objects like Path are created in init, not in the factory. late() reserves the slot until init runs.
By the end of this exercise, you will use late() for a Rive object created in init.
Use Case
You need a Path to draw custom geometry, but it can only be created once the script is initialized.
Example scenarios:
- Custom drawing paths
- Procedural shapes
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_LatePath
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise2 = {
path: Path,
}
function init(self: Exercise2): boolean
-- TODO: Create the path in init
self.path = Path.new()
local ready = self.path ~= nil
print(`ANSWER: ready={ready}`)
return true
end
function draw(self: Exercise2, renderer: Renderer)
end
return function(): Node<Exercise2>
return {
init = init,
draw = draw,
path = late(),
}
end
Assignment
Complete these tasks:
- Create the path inside init
- Keep the
late()placeholder in the factory
Expected Output
Your console output should confirm the Path was successfully created in init (ready is true when path exists).
Verify Your Answer
Checklist
- Path is created in init
- Factory uses late()
- Output matches the expected line
Exercise 3: Input and Computed State ⭐⭐
Premise
Inputs get direct defaults, while computed values use late(). Mixing the two correctly avoids common initialization bugs.
By the end of this exercise, you will combine Input defaults with a late-initialized value.
Use Case
A speed input comes from the editor, and you compute a scaled speed for internal use.
Example scenarios:
- Speed multipliers
- Derived tuning values
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_InputComputed
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise3 = {
speed: Input<number>,
scaled: number,
}
function init(self: Exercise3): boolean
-- TODO: Compute scaled as speed * 1.5
self.scaled = 0
print(`ANSWER: scaled={self.scaled}`)
return true
end
function draw(self: Exercise3, renderer: Renderer)
end
return function(): Node<Exercise3>
return {
init = init,
draw = draw,
speed = 2.0,
scaled = late(),
}
end
Assignment
Complete these tasks:
- Read speed from the Input
- Store speed * 1.5 in scaled
Expected Output
Your console output should display the scaled value computed from the Input speed.
Verify Your Answer
Checklist
- Input uses a direct default
- Computed value uses late()
- Output matches the expected line
Exercise 4: Input Defaults Only ⭐
Premise
Input<T> fields should never use late(). They always have a direct default value in the factory.
By the end of this exercise, you will use an Input default directly.
Use Case
A label is provided by a designer. You read it immediately without any late initialization.
Example scenarios:
- Labels and text inputs
- Simple string inputs
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_InputDefaultsOnly
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise4 = {
label: Input<string>,
}
function init(self: Exercise4): boolean
local label = self.label
print(`ANSWER: label={label}`)
return true
end
function draw(self: Exercise4, renderer: Renderer)
end
return function(): Node<Exercise4>
return {
init = init,
draw = draw,
label = "Orbit",
}
end
Assignment
Complete these tasks:
- Keep label typed as
Input<string> - Use the direct default "Orbit"
Expected Output
Your console output should display the label read directly from the Input field (no late() needed for Inputs).
Verify Your Answer
Checklist
- Input uses a direct default
- No late() used for inputs
- Output matches the expected line
Exercise 5: Late Bounds Table ⭐⭐
Premise
Complex tables can be created in init when they depend on other data. late() lets you reserve the field safely.
By the end of this exercise, you will use late() for a nested table.
Use Case
You compute bounds based on configuration at startup, then store them for later use.
Example scenarios:
- Precomputed bounds
- Derived config tables
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_LateBounds
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise5 = {
bounds: Bounds,
}
type Bounds = {
min: number,
max: number,
}
function init(self: Exercise5): boolean
-- TODO: Set min to 2 and max to 10
self.bounds = {
min = 0,
max = 0,
}
local range = self.bounds.max - self.bounds.min
print(`ANSWER: range={range}`)
return true
end
function draw(self: Exercise5, renderer: Renderer)
end
return function(): Node<Exercise5>
return {
init = init,
draw = draw,
bounds = late(),
}
end
Assignment
Complete these tasks:
- Create bounds in init
- Keep bounds as late() in the factory
Expected Output
Your console output should display the range computed from the late-initialized bounds table.
Verify Your Answer
Checklist
- Bounds created in init
- late() used for bounds
- Output matches the expected line
Exercise 6: Late Status String ⭐⭐
Premise
Sometimes you build strings dynamically in init based on computed data. A late placeholder keeps the type system satisfied.
By the end of this exercise, you will assign a computed string to a late field.
Use Case
You derive a status label during initialization and store it for later display.
Example scenarios:
- Computed status text
- Debug labels built at runtime
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_LateStatus
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise6 = {
status: string,
}
function init(self: Exercise6): boolean
local phase = "ready"
-- TODO: Set status to "State: <phase>"
self.status = ""
print(`ANSWER: status={self.status}`)
return true
end
function draw(self: Exercise6, renderer: Renderer)
end
return function(): Node<Exercise6>
return {
init = init,
draw = draw,
status = late(),
}
end
Assignment
Complete these tasks:
- Build the status string from phase
- Assign it to self.status
Expected Output
Your console output should display the status string built dynamically in init and assigned to the late field.
Verify Your Answer
Checklist
- status is set in init
- late() is used for status
- Output matches the expected line
Knowledge Check
Common Mistakes
1. Using late() for Inputs
-- WRONG: Input needs a real default value
return function(): Node<MyScript>
return {
speed = late(), -- ERROR!
}
end
-- CORRECT: Provide a default
return function(): Node<MyScript>
return {
speed = 100, -- This becomes the editor default
}
end
2. Factory Creation vs init() Creation
-- WORKS but not recommended: Creating in factory
return function(): Node<MyScript>
return {
path = Path.new(), -- This works, but all instances share the same path!
}
end
-- RECOMMENDED: Use late() and create in init()
return function(): Node<MyScript>
return {
path = late(),
}
end
function init(self: MyScript): boolean
self.path = Path.new() -- Each instance gets its own path
return true
end
Why init() is preferred: Objects created in the factory are shared across all instances of the script. Creating in init() ensures each node gets its own unique Path or Paint object.
3. Forgetting to Initialize late() Values
-- WRONG: Declared late() but never set
function init(self: MyScript): boolean
-- Forgot to set self.path!
return true
end
-- CORRECT: Always set every late() property
function init(self: MyScript): boolean
self.path = Path.new() -- Must set this!
return true
end
4. Using late() for Constants
-- UNNECESSARY: This value is known at compile time
return function(): Node<MyScript>
return {
maxHealth = late(), -- Why late()?
}
end
function init(self: MyScript): boolean
self.maxHealth = 100 -- Static value
return true
end
-- BETTER: Just use a direct value or local constant
local MAX_HEALTH = 100
return function(): Node<MyScript>
return {
-- Don't need a property for a constant!
}
end
5. Confusing Artboard Inputs with Regular Inputs
-- Regular Input: direct default
playerSpeed = 100,
-- Artboard Input: ALWAYS late() (engine binds it)
enemyArtboard = late(),
Self-Assessment Checklist
- I understand the two-phase model (factory then init)
- I know when to use
late()vs direct values - I correctly use
late()for Path, Paint, and other Rive objects - I use direct defaults for
Input<T>properties - I understand that
Input<Artboard<T>?>useslate() - I always initialize every
late()property ininit()
Summary
| Property Type | Factory Value | Set In | Notes |
|---|---|---|---|
Input<number> | 100 | Factory (default) | Direct value required |
Input<string> | "Hello" | Factory (default) | Direct value required |
Input<Color> | Color.rgba(...) | Factory (default) | Direct value required |
Input<Artboard<T>?> | late() | Engine-bound | Engine provides value |
Path | late() or nil | init() | late() recommended for type safety |
Paint | late() or nil | init() | late() recommended for type safety |
Font | late() or nil | init() | late() recommended for type safety |
| Computed values | late() or nil | init() | late() recommended for type safety |
| Collections | late() or nil | init() | late() recommended for type safety |
late() and nil both work at runtime. Use late() for type safety in --!strict mode.
Next Steps
- Continue to Prototype-Based OOP
- Need a refresher? Review Quick Reference