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

Proven GE Patterns

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

These patterns are extracted from production BeamNG mods. Each one solves a specific problem you'll encounter — saving data, communicating with vehicles, integrating with the computer UI, managing mod lifecycle. Copy them as starting points for your own code.


1. Extension Module Template

The standard structure for any GE extension module.

local M = {}
M.dependencies = {'career_career', 'career_saveSystem'}

local logTag = 'myModule'

-- ================================
-- STATE VARIABLES (module-local)
-- ================================
local state = {}
local initialized = false

-- ================================
-- PRIVATE FUNCTIONS
-- ================================
local function resetState()
  state = {}
  initialized = false
end

local function doWork()
  if not career_career or not career_career.isActive() then return end
  -- module logic here
end

-- ================================
-- PUBLIC API
-- ================================
local function getData()
  return deepcopy(state)  -- return copy, not reference
end

-- ================================
-- LIFECYCLE HOOKS
-- ================================
local function onExtensionLoaded()
  log('I', logTag, 'Module loaded')
  resetState()
end

local function onCareerModulesActivated(alreadyInLevel)
  initialized = true
  -- load persistent data here
end

local function onCareerActive(active)
  if not active then resetState() end
end

-- ================================
-- EXPORTS
-- ================================
M.getData = getData
M.onExtensionLoaded = onExtensionLoaded
M.onCareerModulesActivated = onCareerModulesActivated
M.onCareerActive = onCareerActive

return M

2. Save/Load Pattern

The standard pattern for persisting data within career saves.

local M = {}
M.dependencies = {'career_career', 'career_saveSystem'}

local saveFile = "myData.json"
local myData = {}

-- ================================
-- SAVE (called on every career save)
-- ================================
local function onSaveCurrentSaveSlot(currentSavePath)
  local dirPath = currentSavePath .. "/career/myMod"
  if not FS:directoryExists(dirPath) then
    FS:directoryCreate(dirPath)
  end
  career_saveSystem.jsonWriteFileSafe(dirPath .. "/" .. saveFile, myData, true)
end

-- ================================
-- LOAD (called when career modules activate)
-- ================================
local function loadData()
  if not career_career.isActive() then return end
  local _, currentSavePath = career_saveSystem.getCurrentSaveSlot()
  if not currentSavePath then return end

  local filePath = currentSavePath .. "/career/myMod/" .. saveFile
  myData = jsonReadFile(filePath) or {}
end

local function onCareerModulesActivated(alreadyInLevel)
  loadData()
end

local function onExtensionLoaded()
  loadData()
end

local function onCareerActive(active)
  if not active then
    myData = {}  -- reset on deactivation
  end
end

M.onSaveCurrentSaveSlot = onSaveCurrentSaveSlot
M.onCareerModulesActivated = onCareerModulesActivated
M.onExtensionLoaded = onExtensionLoaded
M.onCareerActive = onCareerActive
return M

Key points:

  • onSaveCurrentSaveSlot receives the save path as parameter - use it, don't reconstruct
  • Always create directories before writing
  • Use jsonWriteFileSafe for crash-safe writes
  • Reset state on career deactivation

3. GE ↔ VE Communication Pattern

Bidirectional communication between Game Engine and Vehicle Engine contexts.

GE → VE (send command to vehicle)

-- Simple command
local veh = be:getPlayerVehicle(0)
if veh then
  veh:queueLuaCommand("electrics.values.horn = 1")
end

-- With parameters (use string.format + serialize for safety)
veh:queueLuaCommand(string.format(
  "partCondition.initConditions(nil, %d, nil, %f)",
  mileage, visualValue
))

VE → GE (callback from vehicle to game)

-- From VE side: call back to GE when done
-- obj:queueGameEngineLua('myGEModule.onResult(42)')

-- From GE side: send VE command that calls back
veh:queueLuaCommand(string.format(
  "partCondition.initConditions() obj:queueGameEngineLua('myMod.onVehicleReady(%d)')",
  veh:getId()
))

Full Round-Trip Pattern

-- GE Module
local M = {}
local pendingCallbacks = {}

