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

Performance Optimization

How to keep your BeamNG Lua mod fast — avoiding GC pressure, caching lookups, efficient string handling, and proper timer patterns.

BeamNG runs your onUpdate hook 60+ times per second. A single table allocation per frame means 3,600 garbage collections per minute. This guide shows how to write Lua that doesn't tank the game's framerate.


Per-Frame Allocation Avoidance

The #1 performance killer in Lua is garbage collection from per-frame table/string creation.

❌ Bad: Allocating in onUpdate

local function onUpdate(dtReal, dtSim, dtRaw)
  local data = {x=1, y=2, z=3}  -- NEW table every frame = GC pressure
  local msg = "Speed: " .. tostring(speed) .. " m/s"  -- String concat = allocation
  guihooks.trigger('SpeedUpdate', {speed = speed})  -- NEW table every frame
end

✅ Good: Reuse tables, avoid string concat in hot paths

local reuseData = {x=0, y=0, z=0}  -- Allocated ONCE at load
local reuseStream = {speed = 0}

local function onUpdate(dtReal, dtSim, dtRaw)
  reuseData.x, reuseData.y, reuseData.z = getPos()
  reuseStream.speed = speed
  guihooks.queueStream('myModSpeed', reuseStream)  -- Reused table
end

onUpdate vs onPreRender

HookFrequencyUse ForCost
onUpdate~60Hz (frame rate)Game logic, timers, state machinesLow
onPreRender~60Hz (after physics)Visual updates, debug drawingLow
onGuiUpdate~60Hz (during preRender)UI-specific updatesLow

Note: There is no onPhysicsStep hook in GE. Physics-rate callbacks exist only in the VE context.


String Operations

-- ❌ Bad: String concatenation in loops
local result = ""
for _, v in pairs(data) do
  result = result .. v .. ", "  -- Each .. creates a new string
end

-- ✅ Good: Use table.concat
local parts = {}
for _, v in pairs(data) do
  parts[#parts+1] = v
end
local result = table.concat(parts, ", ")

-- ✅ Good: Use string.format for fixed patterns
log('I', 'mymod', string.format("Vehicle %d at (%.1f, %.1f)", vid, x, y))

Table Size Checks

-- ❌ Bad: tableSize() iterates everything
if tableSize(myTable) > 0 then ...

-- ✅ Good: Use next() for emptiness check
if next(myTable) then ...

-- ✅ Good: Use tableIsEmpty() (checks type + next)
if not tableIsEmpty(myTable) then ...

-- ✅ Good: For arrays, use #
if #myArray > 0 then ...

Caching Expensive Lookups

-- ❌ Bad: SceneTree lookup every frame
local function onUpdate(dt)
  local obj = scenetree.findObject("myTrigger")  -- String hash + lookup each frame
  if obj then obj:doSomething() end
end

-- ✅ Good: Cache on level load
local myTrigger = nil
local function onClientPostStartMission()
  myTrigger = scenetree.findObject("myTrigger")
end

local function onUpdate(dt)
  if myTrigger then myTrigger:doSomething() end
end

Avoid deepcopy in Hot Paths

-- ❌ Bad: deepcopy per frame
local function onUpdate(dt)
  local snapshot = deepcopy(gameState)  -- Recursive copy = slow
end

-- ✅ Good: Copy only what changed
local function onUpdate(dt)
  cachedSpeed = gameState.speed  -- Copy value, not table
end

Timer Patterns

-- ❌ Bad: Frame counting (framerate-dependent)
local frames = 0
local function onUpdate()
  frames = frames + 1
  if frames % 60 == 0 then doThing() end  -- Not reliable!
end

-- ✅ Good: Time accumulation
local timer = 0
local function onUpdate(dtReal, dtSim, dtRaw)
  timer = timer + dtReal
  if timer >= 1.0 then  -- Every 1 second
    timer = timer - 1.0
    doThing()
  end
end

Local Variable Upvalues

-- ✅ Good: Localize frequently-used globals
local min = math.min
local max = math.max
local floor = math.floor

local function onUpdate(dt)
  local x = min(a, max(b, c))  -- Faster than math.min / math.max
end

This is standard Lua optimization - local variable access is faster than global table lookups.


Summary

PatternImpactHow
Reuse tablesHighModule-level local tables, clear and refill
Avoid string concatHighstring.format or table.concat
Cache lookupsMediumStore scenetree/map results at level load
Use next() not tableSize()Mediumif next(t) then for emptiness
Localize globalsLow-Mediumlocal min = math.min at top of file
Use throttled timersMediumDon't do expensive work every frame
Time-based timersCorrectnessAccumulate dtReal, not frame count

See Also

  • Anti-Patterns - Common mistakes to avoid
  • Common Patterns - Proven extension patterns
  • Hook Catalog - Understanding per-frame hooks

Error Handling & Defensive Coding

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

Lua Modding FAQ

Quick answers to the most common BeamNG Lua modding questions — extension structure, data persistence, UI communication, timers, and more.

On this page

Per-Frame Allocation Avoidance❌ Bad: Allocating in onUpdate✅ Good: Reuse tables, avoid string concat in hot pathsonUpdate vs onPreRenderString OperationsTable Size ChecksCaching Expensive LookupsAvoid deepcopy in Hot PathsTimer PatternsLocal Variable UpvaluesSummarySee Also