Lua Classes & Metatables
When and how to use Lua classes in BeamNG modding — metatables, constructors, inheritance, with real examples like parcels, checkpoints, and NPC fleets.
When your mod manages multiple instances of the same thing — delivery parcels, mission checkpoints, NPC vehicles — you need a way to create objects with shared behavior and individual state. Lua classes (built from metatables) are the right tool for this.
What is a Lua class?
Lua doesn't have built-in classes. Instead, you build them from tables and metatables. The core trick is the __index metamethod — when Lua can't find a key in a table, it checks __index for a fallback table:
local Dog = {}
Dog.__index = Dog
function Dog.new(name, breed)
local self = setmetatable({}, Dog)
self.name = name
self.breed = breed
return self
end
function Dog:bark()
return self.name .. " says woof!"
endHere's what's happening:
Dogis a plain table that doubles as the class and the metatableDog.__index = Dogmeans "if an instance doesn't have a key, look inDog"Dog.new()creates a new table and setsDogas its metatable viasetmetatableDog:bark()uses colon syntax — Lua automatically passes the table asself
When you call myDog:bark(), Lua looks for bark in myDog, doesn't find it, checks __index (which points to Dog), and finds Dog.bark. The colon passes myDog as self.
When to use classes
Classes shine when you need multiple instances of the same thing, each with their own state and shared behavior:
| Scenario | Use a class? |
|---|---|
| Managing 15 delivery parcels with origin, destination, status | ✅ Yes |
| Tracking route nodes that each have position, radius, rewards | ✅ Yes |
| NPC drivers with individual behavior and state | ✅ Yes |
| Mission checkpoints/markers with shared logic | ✅ Yes |
| A single extension that handles phone UI | ❌ No — plain M table |
| Config data loaded from JSON | ❌ No — plain table |
| A singleton manager (one instance ever) | ❌ No — plain M table |
The rule of thumb: If you're about to create multiple tables with the same shape and the same functions operating on them, that's a class.
When NOT to use classes
Extensions are never classes
The extension M table must always be a plain table. Never do this:
-- ❌ WRONG — extension as a class
local M = {}
M.__index = M
function M:onExtensionLoaded() -- colon syntax on extension = broken
self.data = {}
end
return MBeamNG calls extension functions with dot syntax (M.onExtensionLoaded()). If you use colon syntax, self will be wrong or nil. Extensions are singletons by nature — there's only ever one instance.
Singletons and config don't need classes
-- ❌ Overkill
local Config = {}
Config.__index = Config
function Config.new()
return setmetatable({ maxParcels = 10, payScale = 1.5 }, Config)
end
-- ✅ Just use a table
local config = { maxParcels = 10, payScale = 1.5 }Creating a class step by step
Let's build a RouteNode class — something you'd use in a delivery or racing mod to represent waypoints:
Step 1: Define the class table
local RouteNode = {}
RouteNode.__index = RouteNodeStep 2: Write the constructor
function RouteNode.new(pos, radius, reward)
local self = setmetatable({}, RouteNode)
self.pos = pos -- vec3
self.radius = radius or 5 -- default 5 meters
self.reward = reward or 0
self.reached = false
self.reachedTime = nil
return self
endNote: .new() uses dot syntax — it's a factory function, not a method on an instance.
Step 3: Add methods
function RouteNode:isVehicleInside(vehPos)
return self.pos:distance(vehPos) <= self.radius
end
function RouteNode:markReached()
self.reached = true
self.reachedTime = os.clock()
end
function RouteNode:getTimeBonus()
if not self.reached then return 0 end
local elapsed = os.clock() - self.reachedTime
return math.max(0, self.reward - elapsed * 2) -- decaying bonus
endMethods use colon syntax (:) — Lua passes the instance as self automatically.
Step 4: Use it in your extension
local M = {}
local RouteNode = {}
RouteNode.__index = RouteNode
-- ... constructor and methods from above ...
local nodes = {}
local function setupRoute(waypoints)
nodes = {}
for _, wp in ipairs(waypoints) do
table.insert(nodes, RouteNode.new(wp.pos, wp.radius, wp.reward))
end
end
local function onUpdate(dtReal, dtSim, dtRaw)
local vehPos = be:getPlayerVehicle(0):getPosition()
for _, node in ipairs(nodes) do
if not node.reached and node:isVehicleInside(vehPos) then
node:markReached()
log('I', 'route', 'Reached node! Bonus: ' .. node:getTimeBonus())
end
end
end
M.setupRoute = setupRoute
M.onUpdate = onUpdate
return MThe golden rule: The extension is always a plain M table. Classes live inside it.
Inheritance
One class can extend another. The pattern chains __index lookups:
-- Base class
local Marker = {}
Marker.__index = Marker
function Marker.new(pos, name)
local self = setmetatable({}, Marker)
self.pos = pos
self.name = name or "marker"
self.visible = true
return self
end
function Marker:show()
self.visible = true
end
function Marker:hide()
self.visible = false
end
function Marker:getLabel()
return self.name
end
-- Child class
local PickupMarker = setmetatable({}, { __index = Marker })
PickupMarker.__index = PickupMarker
function PickupMarker.new(pos, name, itemType, quantity)
local self = Marker.new(pos, name) -- call parent constructor
setmetatable(self, PickupMarker) -- re-set to child metatable
self.itemType = itemType
self.quantity = quantity or 1
self.pickedUp = false
return self
end
function PickupMarker:pickup()
self.pickedUp = true
self:hide()
end
function PickupMarker:getLabel() -- override parent
return string.format("%s (%s x%d)", self.name, self.itemType, self.quantity)
endLookup chain: instance → PickupMarker → Marker. If PickupMarker doesn't define a method, Lua follows __index up to Marker.
local m = PickupMarker.new(vec3(100, 200, 30), "Crate", "electronics", 3)
m:show() -- inherited from Marker
m:pickup() -- defined on PickupMarker
m:getLabel() -- overridden: "Crate (electronics x3)"Common patterns
Default values in constructor
Use or for simple defaults and explicit nil checks for booleans:
function Mission.new(data)
local self = setmetatable({}, Mission)
self.id = data.id
self.name = data.name or "Unnamed Mission"
self.difficulty = data.difficulty or 1
self.timeLimit = data.timeLimit or 120
self.active = data.active ~= false -- default true, only false if explicitly set
self.attempts = 0
return self
end__tostring metamethod
Invaluable for debugging. When you tostring() or print an instance, Lua calls this:
local Parcel = {}
Parcel.__index = Parcel
function Parcel.new(origin, destination, weight)
return setmetatable({
origin = origin,
destination = destination,
weight = weight,
delivered = false,
}, Parcel)
end
function Parcel:__tostring()
local status = self.delivered and "DELIVERED" or "pending"
return string.format("Parcel[%s → %s, %dkg, %s]", self.origin, self.destination, self.weight, status)
end
-- Usage:
local p = Parcel.new("Belasco", "Hirochi Raceway", 25)
log('I', 'parcels', tostring(p))
-- Output: Parcel[Belasco → Hirochi Raceway, 25kg, pending]Factory functions
When construction logic is complex or varies by type, use factory functions instead of overloading the constructor:
local NPC = {}
NPC.__index = NPC
function NPC.new(name, vehicleId, behavior)
return setmetatable({
name = name,
vehicleId = vehicleId,
behavior = behavior,
state = "idle",
targetPos = nil,
}, NPC)
end
-- Factory: create from scenario data
function NPC.fromScenarioData(data)
local npc = NPC.new(data.name, data.vehId, data.ai or "traffic")
npc.targetPos = data.destination and vec3(data.destination) or nil
return npc
end
-- Factory: create a parked NPC
function NPC.parked(name, vehicleId)
local npc = NPC.new(name, vehicleId, "none")
npc.state = "parked"
return npc
endReal BeamNG examples
Managing delivery parcels
local M = {}
local Parcel = {}
Parcel.__index = Parcel
function Parcel.new(data)
return setmetatable({
id = data.id,
origin = data.origin,
destination = data.destination,
weight = data.weight or 1,
fragile = data.fragile or false,
value = data.value or 50,
delivered = false,
damageAccumulated = 0,
}, Parcel)
end
function Parcel:applyDamage(amount)
if self.fragile then
amount = amount * 2.5
end
self.damageAccumulated = self.damageAccumulated + amount
end
function Parcel:calculatePayout()
local damagePenalty = self.damageAccumulated * 10
return math.max(0, self.value - damagePenalty)
end
function Parcel:deliver()
self.delivered = true
return self:calculatePayout()
end
function Parcel:__tostring()
return string.format("Parcel#%s [%s→%s $%d]", self.id, self.origin, self.destination, self.value)
end
-- Extension state
local inventory = {}
local function loadParcels(parcelDataList)
inventory = {}
for _, data in ipairs(parcelDataList) do
table.insert(inventory, Parcel.new(data))
end
end
local function onVehicleDamage(damageAmount)
for _, parcel in ipairs(inventory) do
if not parcel.delivered then
parcel:applyDamage(damageAmount)
end
end
end
local function deliverParcel(parcelId)
for _, parcel in ipairs(inventory) do
if parcel.id == parcelId and not parcel.delivered then
local payout = parcel:deliver()
log('I', 'delivery', tostring(parcel) .. ' delivered for $' .. payout)
return payout
end
end
return 0
end
M.loadParcels = loadParcels
M.onVehicleDamage = onVehicleDamage
M.deliverParcel = deliverParcel
return MMission checkpoint system
local M = {}
local Checkpoint = {}
Checkpoint.__index = Checkpoint
function Checkpoint.new(pos, radius, index)
return setmetatable({
pos = pos,
radius = radius or 4,
index = index,
reached = false,
reachedAt = nil,
}, Checkpoint)
end
function Checkpoint:check(vehPos)
if self.reached then return false end
if self.pos:distance(vehPos) <= self.radius then
self.reached = true
self.reachedAt = os.clock()
return true
end
return false
end
-- Mission state
local checkpoints = {}
local nextIndex = 1
local function startMission(waypointList)
checkpoints = {}
nextIndex = 1
for i, wp in ipairs(waypointList) do
checkpoints[i] = Checkpoint.new(vec3(wp.x, wp.y, wp.z), wp.radius, i)
end
end
local function onUpdate(dtReal, dtSim, dtRaw)
if nextIndex > #checkpoints then return end
local vehPos = be:getPlayerVehicle(0):getPosition()
local cp = checkpoints[nextIndex]
if cp:check(vehPos) then
log('I', 'mission', string.format('Checkpoint %d/%d reached', nextIndex, #checkpoints))
nextIndex = nextIndex + 1
if nextIndex > #checkpoints then
log('I', 'mission', 'All checkpoints reached!')
end
end
end
M.startMission = startMission
M.onUpdate = onUpdate
return MNPC fleet manager
local M = {}
local NPC = {}
NPC.__index = NPC
function NPC.new(name, vehicleId)
return setmetatable({
name = name,
vehicleId = vehicleId,
state = "idle", -- idle, driving, parked, despawned
route = nil,
spawnTime = os.clock(),
}, NPC)
end
function NPC:assignRoute(route)
self.route = route
self.state = "driving"
local veh = be:getObjectByID(self.vehicleId)
if veh then
veh:queueLuaCommand("ai.driveUsingPath({wpTargetList = " .. serialize(route) .. "})")
end
end
function NPC:despawn()
self.state = "despawned"
local veh = be:getObjectByID(self.vehicleId)
if veh then veh:delete() end
end
function NPC:isAlive()
return self.state ~= "despawned"
end
function NPC:__tostring()
return string.format("NPC[%s veh:%d %s]", self.name, self.vehicleId, self.state)
end
-- Extension manages the fleet
local fleet = {}
local function spawnNPC(name, vehicleId, route)
local npc = NPC.new(name, vehicleId)
if route then
npc:assignRoute(route)
end
table.insert(fleet, npc)
log('I', 'npcMgr', 'Spawned ' .. tostring(npc))
return npc
end
local function despawnAll()
for _, npc in ipairs(fleet) do
if npc:isAlive() then npc:despawn() end
end
fleet = {}
end
local function getActiveCount()
local count = 0
for _, npc in ipairs(fleet) do
if npc:isAlive() then count = count + 1 end
end
return count
end
M.spawnNPC = spawnNPC
M.despawnAll = despawnAll
M.getActiveCount = getActiveCount
return MSummary
| Concept | Syntax | Purpose |
|---|---|---|
| Class table | local Foo = {} | Holds methods and __index |
| Metatable link | Foo.__index = Foo | Enables method lookup on instances |
| Constructor | function Foo.new(...) | Creates instances with setmetatable |
| Methods | function Foo:method() | Colon passes self automatically |
| Inheritance | setmetatable(Child, {__index = Parent}) | Chains lookup to parent |
__tostring | function Foo:__tostring() | Custom string output for debugging |
Remember: The extension is always a plain M table. Classes live inside it for managing instances. If you only ever need one of something, you don't need a class.
Vehicle Boot Sequence & Lifecycle
The exact order in which vehicle systems initialize — from JBeam compilation to physics loops. Essential for timing your VE code correctly.
Proven GE Patterns
12 battle-tested patterns for BeamNG GE mods — save/load, UI data flow, screen transitions, vehicle callbacks, settings, and more.