Skip to main content

Lesson 1.3: Functions & Closures

Learning Objectives

By the end of this lesson, you will:

  • Define and call functions in Luau
  • Understand function parameters and return values
  • Use type annotations on function signatures
  • Create and use closures for encapsulated state
  • Know when to use local functions vs helper functions

Syntax Note: Coming from JavaScript or After Effects?

JavaScript / After Effects Comparison
ConceptLuauJavaScript
Function declarationfunction name() endfunction name() {}
Local functionlocal function name() endconst name = function() {}
Arrow functionNot available(x) => x * 2
Returnreturn valuereturn value;
Multiple returnsreturn a, breturn [a, b]; (array)
Default paramsNot built-infunction(x = 5) {}

Key differences:

  • Luau uses end to close functions, not }
  • Luau can return multiple values directly (no array needed)
  • No arrow functions in Luau — use function() end

Why Functions?

Functions are reusable blocks of code. Instead of writing the same calculation multiple times:

-- WITHOUT functions - repetitive!
local damage1 = 50 * 2.5 - 10
local damage2 = 75 * 1.5 - 10
local damage3 = 30 * 3.0 - 10

You write a function once and call it:

-- WITH functions - clean and reusable!
local function calculateDamage(base, multiplier)
return base * multiplier - 10
end

local damage1 = calculateDamage(50, 2.5)
local damage2 = calculateDamage(75, 1.5)
local damage3 = calculateDamage(30, 3.0)

Defining Functions

Basic Function Syntax

-- Named function (declare with 'local' for proper scoping)
local function greet(name: string): string
return `Hello, {name}!`
end

-- Call the function
print(greet("Player")) -- "Hello, Player!"

Breaking down the syntax:

local function greet(name: string): string
-- -------- ---- ------ ------
-- keyword param param return
-- name type type
PartMeaning
local functionDeclares a local function (recommended)
greetThe function's name
name: stringParameter name with type string
: string after )Return type is string
returnSends a value back to the caller
endEnds the function body
JavaScript Comparison
// JavaScript (with TypeScript types)
function greet(name: string): string {
return `Hello, ${name}!`;
}

// Or as arrow function
const greet = (name: string): string => `Hello, ${name}!`;
-- Luau
local function greet(name: string): string
return `Hello, {name}!`
end

Anonymous Functions

Functions without names — useful for callbacks:

-- Anonymous function assigned to a variable
local add = function(a: number, b: number): number
return a + b
end

print(add(2, 3)) -- 5
JavaScript Comparison
// JavaScript
const add = function(a, b) {
return a + b;
};

// Or arrow function
const add = (a, b) => a + b;

Note: Luau has no arrow functions. Use function() end for inline functions.


Functions Without Returns

Functions that don't return a value:

local function logMessage(message: string)
print(`[LOG] {message}`)
-- No return statement needed
end

logMessage("Player spawned") -- [LOG] Player spawned
JavaScript Comparison
// JavaScript - implicitly returns undefined
function logMessage(message) {
console.log(`[LOG] ${message}`);
}

Multiple Return Values

Luau can return multiple values directly — no arrays or objects needed!

local function minMax(a: number, b: number): (number, number)
if a < b then
return a, b
else
return b, a
end
end

-- Receive both values
local min, max = minMax(10, 5)
print(`Min: {min}, Max: {max}`) -- Min: 5, Max: 10
JavaScript Comparison

JavaScript can't return multiple values directly. You must use arrays or objects:

// JavaScript - must use array destructuring
function minMax(a, b) {
return a < b ? [a, b] : [b, a];
}

const [min, max] = minMax(10, 5);
-- Luau - direct multiple returns
local function minMax(a: number, b: number): (number, number)
if a < b then return a, b else return b, a end
end

local min, max = minMax(10, 5)

Luau's approach is more efficient — no temporary objects created.


Function Parameters

Required Parameters

All parameters are required by default:

local function greet(name: string, title: string): string
return `Hello, {title} {name}!`
end

print(greet("Smith", "Dr.")) -- "Hello, Dr. Smith!"
-- print(greet("Smith")) -- ERROR: missing argument

Optional Parameters

Use ? to make parameters optional:

local function greet(name: string, title: string?): string
if title then
return `Hello, {title} {name}!`
else
return `Hello, {name}!`
end
end

