Documentation/Guides/Lua Class System

Lua Class System

Create custom Lua classes with properties, events, inheritance, and network replication.

Introduction

StarForge provides a powerful class definition system that lets you create custom Lua classes with full engine integration. These classes can inherit from C++ engine types, define properties and events with automatic network replication, and integrate seamlessly with the engine's reflection system.

Basic Class Definition

Use class.define() to create a new class:

local MyClass = class.define("MyClass")
    :extends("Instance")
    :finalize()

return MyClass

This creates a basic class that inherits from Instance. The :finalize() call completes the class definition and returns the class table.

Adding Properties

Properties are declared using :addProperty(type, name, defaultValue):

local Player = class.define("Player")
    :extends("Instance")
    :addProperty("number", "Health", 100)
    :addProperty("number", "MaxHealth", 100)
    :addProperty("string", "DisplayName", "Player")
    :addProperty("boolean", "IsAlive", true)
    :finalize()

Supported Property Types

TypeDescriptionExample Default
numberFloating-point number100, 3.14
stringText string"Hello"
booleanTrue/false valuetrue, false
InstanceReference to any Instancenil
Vector22D vectornil
Vector33D vectornil
ColorColor valuenil

Adding Events

Events allow instances to notify listeners when something happens:

local Enemy = class.define("Enemy")
    :extends("Instance")
    :addProperty("number", "Health", 100)
    :addEvent("OnDamaged")
    :addEvent("OnDeath")
    :finalize()

function Enemy:TakeDamage(amount)
    self.Health = self.Health - amount
    self.OnDamaged:Fire(amount)

    if self.Health <= 0 then
        self.OnDeath:Fire()
    end
end

-- Usage:
local enemy = Enemy.new()
enemy.OnDamaged:Connect(function(amount)
    print("Enemy took " .. amount .. " damage!")
end)
enemy.OnDeath:Connect(function()
    print("Enemy defeated!")
end)

Network Replication

Mark properties and events for automatic network synchronization:

local NetworkedPlayer = class.define("NetworkedPlayer")
    :extends("Instance")
    :addProperty("number", "Score", 0):replicates(true)
    :addProperty("string", "TeamName", ""):replicates(true)
    :addProperty("Vector3", "Position", nil):replicates(true)
    :addEvent("OnScoreChanged"):replicates(true)
    :finalize()

When a replicated property changes on the server, it automatically syncs to all connected clients. Replicated events fired on the server broadcast to all clients.

Read-Only Properties

Prevent external modification with :readOnly():

local GameState = class.define("GameState")
    :extends("Instance")
    :addProperty("number", "CreatedTimestamp", 0):readOnly(true)
    :addProperty("string", "GameId", ""):readOnly(true)
    :finalize()

The init() Method

Define an init() method for instance initialization:

local Inventory = class.define("Inventory")
    :extends("Instance")
    :addProperty("number", "MaxSlots", 20)
    :finalize()

function Inventory:init()
    -- Initialize private data (underscore prefix)
    self._items = {}
    self._slotCount = 0

    -- Set up initial state
    self.Name = "Inventory"
end

function Inventory:AddItem(item)
    if self._slotCount >= self.MaxSlots then
        return false
    end
    table.insert(self._items, item)
    self._slotCount = self._slotCount + 1
    return true
end

The init() method is called automatically after the instance is created.

Private Instance Data

Keys starting with underscore (_) are treated as private instance data:

function MyClass:init()
    self._internalState = {}    -- Private, not visible to other scripts
    self._cachedValue = nil     -- Private
    self.PublicValue = 0        -- Public property (if defined)
end

Private data:

  • Is not accessible from outside the class
  • Is not replicated over the network
  • Is not serialized
  • Provides a clean separation between internal state and public API

Inheritance

Inheriting from C++ Classes

Inherit from any reflected C++ class:

-- Inherit from UIFrame (C++ class)
local CustomButton = class.define("CustomButton")
    :extends("UIFrame")
    :addProperty("string", "ButtonText", "Click Me")
    :addEvent("OnClicked")
    :finalize()

function CustomButton:init()
    -- Access inherited properties from UIFrame
    self.Size = UDim2.new(0, 200, 0, 50)
    self.BackgroundColor = Color.new(0.2, 0.4, 0.8)

    -- Set up click handling
    self.InputBegan:Connect(function(input)
        if input.InputType == "MouseButton1" then
            self.OnClicked:Fire()
        end
    end)
end

Inheriting from Lua Classes

Create class hierarchies entirely in Lua:

-- Base class
local Entity = class.define("Entity")
    :extends("Instance")
    :addProperty("number", "Health", 100)
    :addProperty("boolean", "IsAlive", true)
    :addEvent("OnDeath")
    :finalize()

