Lua Coding Standards
Write clean, readable, maintainable Lua code. Covers naming, structure, comments, defensive coding, and Lua-specific idioms.
Good code is read 10x more than it's written. These standards help you write Lua that's easy to understand, modify, and debug — whether you're building career modules, vehicle extensions, or UI integrations.
Naming Conventions
Consistent naming makes code scannable. Follow these Lua-community conventions:
| What | Convention | Example |
|---|---|---|
| Local variables | snake_case | vehicle_count, save_path |
| Module methods | camelCase | M.getData, M.onVehicleSpawned |
| Constants | UPPER_CASE | MAX_VEHICLES, DEFAULT_FUEL |
| Classes/Modules | CamelCase | VehicleManager |
| Private/internal | _underscore prefix | _cachedData, _buildIndex |
| Throwaway vars | _ | for _, item in ipairs(items) |
-- ✅ Good
local MAX_RETRIES = 3
local save_path = currentSavePath .. "/career/myMod"
local function onVehicleSpawned(vehicleId) end
-- ❌ Bad
local MAXRETRIES = 3 -- no separation
local s = currentSavePath .. "/career/myMod" -- cryptic
local function vehiclespawned(vid) end -- no conventionScope rule: Variables with larger scope need more descriptive names. Single-letter names are fine for loop counters and tiny scopes — nowhere else.
-- Fine: small scope, obvious meaning
for i = 1, #vehicles do
local v = vehicles[i]
end
-- Not fine: large scope, cryptic name
local d = {} -- what is d? data? directions? damage?Function Design
Functions should do one thing. If you have to read the body to understand what a function does, the name is wrong or the function is too big.
Name functions after what they do
-- ✅ Name tells you everything
local function calculateRepairCost(vehicle, damage)
local base_cost = damage.severity * vehicle.repairRate
return base_cost * getDifficultyMultiplier()
end
-- ❌ Name is vague, you have to read the body
local function process(v, d)
local c = d.severity * v.repairRate
return c * getDifficultyMultiplier()
endUse early returns instead of deep nesting
-- ✅ Guard clauses at the top, main logic at normal indentation
local function startDelivery(vehicleId, destination)
if not career_career.isActive() then return end
local veh = be:getObjectByID(vehicleId)
if not veh then return end
if not destination then return end
local route = calculateRoute(veh, destination)
beginTracking(veh, route)
end
-- ❌ Pyramid of doom
local function startDelivery(vehicleId, destination)
if career_career.isActive() then
local veh = be:getObjectByID(vehicleId)
if veh then
if destination then
local route = calculateRoute(veh, destination)
beginTracking(veh, route)
end
end
end
endExtract when there's a gap between intention and implementation
Don't extract just to hit a line count. Extract when a block of code needs a comment to explain what it's doing — the function name replaces the comment.
-- ✅ The function name IS the documentation
local function hasEnoughFuel(vehicle)
local fuel = vehicle.electrics.fuel or 0
return fuel > 0.05
end
local function canStartRace(vehicle)
return hasEnoughFuel(vehicle) and not vehicle.damaged
endComment Quality
Comments should explain why, never what. If code needs a comment to explain what it does, fix the code.
Comments that add value
-- ✅ Explains WHY — not obvious from the code
local function onVehicleSpawned(_, veh)
-- Delay setup by one frame because VE extensions aren't loaded yet on spawn
core_jobsystem.create(function(job)
job.sleep(0.1)
initializeVehicle(veh:getId())
end)
end
-- ✅ Documents a non-obvious business rule
local repair_cost = math.max(base_cost, 50) -- minimum $50 to prevent free exploit via tiny damage
-- ✅ Links to external context
-- See: https://wiki.beamng.com/Modding/Lua_Electrics
local fuel = electrics.values.fuel or 0Comments that are noise
-- ❌ Restates the code
local careerData = {} -- Initialize the career data table
careerData.money = startingMoney -- Set the player's money
return careerData -- Return the career data
-- ❌ Restates the function name
--- Gets the vehicle data
local function getVehicleData(vehicleId)
-- ❌ Obvious from context
local veh = be:getPlayerVehicle(0) -- Get the player's vehicle
if not veh then return end -- Return if no vehicleThe rule: If deleting a comment loses zero information, delete it.
Module Structure
Every GE extension follows the same pattern. Use it consistently.
local M = {}
M.dependencies = {'career_career', 'career_saveSystem'}
local logTag = 'myModule'
-- ================================
-- STATE
-- ================================
local state = {}
-- ================================
-- PRIVATE FUNCTIONS
-- ================================
local function resetState()
state = {}
end
local function doInternalWork(data)
-- no nil checks needed — callers validate before calling
return data.value * 2
end
-- ================================
-- PUBLIC API
-- ================================
local function getData()
return deepcopy(state)
end
-- ================================
-- LIFECYCLE HOOKS
-- ================================
local function onExtensionLoaded()
resetState()
end
local function onCareerActive(active)
if not active then resetState() end
end
-- ================================
-- EXPORTS
-- ================================
M.getData = getData
M.onExtensionLoaded = onExtensionLoaded
M.onCareerActive = onCareerActive
return MRules:
- Never use the deprecated
module()function orpackage.seeall - Return a table of explicitly defined exports
- No side effects on
require— only load dependencies and return the table - Name your require variable after the module:
local bar = require("foo.bar")
Defensive Coding Done Right
Key principles:
- Validate at boundaries, trust internals — Public APIs validate inputs; private helpers trust their callers already validated.
- Only nil-check things that can actually be nil —
be:getPlayerVehicle(0)can be nil (no vehicle), butcore_gamestateis always loaded. Ask: "Can I name a scenario where this is nil?" - Don't wrap internal code in pcall — Reserve
pcallfor trust boundaries (file I/O, user input, deserialization).
For the full treatment with examples, see Anti-Patterns (sections 1 and 3) and Error Handling.
Lua Idioms
Defaults with or
-- ✅ Idiomatic Lua
local name = config.name or "Unknown"
local count = options.count or 10
local enabled = options.enabled ~= false -- default true (nil → true)
-- ❌ Verbose alternative
local name
if config.name then
name = config.name
else
name = "Unknown"
endor default gotcha
x or default treats false the same as nil. If false is a valid value, use an explicit check:
local show_ui = (config.show_ui ~= nil) and config.show_ui or true -- wrong if false is valid
local show_ui = config.show_ui == nil and true or config.show_ui -- handles false correctlyThe and/or ternary
-- ✅ Clean conditional assignment
local label = is_active and "Running" or "Stopped"
local color = fuel > 0.2 and green or redLocal is faster
In hot paths, cache global lookups in locals:
-- ✅ ~10x faster in tight loops
local sin = math.sin
local insert = table.insert
local format = string.format
for i = 1, 100000 do
insert(results, sin(i))
endFor normal code, don't bother — readability first.
The # operator
-- ✅ Safe: contiguous arrays
local items = {"a", "b", "c"}
print(#items) -- 3
-- ❌ Dangerous: sparse tables
local t = {1, 2, nil, 4, nil}
print(#t) -- undefined! Could be 2 or 4 or 5Rule: Never rely on # for tables that may have nil holes. Use explicit counting or table.maxn for sparse tables.
Table Best Practices
Use constructor syntax
-- ✅ Clean, readable, diff-friendly (trailing comma!)
local config = {
enabled = true,
name = "delivery",
reward = 500,
}
-- ❌ Verbose
local config = {}
config.enabled = true
config.name = "delivery"
config.reward = 500Return copies, not references
-- ✅ Caller can't corrupt your state
function M.getData()
return deepcopy(state)
end
-- ❌ Caller can modify your internal state
function M.getData()
return state -- dangerous!
endCheck empty with next()
-- ✅ Correct
if not next(myTable) then
log('I', logTag, 'Table is empty')
end
-- ❌ Wrong — always creates a new table, never equal
if myTable == {} then endCollect then modify
-- ✅ Safe: collect keys first
local to_remove = {}
for k, v in pairs(vehicles) do
if v.destroyed then
table.insert(to_remove, k)
end
end
for _, k in ipairs(to_remove) do
vehicles[k] = nil
end
-- ❌ Dangerous: modifying during iteration
for k, v in pairs(vehicles) do
if v.destroyed then
vehicles[k] = nil -- undefined behavior
end
endError Handling
See Error Handling for pcall patterns and defensive error strategies.
See Also
- Common Patterns — Battle-tested patterns for BeamNG mods
- Anti-Patterns — Mistakes to avoid
- Cleaning Up AI-Generated Code — Fix common AI coding slop
- Error Handling — Defensive coding patterns
- Performance — Keeping your mod fast
Lua Classes & Metatables
When and how to use Lua classes in BeamNG modding — metatables, constructors, inheritance, with practical examples like parcels, checkpoints, and NPC fleets.
Proven GE Patterns
12 battle-tested patterns for BeamNG GE mods — save/load, UI data flow, screen transitions, vehicle callbacks, settings, and more.