Skip to main content

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

ConceptAfter Effects / JavaScriptLuau (Rive Scripts)
Uninitialized variablelet x; (undefined)Not allowed in strict mode
Declare now, assign laterlet x; x = 5;property = late() then set in init()
Default parameterfunction f(x = 10)speed: Input<number> with default 10
Null referencenull or undefinednil
Factory patternnew Class() or createThing()Factory function returns Node<T>
Constructorconstructor() { this.x = new Path(); }init(self) lifecycle method
Two-Phase Initialization

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:

  1. Factory phase: The factory function returns a table with property values
  2. 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."

late() vs nil

At runtime, late() and nil behave identically. The difference is type safety:

  • late() — Satisfies the type checker in strict mode
  • nil — 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.

  1. Rive objects created in init(): Path, Paint, Font, etc.
  2. Computed values: Values calculated from inputs or other data in init()
  3. External data: Values loaded from elsewhere during initialization

Use direct values for:

  1. Input defaults: Input<T> properties get direct default values
  2. Constants: Values known at compile time
  3. 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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_ComputedTotal
  2. 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:

  1. Set self.total to the computed sum
  2. Keep total as a late() 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_LatePath
  2. 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:

  1. Create the path inside init
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise3_InputComputed
  2. 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:

  1. Read speed from the Input
  2. Store speed * 1.5 in scaled

Expected Output

Your console output should display the scaled value computed from the Input speed.


Verify Your Answer

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise4_InputDefaultsOnly
  2. 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:

  1. Keep label typed as Input<string>
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise5_LateBounds
  2. 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:

  1. Create bounds in init
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise6_LateStatus
  2. 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:

  1. Build the status string from phase
  2. 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

Verify Your Answer

Checklist

  • status is set in init
  • late() is used for status
  • Output matches the expected line

Knowledge Check

Q:Why does late() exist in Rive's type system?
Q:When should you use late() vs a direct value?
Q:Why do we use late() for Path/Paint in these examples?
Q:Which property declaration is correct for an Input<number>?

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>?> uses late()
  • I always initialize every late() property in init()

Summary

Property TypeFactory ValueSet InNotes
Input<number>100Factory (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-boundEngine provides value
Pathlate() or nilinit()late() recommended for type safety
Paintlate() or nilinit()late() recommended for type safety
Fontlate() or nilinit()late() recommended for type safety
Computed valueslate() or nilinit()late() recommended for type safety
Collectionslate() or nilinit()late() recommended for type safety
note

late() and nil both work at runtime. Use late() for type safety in --!strict mode.


Next Steps