local function requestVehicleData(vehId)
  local veh = be:getObjectByID(vehId)
  if not veh then return end
  pendingCallbacks[vehId] = true
  veh:queueLuaCommand(string.format(
    "local fuel = electrics.values.fuel or 0 " ..
    "obj:queueGameEngineLua('myMod.onVehicleData(%d, ' .. fuel .. ')')",
    vehId
  ))
end

local function onVehicleData(vehId, fuel)
  if not pendingCallbacks[vehId] then return end
  pendingCallbacks[vehId] = nil
  log('I', 'myMod', 'Vehicle ' .. vehId .. ' fuel: ' .. fuel)
end

M.requestVehicleData = requestVehicleData
M.onVehicleData = onVehicleData
return M

4. Computer Menu Integration Pattern

Register features in the in-game computer UI.

local M = {}
M.dependencies = {'career_career'}

local function openMyMenu(computerId)
  -- store computer context if needed for back-navigation
  guihooks.trigger('ChangeState', {state = 'my-custom-screen'})
end

local function closeMyMenu()
  career_career.closeAllMenus()
end

-- Hook into computer menu system
local function onComputerAddFunctions(menuData, computerFunctions)
  -- Check if this computer has our function enabled
  if not menuData.computerFacility.functions["myFeature"] then return end

  computerFunctions.general["myFeature"] = {
    id = "myFeature",
    label = "My Feature",
    callback = function()
      openMyMenu(menuData.computerFacility.id)
    end,
    order = 20  -- position in menu
  }
end

M.onComputerAddFunctions = onComputerAddFunctions
M.openMyMenu = openMyMenu
M.closeMyMenu = closeMyMenu
return M

5. Extension Override Pattern

Replace vanilla game modules with modded versions without editing game files.

-- Override Manager: intercepts extensions.load to swap implementations
local M = {}
local overrides = {}
local originalLoad = nil

local function setOverride(originalPath, overridePath)
  local convertedPath = originalPath:gsub('/', '_')

  overrides[originalPath] = { override = overridePath, converted = convertedPath }
  overrides[convertedPath] = overrides[originalPath]

  -- Intercept require() for this module
  package.preload[convertedPath] = function(...)
    local success, result = pcall(require, overridePath)
    if success then return result end
    return nil
  end
end

local function installOverrides()
  originalLoad = extensions.load
  extensions.load = function(...)
    local args = {...}
    local modified = {}
    for _, arg in ipairs(args) do
      if type(arg) == 'string' and overrides[arg] then
        table.insert(modified, overrides[arg].override)
      else
        table.insert(modified, arg)
      end
    end
    return originalLoad(unpack(modified))
  end
end

local function uninstallOverrides()
  if originalLoad then
    extensions.load = originalLoad
    originalLoad = nil
  end
  for path, _ in pairs(overrides) do
    package.preload[path] = nil
  end
  overrides = {}
end

M.setOverride = setOverride
M.installOverrides = installOverrides
M.uninstallOverrides = uninstallOverrides
return M

Key points:

  • Store original function before overriding
  • Always provide uninstall/cleanup path
  • Guard career module overrides behind career_career.isActive()

6. UI Data Flow Pattern

Send structured data from GE to UI screens and handle responses.

local M = {}

-- ================================
-- GE → UI: Push data to screen
-- ================================
local function requestScreenData()
  -- UI calls this via bngApi.engineLua('myMod.requestScreenData()')
  local data = {
    items = getItemList(),
    balance = career_modules_playerAttributes.getAttributeValue("money"),
    canAfford = checkAffordability()
  }
  guihooks.trigger("myScreenData", data)
end

-- ================================
-- UI → GE: Handle user actions
-- ================================
local function onPurchase(itemId)
  -- UI calls: bngApi.engineLua('myMod.onPurchase("item123")')
  if not career_career or not career_career.isActive() then return end

  local price = { money = { amount = getPrice(itemId), canBeNegative = false } }
  local success = career_modules_payment.pay(price, { label = "Bought " .. itemId })

  if success then
    addToInventory(itemId)
    career_saveSystem.saveCurrent()
  end

  -- Push updated data back to UI
  requestScreenData()
end

local function onCancel()
  guihooks.trigger('ChangeState', {state = 'play'})
end

M.requestScreenData = requestScreenData
M.onPurchase = onPurchase
M.onCancel = onCancel
return M

