Skip to main content

Lesson 3.7: Encapsulation

Learning Objectives

  • Understand encapsulation and why it matters
  • Use closures to create truly private state
  • Implement the module pattern for encapsulated objects
  • Know the trade-offs of different privacy approaches
  • Apply encapsulation patterns in Rive Node Scripts

AE/JS Syntax Comparison

ConceptAfter EffectsJavaScript/TypeScriptLuau (Rive)
Private variableN/Aprivate x = 0; or #x = 0;Closure-captured variable
Protected variableN/Aprotected x = 0;No equivalent (use conventions)
Public variablethisPropertypublic x = 0;Regular table field
GetterthisProperty.valueget x() { return this.#x; }getX = function() return x end
SetterN/Aset x(v) { this.#x = v; }setX = function(v) x = v end
Private conventionN/A_privateField_privateField (same)
Critical Difference from JavaScript/TypeScript

TypeScript: Has built-in private and protected keywords enforced by the compiler

class Player {
private health: number = 100; // Can't access from outside
protected maxHealth: number = 100; // Only subclasses can access
public name: string = "Hero"; // Anyone can access

getHealth(): number {
return this.health; // Access inside class is fine
}
}

const player = new Player();
player.health = 999; // ERROR: Property 'health' is private

Luau: Has NO privacy keywords - use closures for TRUE privacy

local function createPlayer(name: string)
-- These variables are TRULY private (not on any object)
local health = 100
local maxHealth = 100

return {
name = name, -- Public
getHealth = function() return health end,
setHealth = function(h: number)
health = math.min(h, maxHealth)
end,
}
end

local player = createPlayer("Hero")
print(player.health) -- nil! Doesn't exist on the table
print(player.getHealth()) -- 100 (through getter)

In Luau, closures are the ONLY way to enforce true privacy. Underscore prefixes (_privateField) are just conventions that rely on programmer discipline.


Why Encapsulation Matters in Rive

In Rive Node Scripts, encapsulation helps you:

  • Protect internal state of animation controllers and physics systems
  • Hide implementation details of complex drawable components
  • Prevent accidental modification of critical properties like paths and paints
  • Create clean APIs for reusable Util scripts

After Effects Comparison: In AE, expressions have limited scope and can only access values through specific properties. You can't really "protect" data - anyone can modify any property. In Luau, closures give you TRUE privacy that JavaScript's private keyword only approximates.


The Problem: No Built-in Privacy

Lua tables are completely open by default - anyone can access or modify any field:

local player = {
health = 100,
_maxHealth = 100, -- Convention: underscore means "private"
}

-- But nothing stops this:
player._maxHealth = 9999 -- Oops, broke the game balance!
player.health = 999999 -- Cheating made easy!

JavaScript has the same problem with underscore conventions:

const player = {
health: 100,
_maxHealth: 100, // "Private" by convention
};

player._maxHealth = 9999; // Still works! Convention doesn't enforce anything

Naming conventions (_privateField) are just hints - they don't enforce anything in either language.


Solution 1: Closure-Based Privacy

Closures can capture variables that are truly inaccessible from outside:

local function createCounter()
-- This variable is TRULY private
local count = 0

return {
increment = function()
count = count + 1
end,
get = function()
return count
end,
}
end

local c = createCounter()
c.increment()
print(c.get()) -- 1
print(c.count) -- nil! Can't access it - doesn't exist on the table

JavaScript equivalent (using closures, not classes):

function createCounter() {
// This variable is truly private
let count = 0;

return {
increment() { count++; },
get() { return count; },
};
}

const c = createCounter();
c.increment();
console.log(c.get()); // 1
console.log(c.count); // undefined! Can't access it

Practice Exercises

Exercise 1: Encapsulated State Machine ⭐⭐

Premise

The State Machine only allows valid transitions. idle->walking->running works, but running->jumping fails because that transition isn't defined. The final state remains 'running'.

Goal

By the end of this exercise, you will be able to Complete the createStateMachine factory function to encapsulate state and enforce valid transitions.

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

local function createStateMachine(initialState: string)
-- Private state (captured in closures)
local currentState = initialState
local transitions: { [string]: {string} } = {}

return {
addTransition = function(from: string, to: string)
if not transitions[from] then
transitions[from] = {}
end
table.insert(transitions[from], to)
end,

-- TODO 1: Implement transition function
-- Check if 'to' state is in transitions[currentState]
-- If valid: update currentState, return true
-- If invalid: print error message, return false
transition = function(to: string): boolean
-- Replace with implementation
return false
end,

-- TODO 2: Implement getState (return current state)
getState = function(): string
return "" -- Fix this
end,
}
end

export type StateMachineType = {
addTransition: (string, string) -> (),
transition: (string) -> boolean,
getState: () -> string,
}

export type StateMachineDemo = {
machine: StateMachineType,
}

function init(self: StateMachineDemo): boolean
self.machine = createStateMachine("idle")

-- Define allowed transitions
self.machine.addTransition("idle", "walking")
self.machine.addTransition("walking", "running")
self.machine.addTransition("running", "walking")

print(`Initial: {self.machine.getState()}`)

self.machine.transition("walking")
print(`After walking: {self.machine.getState()}`)

self.machine.transition("running")
print(`After running: {self.machine.getState()}`)

-- Try invalid transition (not defined!)
self.machine.transition("jumping")
print(`Final state: {self.machine.getState()}`)

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

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

return function(): Node<StateMachineDemo>
return {
init = init,
draw = draw,
machine = createStateMachine(""),
}
end

Assignment

Complete these tasks:

  1. Complete the createStateMachine factory function to encapsulate state and enforce valid transitions.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 2: Encapsulated Animation Controller ⭐⭐

Premise

The spring controller uses encapsulated physics state. After one second, the spring settles at its target position (80) because velocity approaches zero and displacement is within the threshold.

Goal

By the end of this exercise, you will be able to Complete the createSpringController factory function with private physics state. Implement the step() function with spring physics and settle detection.

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

local function createSpringController(initial: number, stiffness: number, damping: number)
-- Private state (captured in closures)
local position = initial
local velocity = 0
local target = initial
local isSettled = true
local settleThreshold = 0.5

return {
setTarget = function(t: number)
target = t
isSettled = false
end,

-- TODO 1: Implement step function with spring physics
-- 1. If settled, return early
-- 2. Calculate displacement = target - position
-- 3. Update velocity: velocity = velocity + displacement * stiffness * dt
-- 4. Apply damping: velocity = velocity * math.max(0, 1 - damping * dt)
-- 5. Update position: position = position + velocity * dt
-- 6. Check if settled (displacement and velocity both < settleThreshold)
step = function(dt: number)
-- Replace with implementation
end,

-- TODO 2: Implement getPosition (return current position)
getPosition = function(): number
return 0 -- Fix this
end,

-- TODO 3: Implement isMoving (return NOT settled)
isMoving = function(): boolean
return false -- Fix this
end,
}
end

export type SpringControllerType = {
setTarget: (number) -> (),
step: (number) -> (),
getPosition: () -> number,
isMoving: () -> boolean,
}

export type SpringDemo = {
spring: SpringControllerType,
}

function init(self: SpringDemo): boolean
self.spring = createSpringController(0, 50, 8)

print(`Initial position: {self.spring.getPosition()}`)

-- Set target to 80
self.spring.setTarget(80)

-- Simulate 0.5 seconds
for i = 1, 50 do
self.spring.step(0.01)
end
print(`After 0.5s: position ~{math.floor(self.spring.getPosition())} (moving toward 80)`)

-- Simulate another 0.5 seconds
for i = 1, 50 do
self.spring.step(0.01)
end
print(`After 1.0s: position ~{math.floor(self.spring.getPosition())} (nearly settled)`)

local status = if self.spring.isMoving() then "moving" else "settled"
print(`Status: {status}`)
print(`ANSWER: {status}`)

return true
end

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

return function(): Node<SpringDemo>
return {
init = init,
draw = draw,
spring = createSpringController(0, 1, 1),
}
end

Assignment

Complete these tasks:

  1. Complete the createSpringController factory function with private physics state. Implement the step() function with spring physics and settle detection.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 3: Weak Privacy with Conventions ⭐

Premise

Underscore conventions indicate 'private' fields that shouldn't be accessed directly from outside the class. The _dirty flag is set to true initially, then false after _rebuildPath runs. This pattern is simpler than closures but relies on programmer discipline.

Goal

By the end of this exercise, you will be able to Complete the Sprite class using underscore conventions for private fields and methods. Implement setColor() and _rebuildPath().

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

local Sprite = {}
Sprite.__index = Sprite

export type SpriteType = {
x: number,
y: number,
_path: Path, -- "Private" by convention
_paint: Paint, -- "Private" by convention
_dirty: boolean, -- "Private" internal flag
}

function Sprite.new(x: number, y: number, color: Color): SpriteType
local self = setmetatable({}, Sprite)
self.x = x
self.y = y
self._path = Path.new()
self._paint = Paint.with({ style = "fill", color = color })
self._dirty = true
return (self :: any) :: SpriteType
end

-- TODO 1: Implement public setColor method
-- Update self._paint.color with the new color
function Sprite:setColor(color: Color)
-- Replace with implementation
end

-- TODO 2: Implement internal _rebuildPath method (underscore = private)
-- 1. If not self._dirty, return early
-- 2. Reset self._path
-- 3. Add an ellipse centered at (0,0) with radius 25
-- 4. Set self._dirty = false
function Sprite:_rebuildPath()
-- Replace with implementation
end

function Sprite:draw(renderer: Renderer)
self:_rebuildPath()
renderer:save()
renderer:transform(Mat2D.withTranslation(self.x, self.y))
renderer:drawPath(self._path, self._paint)
renderer:restore()
end

export type ConventionDemo = {
sprite: SpriteType,
}

function init(self: ConventionDemo): boolean
self.sprite = Sprite.new(50, 50, Color.rgb(100, 200, 255))
print(`Created sprite at ({self.sprite.x}, {self.sprite.y})`)
print(`Initial dirty: {self.sprite._dirty}`)

-- Trigger draw to call _rebuildPath
self.sprite:_rebuildPath()
print(`After draw, dirty: {self.sprite._dirty}`)

-- Use PUBLIC API to change color
self.sprite:setColor(Color.rgb(255, 100, 100))
print("Color changed via public API")

print(`ANSWER: dirty={self.sprite._dirty}`)
return true
end

function draw(self: ConventionDemo, renderer: Renderer)
self.sprite:draw(renderer)
end

return function(): Node<ConventionDemo>
return {
init = init,
draw = draw,
sprite = Sprite.new(0, 0, Color.rgb(0, 0, 0)),
}
end

Assignment

Complete these tasks:

  1. Complete the Sprite class using underscore conventions for private fields and methods. Implement setColor() and _rebuildPath().
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 4: Module Pattern ⭐⭐

Premise

The module pattern combines closures with a clean factory interface. Private helpers (createParticle, updateParticle) are completely hidden. After emitting 5 particles, the count is 5. The internal particles array can't be accessed or modified directly.

Goal

By the end of this exercise, you will be able to Complete the ParticleSystem module with private state and helpers. Implement the emit() and update() functions.

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

local ParticleSystem = {}

export type Particle = {
x: number,
y: number,
vx: number,
vy: number,
life: number,
}

export type ParticleSystemType = {
emit: (number, number, number) -> (),
update: (number) -> (),
getCount: () -> number,
clear: () -> (),
}

function ParticleSystem.create(maxParticles: number): ParticleSystemType
-- PRIVATE STATE (closure-captured)
local particles: {Particle} = {}

-- PRIVATE HELPER (not exposed!)
local function createParticle(x: number, y: number): Particle
return {
x = x,
y = y,
vx = (math.random() - 0.5) * 100,
vy = (math.random() - 0.5) * 100,
life = 1 + math.random(),
}
end

-- PUBLIC INTERFACE
return {
-- TODO 1: Implement emit function
-- For count times: if #particles < maxParticles, create and add a particle
emit = function(x: number, y: number, count: number)
-- Replace with implementation
end,

-- TODO 2: Implement update function
-- Create alive array, loop through particles
-- Decrease life by dt, if life > 0 add to alive
-- Replace particles with alive
update = function(dt: number)
-- Replace with implementation
end,

getCount = function(): number
return #particles
end,

clear = function()
particles = {}
end,
}
end

export type ModuleDemo = {
system: ParticleSystemType,
}

function init(self: ModuleDemo): boolean
self.system = ParticleSystem.create(100)
print("Created particle system (max: 100)")

-- Emit particles
self.system.emit(0, 0, 5)
print("Emitted 5 particles")
print(`Current count: {self.system.getCount()}`)

-- Update simulation
self.system.update(0.5)
print("After update (0.5s): some particles still alive")

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

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

return function(): Node<ModuleDemo>
return {
init = init,
draw = draw,
system = ParticleSystem.create(1),
}
end

Assignment

Complete these tasks:

  1. Complete the ParticleSystem module with private state and helpers. Implement the emit() and update() functions.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 5: Read-Only Properties ⭐⭐

Premise

Read-only properties can only be modified through validated setter methods. Direct assignment (state.score = 999) fails because the field doesn't exist on the returned object - only the getter function exists. This prevents cheating and ensures game logic consistency.

Goal

By the end of this exercise, you will be able to Complete the createGameState factory with read-only getters and controlled mutation methods (addScore, loseLife).

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

local function createGameState()
-- PRIVATE MUTABLE STATE
local score = 0
local level = 1
local lives = 3
local gameOver = false

return {
-- TODO 1: Implement read-only getters
-- Return score, level, lives, and gameOver values
getScore = function(): number
return 0 -- Fix this
end,
getLevel = function(): number
return 0 -- Fix this
end,
getLives = function(): number
return 0 -- Fix this
end,
isGameOver = function(): boolean
return false -- Fix this
end,

-- TODO 2: Implement addScore (validated setter)
-- If not gameOver: add points to score
-- Check for level up: newLevel = floor(score/1000) + 1
-- If newLevel > level, update level and print "Level up!"
addScore = function(points: number)
-- Replace with implementation
end,

-- TODO 3: Implement loseLife (validated setter)
-- If not gameOver: decrement lives
-- If lives <= 0, set gameOver = true and print "Game Over!"
loseLife = function()
-- Replace with implementation
end,
}
end

export type GameStateType = {
getScore: () -> number,
getLevel: () -> number,
getLives: () -> number,
isGameOver: () -> boolean,
addScore: (number) -> (),
loseLife: () -> (),
}

export type ReadOnlyDemo = {
state: GameStateType,
}

function init(self: ReadOnlyDemo): boolean
self.state = createGameState()

print(`Starting: Score={self.state.getScore()}, Level={self.state.getLevel()}, Lives={self.state.getLives()}`)

self.state.addScore(500)
print(`After 500 points: Score={self.state.getScore()}`)

self.state.addScore(600) -- Should trigger level up (total 1100)
print(`After 600 more: Score={self.state.getScore()}, Level={self.state.getLevel()}`)

self.state.loseLife()
self.state.loseLife()
print(`After 2 hits: Lives={self.state.getLives()}`)

self.state.loseLife() -- Should trigger game over
print(`Game over: {self.state.isGameOver()}`)

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

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

return function(): Node<ReadOnlyDemo>
return {
init = init,
draw = draw,
state = createGameState(),
}
end

Assignment

Complete these tasks:

  1. Complete the createGameState factory with read-only getters and controlled mutation methods (addScore, loseLife).
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Exercise 6: Combining Approaches ⭐⭐⭐

Premise

Real applications combine both approaches: closures for critical state (timer logic where elapsed/running must be protected) and conventions for internal implementation details (UI paths/paints). This gives you security where it matters and simplicity where it doesn't.

Goal

By the end of this exercise, you will be able to Complete the Timer module (closure-based) and Countdown class (convention-based) that work together. The timer handles critical timing logic while the countdown handles visual state.

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:

  1. Create the script:

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

    • Attach to any shape and press Play
  3. Open the Console:

    • View → Console

Starter Code

--!strict

-- UTIL: Timer Module (true encapsulation via closures)
local Timer = {}

export type TimerType = {
start: () -> (),
tick: (number) -> boolean,
getElapsed: () -> number,
getRemaining: () -> number,
isComplete: () -> boolean,
}

function Timer.create(duration: number): TimerType
-- PRIVATE state (truly encapsulated)
local elapsed = 0
local running = false
local complete = false

return {
start = function()
running = true
end,

-- TODO 1: Implement tick function
-- If running and not complete:
-- Add dt to elapsed
-- If elapsed >= duration: set elapsed=duration, complete=true, running=false, return true
-- Return false otherwise
tick = function(dt: number): boolean
-- Replace with implementation
return false
end,

-- TODO 2: Implement getElapsed (return elapsed)
getElapsed = function(): number
return 0 -- Fix this
end,

-- TODO 3: Implement getRemaining (return duration - elapsed, min 0)
getRemaining = function(): number
return 0 -- Fix this
end,

-- TODO 4: Implement isComplete (return complete)
isComplete = function(): boolean
return false -- Fix this
end,
}
end

-- CLASS: Countdown (convention-based internal state)
local Countdown = {}
Countdown.__index = Countdown

export type CountdownType = {
_timer: TimerType, -- Convention: internal
_duration: number, -- Convention: internal
}

function Countdown.new(duration: number): CountdownType
local self = setmetatable({}, Countdown)
self._timer = Timer.create(duration)
self._duration = duration
self._timer.start()
return (self :: any) :: CountdownType
end

function Countdown:update(dt: number): boolean
return self._timer.tick(dt)
end

function Countdown:getStatus(): string
return if self._timer.isComplete() then "complete" else "running"
end

export type CombinedDemo = {
countdown: CountdownType,
}

function init(self: CombinedDemo): boolean
self.countdown = Countdown.new(3)
print("Created countdown timer (3 seconds)")

print(`Initial: elapsed={self.countdown._timer.getElapsed()}, remaining={self.countdown._timer.getRemaining()}`)

-- Simulate 1 second
self.countdown:update(1)
print(`After 1s: elapsed={self.countdown._timer.getElapsed()}, remaining={self.countdown._timer.getRemaining()}`)

-- Simulate another second
self.countdown:update(1)
print(`After 2s: elapsed={self.countdown._timer.getElapsed()}, remaining={self.countdown._timer.getRemaining()}`)

-- Simulate final second (should complete)
if self.countdown:update(1) then
print("Timer complete!")
end

local status = self.countdown:getStatus()
print(`Final status: {status}`)
print(`ANSWER: {status}`)
return true
end

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

return function(): Node<CombinedDemo>
return {
init = init,
draw = draw,
countdown = Countdown.new(1),
}
end

Assignment

Complete these tasks:

  1. Complete the Timer module (closure-based) and Countdown class (convention-based) that work together. The timer handles critical timing logic while the countdown handles visual state.
  2. Run the script and verify the console output
  3. Copy the ANSWER: line into the validator

Expected Output

Console prints the relevant debug lines for this exercise.
ANSWER: <your result>

Verify Your Answer

Verify Your Answer

Checklist

  • --!strict is at the top
  • All TODOs are replaced with working code
  • Console output includes the ANSWER: line


Trade-offs Summary

ApproachPrivacy LevelPerformanceFlexibility
Underscore conventionWeak (honor system)Best (shared methods)High
Closure-basedStrong (enforced)Good (methods per instance)Limited
Module patternStrong (enforced)GoodModerate

Recommendation for Rive:

  • Use closures for critical state (game logic, security, physics)
  • Use underscore convention for internal implementation details (paths, paints)
  • Use module pattern for reusable Util scripts

Common Mistakes

Mistake 1: Returning Private Data Directly

-- WRONG: Returns the actual private table (can be modified!)
local function createBad()
local data = { secret = 42 }
return {
getData = function() return data end, -- DANGER!
}
end

local bad = createBad()
bad.getData().secret = 999 -- Oops, modified private data!

-- CORRECT: Return a copy
local function createGood()
local data = { secret = 42 }
return {
getData = function()
return { secret = data.secret } -- Return COPY
end,
}
end

Mistake 2: Forgetting Closures Capture by Reference

-- SURPRISING: All buttons share the same 'i' value!
local buttons = {}
for i = 1, 3 do
buttons[i] = {
click = function()
print(`Button {i}`) -- Will always print "Button 3"!
end,
}
end

-- CORRECT: Create a new scope for each iteration
for i = 1, 3 do
local index = i -- Create new variable
buttons[i] = {
click = function()
print(`Button {index}`) -- Captures correct value
end,
}
end

Mistake 3: Over-Encapsulating Everything

-- OVERKILL: Not everything needs closures
local function createPoint(x: number, y: number)
local px = x
local py = y
return {
getX = function() return px end,
getY = function() return py end,
setX = function(v: number) px = v end,
setY = function(v: number) py = v end,
}
end

-- SIMPLER: Just use a plain table
local function createPoint(x: number, y: number)
return { x = x, y = y }
end

Mistake 4: Mixing Patterns Inconsistently

-- INCONSISTENT: Some fields use conventions, some use closures
local function createMixed()
local private1 = 0
return {
_private2 = 0, -- Convention
getPrivate1 = function() return private1 end,
-- No getter for _private2?
}
end

-- CONSISTENT: Pick one approach per module

Knowledge Check

Q:How do closures provide true privacy in Lua?
Q:What's the main trade-off of closure-based encapsulation?
Q:When should you use underscore conventions instead of closures?

Self-Assessment Checklist

  • I understand why Lua tables don't have built-in privacy
  • I can create truly private state using closures
  • I know when to use closure-based privacy vs underscore conventions
  • I can implement the module pattern
  • I can create read-only properties with getters
  • I understand the memory trade-offs of different approaches
  • I can combine approaches appropriately (closures for critical state, conventions for internals)

Next Steps