Anti-Patterns to Avoid
Common BeamNG modding mistakes that cause crashes, data loss, and silent failures — with correct alternatives for each.
Every anti-pattern below was found in real mod code and caused real problems — crashes, corrupted saves, or subtle bugs that only appear after hours of play. Learn from others' mistakes.
1. Career Mode Guards
Not checking career active before accessing career modules
❌ Don't - Access career modules without checking career state:
local function doSomething()
local money = career_modules_playerAttributes.getAttributeValue("money")
-- CRASH if career is not active - module may not be loaded
end✅ Do - Always guard with career_career check:
local function doSomething()
if not career_career or not career_career.isActive() then return end
local money = career_modules_playerAttributes.getAttributeValue("money")
endUsing only career_career.isActive() without nil check
❌ Don't - Skip the nil check on the module itself:
if career_career.isActive() then -- CRASH if career_career is nil✅ Do - Check both existence and state:
if career_career and career_career.isActive() then2. 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 career/state check
❌ Don't - Trigger UI events when state is invalid:
guihooks.trigger("vehicleInventoryData", getInventory())
-- If career isn't active, getInventory() may return garbage or crash✅ Do - Guard the trigger:
if career_career and career_career.isActive() then
guihooks.trigger("vehicleInventoryData", getInventory())
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 career deactivation
❌ Don't - Leave stale state from previous career session:
local purchasedItems = {}
-- If player ends career and starts new one, purchasedItems still has old data✅ Do - Reset on career activation:
local purchasedItems = {}
local function onCareerActive(active)
if active then
purchasedItems = {} -- fresh start
loadFromSave()
end
end
M.onCareerActive = onCareerActiveSee Also
- Common Patterns - Correct patterns to use instead
- Performance - Performance-specific anti-patterns
- Error Handling - Defensive coding patterns
- Debugging - Finding and fixing problems