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 M2. 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 MKey points:
onSaveCurrentSaveSlotreceives the save path as parameter - use it, don't reconstruct- Always create directories before writing
- Use
jsonWriteFileSafefor 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 M4. 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 M5. 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 MKey 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 MData flow:
- UI calls
bngApi.engineLua('myMod.requestScreenData()')on mount - GE assembles data →
guihooks.trigger("myScreenData", data) - UI renders data, user clicks "Buy"
- UI calls
bngApi.engineLua('myMod.onPurchase("item123")') - 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 MKey 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 MKey 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 M10. 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 = onMyFeatureActivatedHook 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 M12. 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 MSummary: 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 removedRule 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.