Data flow:

  1. UI calls bngApi.engineLua('myMod.requestScreenData()') on mount
  2. GE assembles data → guihooks.trigger("myScreenData", data)
  3. UI renders data, user clicks "Buy"
  4. UI calls bngApi.engineLua('myMod.onPurchase("item123")')
  5. GE processes, saves, pushes updated data back

7. Mod Lifecycle Pattern

Complete mod activation/deactivation with proper cleanup.

local M = {}
local modName = "my_awesome_mod"
local modId = "MYMOD01"

local function startup()
  -- Set persistent extensions
  setExtensionUnloadMode("mymod_feature1", "manual")
  setExtensionUnloadMode("mymod_feature2", "manual")
  extensions.load("mymod_feature1")
  extensions.load("mymod_feature2")

  -- Unload vanilla extensions we're replacing
  extensions.unload("vanilla_feature")
end

local function cleanup()
  extensions.unload("mymod_feature1")
  extensions.unload("mymod_feature2")

  -- Restore vanilla behavior
  extensions.load("vanilla_feature")
  reloadUI()
end

local function onExtensionLoaded()
  startup()
end

local function onModDeactivated(modData)
  if not modData or not modData.modname then return end
  -- Handle batch operations
  if modData.modname:find("BatchActivation_") or modData.modname:find("BatchDeactivation_") then
    return
  end
  if (modData.modname == modName) or
     (modData.modData and modData.modData.tagid == modId) then
    cleanup()
  end
end

M.onExtensionLoaded = onExtensionLoaded
M.onModDeactivated = onModDeactivated
return M

Key points:

  • Filter out batch activation/deactivation events
  • Match on both mod name and tag ID (either may be present)
  • Restore vanilla state on deactivation

8. Custom Settings Pattern

Persistent settings stored outside career saves (per-mod config).

local M = {}
local settingsRoot = 'settings/myMod/'
local settingsFile = settingsRoot .. 'config.json'

-- Defaults - keys must exist here to be loaded from disk
local settings = {
  featureEnabled = true,
  difficulty = "normal",
  volume = 1.0
}

local function saveSettings()
  if not FS:directoryExists(settingsRoot) then
    FS:directoryCreate(settingsRoot)
  end
  jsonWriteFile(settingsFile, settings, true)
end

local function getSetting(key)
  return settings[key]
end

local function setSetting(key, value)
  if settings[key] == nil then return end  -- reject unknown keys
  if settings[key] ~= value then
    settings[key] = value
    saveSettings()
  end
end

local function loadSettings()
  local data = jsonReadFile(settingsFile)
  if data then
    for k, v in pairs(data) do
      if settings[k] ~= nil then  -- only load known keys
        settings[k] = v
      end
    end
  else
    saveSettings()  -- write defaults on first run
  end
end

local function onExtensionLoaded()
  loadSettings()
end

M.getSetting = getSetting
M.setSetting = setSetting
M.onExtensionLoaded = onExtensionLoaded
return M

Key points:

  • Settings stored in settings/ (survives career resets)
  • Only load keys that exist in defaults (forward compatibility)
  • Write defaults on first run so users can edit the file

9. Screen Fade Transition Pattern

Smooth transitions with fade-to-black for teleportation, time changes, etc.

local M = {}
local transitionActive = false
local transitionData = nil

local function startTransition(data)
  transitionActive = true
  transitionData = data
  ui_fadeScreen.start(0.5)  -- fade to black
end

local function onScreenFadeState(state)
  if not transitionActive then return end

  if state == 2 then  -- fully black
    -- Do the "hidden" work
    if transitionData.type == "teleport" then
      career_modules_playerDriving.showPosition(transitionData.pos)
    elseif transitionData.type == "timeChange" then
      scenetree.tod.time = transitionData.time
    end
    ui_fadeScreen.stop(0.5)  -- fade back in

  elseif state == 3 then  -- fully visible
    transitionActive = false
    transitionData = nil
  end
end

M.startTransition = startTransition
M.onScreenFadeState = onScreenFadeState
return M

10. Hook Chain Pattern

Multiple modules reacting to the same hook in coordination.

-- Module A: fires a custom hook after its work
local function activateFeature()
  doSetup()
  extensions.hook("onMyFeatureActivated", { id = featureId, data = featureData })
end

