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
| Concept | After Effects | JavaScript/TypeScript | Luau (Rive) |
|---|---|---|---|
| Private variable | N/A | private x = 0; or #x = 0; | Closure-captured variable |
| Protected variable | N/A | protected x = 0; | No equivalent (use conventions) |
| Public variable | thisProperty | public x = 0; | Regular table field |
| Getter | thisProperty.value | get x() { return this.#x; } | getX = function() return x end |
| Setter | N/A | set x(v) { this.#x = v; } | setX = function(v) x = v end |
| Private convention | N/A | _privateField | _privateField (same) |
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'.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_Exercise1EncapsulatedStateMachine
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the createStateMachine factory function to encapsulate state and enforce valid transitions.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_Exercise2EncapsulatedAnimationController
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the createSpringController factory function with private physics state. Implement the step() function with spring physics and settle detection.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_Exercise3WeakPrivacyWithConventions
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the Sprite class using underscore conventions for private fields and methods. Implement setColor() and _rebuildPath().
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_Exercise4ModulePattern
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the ParticleSystem module with private state and helpers. Implement the emit() and update() functions.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_Exercise5ReadOnlyProperties
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- Complete the createGameState factory with read-only getters and controlled mutation methods (addScore, loseLife).
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis 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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_Exercise6CombiningApproaches
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
-
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:
- 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.
- Run the script and verify the console output
- Copy the
ANSWER:line into the validator
Expected Output
Console prints the relevant debug lines for this exercise.
ANSWER: <your result>
Verify Your Answer
Checklist
-
--!strictis at the top - All TODOs are replaced with working code
- Console output includes the
ANSWER:line
Trade-offs Summary
| Approach | Privacy Level | Performance | Flexibility |
|---|---|---|---|
| Underscore convention | Weak (honor system) | Best (shared methods) | High |
| Closure-based | Strong (enforced) | Good (methods per instance) | Limited |
| Module pattern | Strong (enforced) | Good | Moderate |
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
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
- Continue to 3.8 OOP Patterns
- Need a refresher? Review Quick Reference