print(greet("Smith", "Dr.")) -- "Hello, Dr. Smith!"
print(greet("Smith")) -- "Hello, Smith!"

Default Values Pattern

Luau doesn't have built-in default parameters, but you can simulate them:

local function attack(damage: number, multiplier: number?): number
local mult = multiplier or 1.0 -- Default to 1.0 if nil
return damage * mult
end

print(attack(50)) -- 50 (uses default multiplier 1.0)
print(attack(50, 2.0)) -- 100
JavaScript Comparison
// JavaScript has built-in default parameters
function attack(damage, multiplier = 1.0) {
return damage * multiplier;
}
-- Luau uses the 'or' pattern
local function attack(damage: number, multiplier: number?): number
multiplier = multiplier or 1.0
return damage * multiplier
end

Closures

What Is a Closure?

A closure is a function that "remembers" variables from the scope where it was created. These captured variables are called upvalues.

local function makeCounter()
local count = 0 -- This variable is "captured"

return function()
count += 1 -- The inner function remembers 'count'
return count
end
end

local counter = makeCounter()
print(counter()) -- 1
print(counter()) -- 2
print(counter()) -- 3

How it works:

  1. makeCounter() creates a local variable count
  2. It returns an inner function that uses count
  3. Even after makeCounter finishes, the inner function remembers count
  4. Each call to counter() updates and returns the same count
JavaScript Comparison

Closures work the same way in JavaScript:

// JavaScript closure
function makeCounter() {
let count = 0;
return function() {
count++;
return count;
};
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2

Why Are Closures Useful?

1. Creating private state:

local function createPlayer(name: string)
local health = 100 -- Private - can't be accessed directly

return {
getName = function() return name end,
getHealth = function() return health end,
takeDamage = function(amount: number)
health -= amount
if health < 0 then health = 0 end
end,
}
end

local player = createPlayer("Hero")
print(player.getName()) -- "Hero"
print(player.getHealth()) -- 100
player.takeDamage(30)
print(player.getHealth()) -- 70
-- print(health) -- ERROR: can't access directly

2. Factory functions for parameterized behavior:

local function makeMultiplier(factor: number)
return function(value: number): number
return value * factor
end
end

local double = makeMultiplier(2)
local triple = makeMultiplier(3)

print(double(10)) -- 20
print(triple(10)) -- 30

Closures in Rive Scripts

Closures are especially useful in Rive for pointer callbacks and reusable actions:

export type ButtonScript = {
onClick: () -> (),
}

local function makeClickHandler(label: string)
local count = 0
return function()
count += 1
print(`[{label}] Clicked {count} times`)
end
end

function init(self: ButtonScript): boolean
self.onClick = makeClickHandler("Button A")
return true
end

function pointerDown(self: ButtonScript, event: PointerEvent)
self.onClick()
event:hit()
end

return function(): Node<ButtonScript>
return {
init = init,
pointerDown = pointerDown,
onClick = late(),
}
end

Output (after each click):

[Button A] Clicked 1 times
[Button A] Clicked 2 times

Function Scope

Local vs Module-Level Functions

--!strict

-- Module-level function (accessible throughout the file)
local function helperFunction()
print("I'm accessible anywhere in this file")
end

function init(self: MyScript): boolean
-- Local function (only accessible inside init)
local function innerHelper()
print("I only exist inside init")
end

helperFunction() -- Works
innerHelper() -- Works

return true
end

function draw(self: MyScript, renderer: Renderer)
helperFunction() -- Works
-- innerHelper() -- ERROR: not accessible here
end

Output (when init runs, then draw runs):

I'm accessible anywhere in this file
I only exist inside init
I'm accessible anywhere in this file
Best Practice
  • Helper functions that are reused → Define at module level
  • Single-use functions → Define locally where needed

Exercises

Exercise 1: Damage Function ⭐

Premise

Functions let you encapsulate math once and reuse it everywhere. This is how you avoid duplicating formulas across scripts.

Goal

By the end of this exercise, you will implement a typed function and verify its output with test cases.

Use Case

You are building a combat HUD and need a reliable damage function that is used for hits, crits, and tooltips. If the function is wrong, every animation that depends on it becomes inconsistent.

Example scenarios:

  • Damage popups
  • Critical hit flashes

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_DamageFunction
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise1 = {}

-- TODO: Implement the function using the formula
-- baseDamage * weaponMultiplier * (2 if critical, else 1)
local function calculateDamage(baseDamage: number, weaponMultiplier: number, isCritical: boolean): number
return 0
end

function init(self: Exercise1): boolean
local t1 = calculateDamage(100, 1.0, false)
local t2 = calculateDamage(100, 1.5, false)
local t3 = calculateDamage(100, 1.0, true)
local t4 = calculateDamage(50, 2.0, true)

print(`ANSWER: {t1},{t2},{t3},{t4}`)
return true
end

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

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

Assignment

Complete these tasks:

  1. Implement calculateDamage
  2. Use an inline conditional for the crit multiplier

Expected Output

Your console output should display four damage values separated by commas:

  • Test 1: Base damage with no multiplier or crit
  • Test 2: Base damage with weapon multiplier
  • Test 3: Base damage with critical hit (doubles damage)
  • Test 4: Modified base with both multiplier and crit

Verify Your Answer

Verify Your Answer

Checklist

  • Function returns a number
  • Critical hits double damage
  • Output matches the expected line

Exercise 2: Typed Formatters ⭐

Premise

Typed parameters and return values catch errors early. This matters in Rive where a bad type can break your entire script at runtime.

Goal

By the end of this exercise, you will implement two typed helper functions and use them together.

Use Case

You are formatting values for a UI label and a progress indicator. Both functions need to accept numbers and return consistent outputs.

Example scenarios:

  • Currency formatting for a shop
  • Percent labels for progress bars

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_TypedFormatters
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise2 = {}

-- TODO 1: Return a formatted currency string
local function formatCurrency(amount: number, symbol: string): string
return ""
end

-- TODO 2: Return a percent number (0-100)
local function percent(value: number, total: number): number
return 0
end

function init(self: Exercise2): boolean
local label = formatCurrency(99.99, "$")
local p = percent(50, 200)

print(`ANSWER: {label},{p}`)
return true
end

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

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

Assignment

Complete these tasks:

  1. Implement formatCurrency using string interpolation
  2. Implement percent with division and multiplication

Expected Output

Your console output should display two values separated by a comma:

  • A formatted currency string with the symbol prefix
  • A percentage value (value divided by total, multiplied by 100)

Verify Your Answer

Verify Your Answer

Checklist

  • Both functions return the correct types
  • Percent uses total as the denominator
  • Output matches the expected line

Exercise 3: Closure Counter ⭐⭐

Premise

Closures let functions remember data after they are created. This is useful for counters, timers, and reusable helpers.

Goal

By the end of this exercise, you will build a counter closure that keeps its own internal state.

Use Case

You need a counter for animation steps or event triggers that increments each time it is called. Closures keep this logic isolated without exposing state globally.

Example scenarios:

  • Counting clicks or triggers
  • Simple timer steps

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise3_ClosureCounter
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise3 = {}

local function makeCounter(start: number): () -> number
local count = start
return function(): number
-- TODO: increment count by 1 and return it
return count
end
end

function init(self: Exercise3): boolean
local counter = makeCounter(0)
local a = counter()
local b = counter()
local c = counter()

print(`ANSWER: {a},{b},{c}`)
return true
end

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

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

Assignment

Complete these tasks:

  1. Increment count inside the closure
  2. Return the incremented value

Expected Output

Your console output should display three incrementing values separated by commas. Each call to the counter function should return the next number in sequence, demonstrating that the closure maintains its internal state between calls.


Verify Your Answer

Verify Your Answer

Checklist

  • Closure updates internal state
  • Output increments each call
  • Output matches the expected line

Exercise 4: Update Helper ⭐⭐

Premise

Functions are also used to update shared state cleanly. This is the same pattern used in many Rive scripts for advancing values.

Goal

By the end of this exercise, you will write a function that updates self state and verify it with repeated calls.

Use Case

You have a progress value that advances when events happen. By wrapping the update logic in a function, you can reuse it in multiple places without copy-paste.

Example scenarios:

  • Progress bars that tick forward
  • Score increments

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise4_UpdateHelper
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise4 = {
progress: number,
}

local function addProgress(state: Exercise4, amount: number)
-- TODO: add amount to state.progress
end

function init(self: Exercise4): boolean
self.progress = 0

addProgress(self, 0.25)
addProgress(self, 0.25)
addProgress(self, 0.5)

print(`ANSWER: {self.progress}`)
return true
end

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

return function(): Node<Exercise4>
return {
init = init,
draw = draw,
progress = 0,
}
end

Assignment

Complete these tasks:

  1. Implement addProgress to update state.progress
  2. Ensure the final progress is 1.0

Expected Output

Your console output should display the final progress value after all three addProgress calls have been applied. The sum of all added amounts should equal the displayed value.


Verify Your Answer

Verify Your Answer

Checklist

  • addProgress updates self state
  • Final progress equals 1
  • Output matches the expected line

Exercise 5: Lerp Factory ⭐⭐

Premise

Factory functions return other functions customized with parameters. This is a common pattern for easing and interpolation.

Goal

By the end of this exercise, you will build a lerp factory and test it with sample inputs.

Use Case

You want to interpolate between two values for animation. A factory makes it easy to reuse that interpolation for different ranges.

Example scenarios:

  • Position interpolation
  • Color channel interpolation

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise5_LerpFactory
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise5 = {}

local function makeLerp(startValue: number, endValue: number)
-- TODO: return a function that interpolates from startValue to endValue
return function(t: number): number
return 0
end
end

function init(self: Exercise5): boolean
local lerpPos = makeLerp(0, 100)
local a = lerpPos(0.25)
local b = lerpPos(0.5)

print(`ANSWER: {a},{b}`)
return true
end

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

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

Assignment

Complete these tasks:

  1. Implement the returned interpolation function
  2. Use the formula start + (end - start) * t

Expected Output

Your console output should display two interpolated values separated by a comma:

  • The first value at t=0.25 (25% of the way from start to end)
  • The second value at t=0.5 (halfway from start to end)

Verify Your Answer

Verify Your Answer

Checklist

  • Interpolation uses the correct formula
  • Output matches 25 and 50
  • Output matches the expected line

Exercise 6: Clamp Utility ⭐⭐

Premise

Utility functions like clamp and mapRange appear in nearly every Rive script. They keep values within safe bounds.

Goal

By the end of this exercise, you will implement clamp and mapRange helpers.

Use Case

You are limiting user Input values and remapping ranges for UI animations. Clamping prevents invalid values, and mapping lets you translate ranges cleanly.

Example scenarios:

  • Constraining slider input
  • Mapping progress to opacity

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise6_ClampUtility
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise6 = {}

local function clamp(value: number, minValue: number, maxValue: number): number
-- TODO: return minValue if value < minValue
-- TODO: return maxValue if value > maxValue
-- TODO: otherwise return value
return 0
end

local function mapRange(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number
-- TODO: map value from input range to output range
return 0
end

function init(self: Exercise6): boolean
local c1 = clamp(150, 0, 100)
local c2 = clamp(-10, 0, 100)
local m1 = mapRange(50, 0, 100, 0, 1)

print(`ANSWER: {c1},{c2},{m1}`)
return true
end

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

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

Assignment

Complete these tasks:

  1. Implement clamp
  2. Implement mapRange

Expected Output

Your console output should display three values:

  • First clamp result: value clamped to max when exceeding upper bound
  • Second clamp result: value clamped to min when below lower bound
  • Map result: input value mapped from one range to another range

Verify Your Answer

Verify Your Answer

Checklist

  • clamp enforces min and max bounds
  • mapRange returns 0.5 for the test
  • Output matches the expected line

Knowledge Check

Q:What is a closure?
Q:Where should you store data that needs to persist across frames in a Rive Node Script?
Q:How do you make a function return multiple values in Luau?
Q:What is the correct syntax for a function with type annotations?
Q:Which lifecycle function runs every frame for logic updates?

Common Mistakes

Top Mistakes
  1. Forgetting end — Every function needs end
  2. Using {} instead of end — Luau uses end, not braces
  3. Storing frame data in local variables — Use self.property for persistence
  4. Forgetting to return — Functions need explicit returns (no implicit return)
  5. Type mismatch — Ensure parameter and return types match annotations

Quick Reference

-- Basic function
local function name(param: Type): ReturnType
return value
end

-- Anonymous function
local func = function(param: Type): ReturnType
return value
end

-- Multiple returns
local function multi(): (number, string)
return 42, "hello"
end
local num, str = multi()

-- Optional parameter
local function opt(required: number, optional: string?)
local s = optional or "default"
end

-- Closure
local function factory(x: number)
return function(): number
return x * 2
end
end

Next Steps