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?
| Concept | Luau | JavaScript |
|---|---|---|
| Function declaration | function name() end | function name() {} |
| Local function | local function name() end | const name = function() {} |
| Arrow function | Not available | (x) => x * 2 |
| Return | return value | return value; |
| Multiple returns | return a, b | return [a, b]; (array) |
| Default params | Not built-in | function(x = 5) {} |
Key differences:
- Luau uses
endto 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
| Part | Meaning |
|---|---|
local function | Declares a local function (recommended) |
greet | The function's name |
name: string | Parameter name with type string |
: string after ) | Return type is string |
return | Sends a value back to the caller |
end | Ends the function body |
// 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
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 - 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 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 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:
makeCounter()creates a local variablecount- It returns an inner function that uses
count - Even after
makeCounterfinishes, the inner function rememberscount - Each call to
counter()updates and returns the samecount
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
- 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_DamageFunction
- Assets panel →
-
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:
- Implement
calculateDamage - 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_TypedFormatters
- Assets panel →
-
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:
- Implement
formatCurrencyusing string interpolation - Implement
percentwith 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_ClosureCounter
- Assets panel →
-
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:
- Increment
countinside the closure - 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_UpdateHelper
- Assets panel →
-
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:
- Implement
addProgressto updatestate.progress - 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
Checklist
-
addProgressupdates 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_LerpFactory
- Assets panel →
-
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:
- Implement the returned interpolation function
- 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_ClampUtility
- Assets panel →
-
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:
- Implement
clamp - 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
Checklist
- clamp enforces min and max bounds
- mapRange returns 0.5 for the test
- Output matches the expected line
Knowledge Check
Common Mistakes
- Forgetting
end— Every function needsend - Using
{}instead ofend— Luau usesend, not braces - Storing frame data in local variables — Use
self.propertyfor persistence - Forgetting to
return— Functions need explicit returns (no implicit return) - 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
- Continue to 1.4 Tables
- Need a refresher? Review Quick Reference