RLS Studios
ProjectsPatreonCommunityDocsAbout
Join Patreon
BeamNG Modding Docs

Guides

Lua Classes & MetatablesProven GE PatternsAnti-Patterns to AvoidError Handling & Defensive CodingPerformance OptimizationLua Modding FAQ

Reference

UI

Resources

BeamNG Game Engine Lua Cheat SheetGE Developer RecipesMCP Server Setup

// RLS.STUDIOS=true

Premium Mods for BeamNG.drive. Career systems, custom vehicles, and immersive gameplay experiences.

Index

HomeProjectsPatreon

Socials

DiscordPatreon (RLS)Patreon (Vehicles)

© 2026 RLS Studios. All rights reserved.

Modding since 2024

GuidesLua

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!"
end

Here's what's happening:

  1. Dog is a plain table that doubles as the class and the metatable
  2. Dog.__index = Dog means "if an instance doesn't have a key, look in Dog"
  3. Dog.new() creates a new table and sets Dog as its metatable via setmetatable
  4. Dog:bark() uses colon syntax — Lua automatically passes the table as self

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:

ScenarioUse 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 M

BeamNG 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 = RouteNode

Step 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
end

Note: .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
end

Methods 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 M

The 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)
end

Lookup 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
end

Real 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 M

Mission 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 M

NPC 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 M

Summary

ConceptSyntaxPurpose
Class tablelocal Foo = {}Holds methods and __index
Metatable linkFoo.__index = FooEnables method lookup on instances
Constructorfunction Foo.new(...)Creates instances with setmetatable
Methodsfunction Foo:method()Colon passes self automatically
Inheritancesetmetatable(Child, {__index = Parent})Chains lookup to parent
__tostringfunction 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.

On this page

What is a Lua class?When to use classesWhen NOT to use classesExtensions are never classesSingletons and config don't need classesCreating a class step by stepStep 1: Define the class tableStep 2: Write the constructorStep 3: Add methodsStep 4: Use it in your extensionInheritanceCommon patternsDefault values in constructor__tostring metamethodFactory functionsReal BeamNG examplesManaging delivery parcelsMission checkpoint systemNPC fleet managerSummary