Anti-Patterns to Avoid
Common BeamNG modding mistakes that cause crashes, data loss, and silent failures — with correct alternatives for each.
Common modding mistakes that cause crashes, corrupted saves, or subtle bugs that only appear after hours of play.
1. Module Existence Checks
When do you actually need nil checks?
Core modules (always loaded by the engine — core_*, gameplay_*, freeroam_*, etc.) are safe to call without nil checks. Dynamically loaded modules (loaded conditionally at runtime, by specific game modes, or by other mods) should be nil-checked before use. The rule: if a module might not be loaded when your code runs, check for it. If it's always there, don't clutter your code with unnecessary guards.
Calling a dynamically loaded module without checking
❌ Don't - Call a module that may not be loaded:
local function doSomething()
myMod_economy.addMoney(100)
-- CRASH if myMod_economy isn't loaded (e.g., mod not active, optional dependency)
end✅ Do - Guard calls to modules that may not exist:
local function doSomething()
if not myMod_economy then return end
myMod_economy.addMoney(100)
end✅ Fine - No guard needed for core/always-loaded modules:
local function doSomething()
-- core_vehicles is always loaded - no nil check needed
local veh = core_vehicles.getPlayerVehicle()
endNil-checking everything "just to be safe"
❌ Don't - Unnecessary guards add noise and hide real issues:
if core_gamestate then -- always loaded, this check is pointless
core_gamestate.requestEnterLoadingScreen("myTag")
end✅ Do - Only check modules that are genuinely optional:
core_gamestate.requestEnterLoadingScreen("myTag") -- core module, always available
if someOptionalMod_feature then -- dynamically loaded, check is warranted
someOptionalMod_feature.activate()
end2. Save System
Writing directly to save path without ensuring directory exists
❌ Don't - Write files assuming directories exist:
local function onSaveCurrentSaveSlot(currentSavePath)
jsonWriteFile(currentSavePath .. "/career/myMod/data.json", data, true)
-- FAILS silently if /career/myMod/ doesn't exist
end✅ Do - Create directory first, use safe write:
local function onSaveCurrentSaveSlot(currentSavePath)
local dirPath = currentSavePath .. "/career/myMod"
if not FS:directoryExists(dirPath) then
FS:directoryCreate(dirPath)
end
career_saveSystem.jsonWriteFileSafe(dirPath .. "/data.json", data, true)
endUsing jsonWriteFile for save data instead of jsonWriteFileSafe
❌ Don't - Direct write can corrupt on crash:
jsonWriteFile(savePath .. "/data.json", data, true)✅ Do - Safe write uses temp file + rename (atomic):
career_saveSystem.jsonWriteFileSafe(savePath .. "/data.json", data, true)Not handling nil from getCurrentSaveSlot
❌ Don't - Assume save slot always exists:
local _, path = career_saveSystem.getCurrentSaveSlot()
local data = jsonReadFile(path .. "/career/myMod/data.json")
-- path could be nil → string concatenation crash✅ Do - Guard the return value:
local _, path = career_saveSystem.getCurrentSaveSlot()
if not path then return end
local data = jsonReadFile(path .. "/career/myMod/data.json") or {}3. Vehicle Spawning & Access
Race condition: using vehicle immediately after spawn
❌ Don't - Use vehicle object right after spawning:
local veh = spawn.spawnVehicle("etk800", options)
veh:queueLuaCommand("partCondition.initConditions()")
-- Vehicle VE may not be ready yet - command may be lost✅ Do - Use onVehicleSpawned hook or callback chain:
local pendingVehicles = {}
local function onVehicleSpawned(_, veh)
local id = veh:getId()
if pendingVehicles[id] then
veh:queueLuaCommand("partCondition.initConditions()")
pendingVehicles[id] = nil
end
end
M.onVehicleSpawned = onVehicleSpawnedNot nil-checking be:getPlayerVehicle(0)
❌ Don't - Assume a player vehicle exists:
local veh = be:getPlayerVehicle(0)
local id = veh:getId() -- CRASH if no vehicle (walking mode, loading)✅ Do - Always nil-check:
local veh = be:getPlayerVehicle(0)
if not veh then return end
local id = veh:getId()Forgetting be:getObjectByID can return nil
❌ Don't - Chain calls without checking:
be:getObjectByID(vehId):queueLuaCommand("...") -- CRASH if ID is stale✅ Do - Store and check:
local veh = be:getObjectByID(vehId)
if veh then
veh:queueLuaCommand("...")
end4. Extension Lifecycle
Forgetting to clean up on mod deactivation
❌ Don't - Load extensions without tracking them:
local function onExtensionLoaded()
extensions.load("gameplay_taxi")
extensions.load("gameplay_bus")
-- These stay loaded even if your mod is deactivated
end✅ Do - Clean up in onModDeactivated:
local function onModDeactivated(modData)
if modData.modname == "my_mod" then
extensions.unload("gameplay_taxi")
extensions.unload("gameplay_bus")
end
end
M.onModDeactivated = onModDeactivatedNot setting manual unload mode for persistent extensions
❌ Don't - Load extension that gets auto-unloaded on level change:
extensions.load("my_persistent_feature")
-- Gets unloaded when player changes level✅ Do - Set manual unload mode first:
setExtensionUnloadMode("my_persistent_feature", "manual")
extensions.load("my_persistent_feature")Hooking extensions.load without preserving the original
❌ Don't - Override without backup:
extensions.load = myCustomLoad -- original is lost forever✅ Do - Store and delegate:
local originalLoad = extensions.load
extensions.load = function(...)
-- custom logic
return originalLoad(...)
end5. UI Communication
Sending UI data without validating state
❌ Don't - Trigger UI events when the data source may be invalid:
guihooks.trigger("myModData", getData())
-- If the module providing getData() isn't loaded, this crashes or sends garbage✅ Do - Validate state before triggering:
local data = getData()
if data then
guihooks.trigger("myModData", data)
endSending non-serializable data to UI
❌ Don't - Pass functions or userdata to guihooks:
guihooks.trigger("myData", { callback = function() end, veh = be:getPlayerVehicle(0) })
-- Functions and engine objects can't be serialized to JSON for the UI layer✅ Do - Send only plain data (strings, numbers, bools, tables):
guihooks.trigger("myData", { vehicleId = veh:getId(), name = "ETK 800" })6. Blocking & Performance
Doing heavy work in onUpdate or onPreRender
❌ Don't - Run expensive operations every frame:
local function onPreRender(dt)
local allFiles = FS:findFiles("/vehicles/", "*.pc", -1, true, false)
-- Scans filesystem 60+ times per second
end✅ Do - Use throttled timers or one-time init:
local updateTimer = 0
local function onPreRender(dt)
updateTimer = updateTimer + dt
if updateTimer < 1.0 then return end -- run once per second
updateTimer = 0
doExpensiveWork()
endUsing sleep() or busy-wait loops
❌ Don't - Block the main thread:
-- This freezes the entire game
local start = os.clock()
while os.clock() - start < 2 do end✅ Do - Use the job system for async delays:
core_jobsystem.create(function(job)
job.sleep(2)
doDelayedWork()
end)7. String & Path Handling
Mixing extension name formats
❌ Don't - Use slash paths where underscores are expected:
extensions.load("career/modules/inventory") -- WRONG format✅ Do - Use underscore-separated names:
extensions.load("career_modules_inventory")Hardcoding save paths instead of using the hook parameter
❌ Don't - Construct save paths manually:
local function onSaveCurrentSaveSlot()
local _, path = career_saveSystem.getCurrentSaveSlot()
-- This gets the CURRENT path, not necessarily the path being saved to
end✅ Do - Use the path passed to the hook:
local function onSaveCurrentSaveSlot(currentSavePath)
-- currentSavePath is the ACTUAL target save directory
local dirPath = currentSavePath .. "/career/myMod"
end8. Table & Data Handling
Modifying a table while iterating it
❌ Don't - Remove items during iteration:
for k, v in pairs(myTable) do
if shouldRemove(v) then
myTable[k] = nil -- Undefined behavior in pairs()
end
end✅ Do - Collect keys first, then remove:
local toRemove = {}
for k, v in pairs(myTable) do
if shouldRemove(v) then
table.insert(toRemove, k)
end
end
for _, k in ipairs(toRemove) do
myTable[k] = nil
endComparing empty table with == {}
❌ Don't - Tables are compared by reference:
if myTable == {} then -- ALWAYS false - {} creates a new table✅ Do - Check if table is empty with next():
if not next(myTable) then -- true if table has no entries9. GE ↔ VE Communication
Building VE commands with unescaped strings
❌ Don't - Concatenate raw strings into Lua code:
local name = 'O\'Brien'
veh:queueLuaCommand("myFunc('" .. name .. "')")
-- Produces: myFunc('O'Brien') - syntax error in VE✅ Do - Use serialize() for safe embedding:
veh:queueLuaCommand(string.format("myFunc(%s)", serialize(name)))Expecting synchronous return from VE commands
❌ Don't - Treat queueLuaCommand as synchronous:
veh:queueLuaCommand("electrics.values.fuel = 1.0")
local fuel = getFuelFromVE() -- This runs BEFORE the VE command executes✅ Do - Use callback pattern (VE calls back to GE):
veh:queueLuaCommand("obj:queueGameEngineLua('myMod.onFuelResult(' .. electrics.values.fuel .. ')')")10. Module State
Using global variables instead of module-local state
❌ Don't - Pollute global scope:
myModState = {} -- Global - conflicts with other mods, survives reload
function doThing()
myModState.count = (myModState.count or 0) + 1
end✅ Do - Use module-local variables:
local M = {}
local state = {}
local function doThing()
state.count = (state.count or 0) + 1
end
return MNot resetting state on mode/session change
❌ Don't - Leave stale state from a previous session:
local trackedData = {}
-- If the player switches modes or reloads, trackedData still has old values✅ Do - Reset state when your mode activates or the extension reloads:
local trackedData = {}
local function onExtensionLoaded()
trackedData = {} -- fresh start
loadFromSave()
end
M.onExtensionLoaded = onExtensionLoadedSee Also
- Common Patterns - Correct patterns to use instead
- Lua Coding Standards - Write clean, readable Lua
- Cleaning Up AI-Generated Code - Fix common AI coding slop
- Performance - Performance-specific anti-patterns
- Error Handling - Defensive coding patterns
- Debugging - Finding and fixing problems