function Entity:TakeDamage(amount)
    self.Health = self.Health - amount
    if self.Health <= 0 and self.IsAlive then
        self.IsAlive = false
        self.OnDeath:Fire()
    end
end

-- Derived class
local Character = class.define("Character")
    :extends("Entity")  -- Inherits from Entity (Lua class)
    :addProperty("number", "Speed", 10)
    :addProperty("number", "JumpHeight", 5)
    :finalize()

function Character:init()
    self._velocity = Vector3.new(0, 0, 0)
end

function Character:Move(direction)
    self._velocity = direction * self.Speed
end

Creating Instances

Use .new() to create instances:

local player = Player.new()
player.DisplayName = "Alice"
player.Health = 100

-- Or use the global new() function
local enemy = new("Enemy")
enemy.Health = 50

Complete Example

Here's a complete example of a game entity system:

-- Entity.module.lua
local Entity = class.define("Entity")
    :extends("Part")
    :addProperty("number", "Health", 100):replicates(true)
    :addProperty("number", "MaxHealth", 100)
    :addProperty("string", "EntityType", ""):replicates(true)
    :addProperty("Instance", "Target", nil)
    :addEvent("OnDamaged"):replicates(true)
    :addEvent("OnHealed")
    :addEvent("OnDeath"):replicates(true)
    :finalize()

function Entity:init()
    self._isDead = false
    self._damageMultiplier = 1.0
    self._healMultiplier = 1.0
    self.Name = "Entity"
end

function Entity:TakeDamage(amount, source)
    if self._isDead then return end

    local actualDamage = amount * self._damageMultiplier
    self.Health = math.max(0, self.Health - actualDamage)
    self.OnDamaged:Fire(actualDamage, source)

    if self.Health <= 0 then
        self._isDead = true
        self.OnDeath:Fire(source)
    end
end

function Entity:Heal(amount)
    if self._isDead then return end

    local actualHeal = amount * self._healMultiplier
    local oldHealth = self.Health
    self.Health = math.min(self.MaxHealth, self.Health + actualHeal)

    if self.Health > oldHealth then
        self.OnHealed:Fire(self.Health - oldHealth)
    end
end

function Entity:IsDead()
    return self._isDead
end

return Entity

Using the entity:

local Entity = require("Game/Entity")

-- Create an enemy
local enemy = Entity.new()
enemy.EntityType = "Goblin"
enemy.MaxHealth = 50
enemy.Health = 50

-- Listen for events
enemy.OnDamaged:Connect(function(amount, source)
    print(enemy.EntityType .. " took " .. amount .. " damage!")
end)

enemy.OnDeath:Connect(function(source)
    print(enemy.EntityType .. " was defeated!")
    enemy:Destroy()
end)

-- Deal damage
enemy:TakeDamage(25, player)
enemy:TakeDamage(30, player)  -- This kills the enemy

Method Chaining Reference

MethodDescription
:extends(className)Set the base class (C++ or Lua)
:addProperty(type, name, default)Add a property to the class
:addEvent(name)Add an event to the class
:replicates(bool)Mark last property/event as replicated
:readOnly(bool)Mark last property as read-only
:finalize()Complete definition, returns class table

Best Practices

  1. Always call :finalize() - The class isn't usable until finalized
  2. Use private data for internal state - Prefix with underscore
  3. Define init() for setup - Don't rely on property defaults for complex initialization
  4. Return the class from module scripts - Makes it easy to require
  5. Use events for notifications - Don't poll for changes
  6. Only replicate what's needed - Network bandwidth is precious
  7. Inherit from the most specific base - If you need UI, inherit from a UI class

Type Checking with typeof()

Use typeof() to check instance types:

local entity = Entity.new()

print(typeof(entity))           -- "Entity"
print(entity:IsA("Entity"))     -- true
print(entity:IsA("Part"))       -- true (Entity extends Part)
print(entity:IsA("Instance"))   -- true (Part extends Instance)

Module Script Pattern

The recommended way to organize Lua classes:

Content/
  Scripts/
    Game/
      Entity.module.lua
      Character.module.lua
      Inventory.module.lua
    UI/
      HealthBar.module.lua
      Minimap.module.lua

Each module script exports a single class:

-- Character.module.lua
local Entity = require("Game/Entity")

local Character = class.define("Character")
    :extends("Entity")
    :addProperty("string", "CharacterName", "")
    :finalize()

return Character

Then require it elsewhere:

local Character = require("Game/Character")
local player = Character.new()

This keeps your codebase organized and makes dependencies clear.

Next Steps