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

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

Using 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() then

2. 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)
end

Using 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 = onVehicleSpawned

Not 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("...")
end

4. 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 = onModDeactivated

Not 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(...)
end

5. 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())
end

Sending 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()
end

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

8. 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
end

Comparing 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 entries

9. 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 M

Not 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 = onCareerActive

See Also

  • Common Patterns - Correct patterns to use instead
  • Performance - Performance-specific anti-patterns
  • Error Handling - Defensive coding patterns
  • Debugging - Finding and fixing problems

Proven GE Patterns

12 battle-tested patterns for BeamNG GE mods — save/load, UI data flow, screen transitions, vehicle callbacks, settings, and more.

Error Handling & Defensive Coding

How errors propagate in BeamNG Lua, pcall/xpcall patterns, nil safety, and preventing silent state corruption.

On this page

1. Career Mode GuardsNot checking career active before accessing career modulesUsing only career_career.isActive() without nil check2. Save SystemWriting directly to save path without ensuring directory existsUsing jsonWriteFile for save data instead of jsonWriteFileSafeNot handling nil from getCurrentSaveSlot3. Vehicle Spawning & AccessRace condition: using vehicle immediately after spawnNot nil-checking be:getPlayerVehicle(0)Forgetting be:getObjectByID can return nil4. Extension LifecycleForgetting to clean up on mod deactivationNot setting manual unload mode for persistent extensionsHooking extensions.load without preserving the original5. UI CommunicationSending UI data without career/state checkSending non-serializable data to UI6. Blocking & PerformanceDoing heavy work in onUpdate or onPreRenderUsing sleep() or busy-wait loops7. String & Path HandlingMixing extension name formatsHardcoding save paths instead of using the hook parameter8. Table & Data HandlingModifying a table while iterating itComparing empty table with == {}9. GE ↔ VE CommunicationBuilding VE commands with unescaped stringsExpecting synchronous return from VE commands10. Module StateUsing global variables instead of module-local stateNot resetting state on career deactivationSee Also