25 commits in one day. This was the big one.

The problem with metatables

In Lua (and by extension Luau), there’s no real concept of private fields. The standard pattern on Roblox is to use metatables:

function MyClass.new()
    local self = setmetatable({}, MyClass)
    self._health = 100  -- "private" by convention
    return self
end

That underscore prefix is just a convention. Anyone with a reference to the object can read or write _health directly. In a framework where components are composed together and handed off to orchestrators, this is a real problem. During the turret system work (Day 12), we had a bug where one component was accidentally mutating another’s internal state through a shared reference. Convention-based privacy doesn’t protect you from mistakes across module boundaries.

Closure-based privacy

The solution: closures. Instead of storing state on self, store it in local variables captured by the constructor:

-- Before: convention-based (anyone can touch _health)
function Enemy.new()
    local self = setmetatable({}, Enemy)
    self._health = 100
    return self
end

-- After: closure-based (health is truly inaccessible)
function Enemy.new()
    local health = 100

    local self = {}
    function self:getHealth() return health end
    function self:takeDamage(n) health = math.max(0, health - n) end
    return self
end

Now health is genuinely inaccessible from outside. No reflection, no rawget, no metatable tricks. Lua’s lexical scoping enforces the boundary.

The tradeoff: you lose self access to the field inside methods (you use the local variable directly), and you can’t use metatables for method sharing across instances. Each instance gets its own function objects. For a game with hundreds of active components, this is fine. For thousands of identical lightweight entities, the memory overhead of per-instance functions might matter. We’re not at that scale.

The migration

We converted every single component — 14 modules in total: StatusEffect, DamageCalculator, Checkpoint, Zone, Launcher, Targeter, Swivel, PathFollower, Hatcher, Dropper, NodePool, PathedConveyor, Orchestrator, EntityStats.

Each conversion followed the same steps: move state from self._foo to local foo, convert methods from function MyClass:method() to function self:method(), update any external code that was touching internal state. We wrote QA test suites for each phase to verify nothing broke — tests that specifically try to access internal state and assert that it’s not possible.

Weapon systems

After the refactor, we had momentum and kept building. The Launcher component got a major upgrade:

  • Magazine system — Ammo count, reload timing, empty-click feedback
  • Tracer projectile — Fast-moving bullet with a visible trail rendered via Beam instances
  • PlasmaBeam projectile — Continuous beam with heat buildup and power drain
  • Component ownership — Previously, the Launcher configured its projectiles’ properties. Now each projectile type is a self-contained component that owns its own behavior. The Launcher just says “fire” and the projectile handles everything from there.

The demos broke during the refactor (of course — 14 modules changed means every demo that uses them needs updating), so we rebuilt the Swivel and Launcher demos with proper signal-driven architecture. The Swivel got a fix for direction-aware limit detection — it was falsely reporting “stopped” when reversing direction at its rotation limits, because the limit check didn’t account for which direction the swivel was traveling.

Long day. But the codebase is genuinely private now, and the weapon system is ready for the game we’re actually building.