-- Module B: reacts to Module A's hook
local function onMyFeatureActivated(info)
  log('I', 'moduleB', 'Feature activated: ' .. info.id)
  updateUI(info.data)
end
M.onMyFeatureActivated = onMyFeatureActivated

-- Module C: also reacts (hooks fan out to all loaded extensions)
local function onMyFeatureActivated(info)
  trackAnalytics("feature_activated", info.id)
end
M.onMyFeatureActivated = onMyFeatureActivated

Hook execution order is determined by extension load order. Use M.dependencies to control ordering when it matters.


11. Vehicle Spawn Callback Pattern

Execute code on newly spawned vehicles with GE↔VE coordination.

local M = {}
local pendingSpawns = {}

local function onVehicleSpawned(_, veh)
  local id = veh:getId()

  -- Load custom VE extensions into every vehicle
  veh:queueLuaCommand("extensions.load('myVeExtension')")

  -- If this was a managed spawn, do additional setup
  if pendingSpawns[id] then
    local config = pendingSpawns[id]
    veh:queueLuaCommand(string.format(
      "partCondition.initConditions(nil, %d, nil, %f) " ..
      "obj:queueGameEngineLua('myMod.onSetupComplete(%d)')",
      config.mileage, config.condition, id
    ))
    pendingSpawns[id] = nil
  end
end

local function onSetupComplete(vehId)
  log('I', 'myMod', 'Vehicle ' .. vehId .. ' setup complete')
  -- Safe to interact with vehicle now
end

M.onVehicleSpawned = onVehicleSpawned
M.onSetupComplete = onSetupComplete
return M

12. World Ready Initialization Pattern

Defer initialization until the world is fully loaded.

local M = {}
local worldReady = false

local function initialize()
  if not career_career or not career_career.isActive() then return end
  -- Heavy init: read facilities, build caches, etc.
  local garages = freeroam_facilities.getFacilitiesByType("garage")
  buildLookupTables(garages)
  worldReady = true
end

local function onWorldReadyState(state)
  if state == 2 then  -- 2 = fully loaded
    initialize()
  end
end

local function onCareerActive(active)
  if not active then
    worldReady = false
  end
end

-- Guard runtime functions
local function doSomething()
  if not worldReady then return end
  -- safe to use cached data
end

M.onWorldReadyState = onWorldReadyState
M.onCareerActive = onCareerActive
M.doSomething = doSomething
return M

Summary: Hook Execution Order

Understanding when each hook fires is critical for correct initialization:

1. onExtensionLoaded       - Extension first loaded (basic setup, no world)
2. onCareerActive(true)    - Career session starting
3. onCareerModulesActivated - All career modules ready (safe to cross-reference)
4. onWorldReadyState(2)    - Level fully loaded (facilities, scenetree available)
5. onVehicleSpawned        - Each vehicle enters the world
6. onPreRender / onUpdate - Per-frame (use sparingly)
7. onSaveCurrentSaveSlot   - Career save triggered
8. onCareerActive(false)   - Career ending
9. onModDeactivated        - Mod being removed

Rule of thumb: Load data in onCareerModulesActivated, build caches in onWorldReadyState(2), save in onSaveCurrentSaveSlot.


See Also

  • Anti-Patterns - Mistakes to avoid
  • Performance - Keeping your mod fast
  • Hook Catalog - All available hooks
  • Saving Data - Data persistence patterns
  • Cross-VM Comms - GE/VE/UI communication

Lua Classes & Metatables

When and how to use Lua classes in BeamNG modding — metatables, constructors, inheritance, with real examples like parcels, checkpoints, and NPC fleets.

Anti-Patterns to Avoid

Common BeamNG modding mistakes that cause crashes, data loss, and silent failures — with correct alternatives for each.

On this page

1. Extension Module Template2. Save/Load Pattern3. GE ↔ VE Communication PatternGE → VE (send command to vehicle)VE → GE (callback from vehicle to game)Full Round-Trip Pattern4. Computer Menu Integration Pattern5. Extension Override Pattern6. UI Data Flow Pattern7. Mod Lifecycle Pattern8. Custom Settings Pattern9. Screen Fade Transition Pattern10. Hook Chain Pattern11. Vehicle Spawn Callback Pattern12. World Ready Initialization PatternSummary: Hook Execution OrderSee Also