RLS Studios
ProjectsPatreonCommunityDocsAbout
Join Patreon
BeamNG Modding Docs

Guides

Lua Classes & MetatablesLua Coding StandardsProven GE PatternsAnti-Patterns to AvoidOverriding Lua ModulesCleaning Up AI-Generated CodeError 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.

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

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

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 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)
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 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 = onExtensionLoaded

See 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

Proven GE Patterns

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

Overriding Lua Modules

How to safely override and extend existing Lua module functions without breaking other mods.

On this page

1. Module Existence ChecksCalling a dynamically loaded module without checkingNil-checking everything "just to be safe"2. 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 validating stateSending 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 mode/session changeSee Also