RLS Studios
ProjectsPatreonCommunityDocsAbout
Join Patreon
BeamNG Modding Docs

Guides

Reference

UI

UI Apps - HUD WidgetsBridge/API Layer - Lua ↔ UI CommunicationBeamNG UI Development Cheat SheetCommon Components - Bng* UI ElementsLua ↔ UI Integration - Full Round-Trip PatternsServices - Shared UtilitiesVue Modules - Full-Page Screens

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

UI Framework

Bridge/API Layer - Lua ↔ UI Communication

The bridge layer connects JavaScript UI code to the Lua game engine. It provides event handling, stream subscriptions, and typed Lua function calls.

The bridge layer connects JavaScript UI code to the Lua game engine. It provides event handling, stream subscriptions, and typed Lua function calls.


Overview

The bridge provides:

  • Events - Fire-and-forget messages from Lua to UI
  • Streams - High-frequency data channels (electrics, sensors, etc.)
  • Lua calls - Invoke Lua functions from JavaScript
  • Hooks - Global event dispatch system

Using the Bridge

Import Methods

// Method 1: useBridge composable
import { useBridge } from '@/bridge'
const { lua, api, events, streams } = useBridge()

// Method 2: Direct imports
import { lua } from '@/bridge'

// Method 3: Via useLibStore (in apps)
import { useLibStore } from '@/services'
const { $game } = useLibStore()
// $game.lua, $game.events, $game.streams, $game.api

Events

Events are one-way messages from Lua → UI.

Listening for Events

import { useEvents } from '@/services/events'

// Auto-cleanup on component unmount
const events = useEvents()

events.on('MyEvent', (data) => {
  console.log('Received:', data)
})

events.once('OneTimeEvent', (data) => {
  // Only fires once
})

// Manual cleanup (if needed)
events.off('MyEvent', handler)

Common Events

EventDataDescription
VehicleChange-Player vehicle changed
VehicleFocusChanged{mode}Camera focus changed
ChangeState{state, params}Navigate to UI state
Message{category, text, icon, ttl}Show HUD message
toastrMsg{type, title, msg}Toast notification
MenuHide-Hide menu overlay
ClearTasklist-Clear task list

Emitting Events (UI → UI)

events.emit('MyCustomEvent', { data: 'value' })

Streams

Streams are high-frequency data channels, typically updated every frame.

Using useStreams Composable

import { useStreams } from '@/services/events'

// Subscribe to streams (auto-cleanup on unmount)
useStreams(['electrics', 'sensors'], (streams) => {
  if (streams.electrics) {
    const speed = streams.electrics.wheelspeed
    const rpm = streams.electrics.rpm
  }
  if (streams.sensors) {
    const yaw = streams.sensors.yaw
  }
})

Manual Stream Management

import { useBridge } from '@/bridge'
const { streams, events } = useBridge()

// Subscribe
streams.add(['electrics', 'engineInfo'])

// Listen for updates
events.on('onStreamsUpdate', (data) => {
  console.log('Speed:', data.electrics?.wheelspeed)
})

// Unsubscribe
streams.remove(['electrics', 'engineInfo'])

Common Streams

StreamContentsUpdate Rate
electricsSpeed, RPM, gear, lights, fuelEvery frame
sensorsYaw, pitch, roll, g-forcesEvery frame
engineInfoMax RPM, idle RPM, shift pointsOn vehicle load
damageDataPart damage valuesOn damage
minimapLocation name, distance to targetVaries

Stream Data Examples

// electrics stream
{
  wheelspeed: 15.5,       // m/s
  rpm: 3500,
  gear: 3,
  gearIndex: 4,           // Including neutral/reverse
  fuel: 0.75,
  lowbeam: true,
  highbeam: false,
  signal_L: false,
  signal_R: false,
  hazard: false,
  ignitionLevel: 2,       // 0=off, 1=acc, 2=on
  parkingbrake: 0,
  abs: false,
  esc: false
}

// sensors stream
{
  yaw: 1.57,              // radians
  pitch: 0.02,
  roll: 0.01,
  gravity: { x: 0, y: 0, z: -9.81 },
  gx: 0.1,                // G-forces
  gy: 0.0,
  gz: 1.0
}

// engineInfo stream
{
  maxRPM: 7000,
  idleRPM: 800,
  shiftUpRPM: 6500,
  shiftDownRPM: 2000
}

Calling Lua Functions

Using Typed Signatures

import { lua } from '@/bridge'

// Call extension function (returns Promise)
const result = await lua.extensions.myExtension.myFunction(arg1, arg2)

// Call career module
await lua.career_modules_fuel.requestRefuelingTransactionData()

// Call core function
const details = await lua.core_vehicles.getCurrentVehicleDetails()

// Check extension state
const isActive = await lua.career_career.isActive()

Function Signature Types

Signatures in LuaFunctionSignatures.js define parameter types:

// bridge/LuaFunctionSignatures.js
export default {
  career_career: {
    isActive: () => {},                              // No params
    createOrLoadCareerAndStart: (id, auto, tut) =>  // Typed params
      [String, Any, Boolean]
  },
  
  guihooks: {
    trigger: hookName => String                      // String param
  },
  
  core_vehicles: {
    getCurrentVehicleDetails: () => {}               // No params, returns object
  }
}

Raw Lua Execution

For functions not in signatures:

import { runRaw } from '@/bridge/libs/Lua.js'

// Execute arbitrary Lua code
const result = await runRaw('extensions.myMod.customFunction("arg")')

// Without promise (fire-and-forget)
runRaw('print("Hello from JS")', false)

Using API for Vehicle Lua

import { useBridge } from '@/bridge'
const { api } = useBridge()

// Execute on active vehicle (VE context)
api.activeObjectLua('electrics.setIgnitionLevel(2)')

// Execute on game engine
api.engineLua('print("Hello")', (result) => {
  console.log('Result:', result)
})

Hooks System

The Hooks module handles global event dispatch.

How Hooks Work

  1. Lua triggers hook - guihooks.trigger('EventName', data)
  2. CEF receives - window.multihookUpdate(hooksData)
  3. Hooks dispatches - To both Angular and Vue event buses
  4. Components receive - Via events.on('EventName', handler)

Internal Hook Handler

// bridge/libs/Hooks.js
const mainHookHandler = function(hookName, args) {
  // Dispatch to Vue event bus
  window.vueEventBus?.emit(hookName, ...args)
  
  // Dispatch to Angular (legacy)
  window.globalAngularRootScope?.$broadcast(hookName, ...args)
}

Stream Update Flow

  1. Lua queues stream data - guihooks.queueStream('electrics', data)
  2. Lua flushes - guihooks.sendStreams() (typically in updateGFX)
  3. CEF receives - window.streamUpdate(data)
  4. StreamManager processes - Merges vehicle/global streams
  5. Event emitted - vueEventBus.emit('onStreamsUpdate', data)
  6. Coordinator syncs - Waits for Vue/Angular updates, signals completion

Stream Coordinator

The coordinator ensures UI updates complete before signaling the game engine.

// bridge/coordinator/lite.js

// Before broadcasting streams
coordinator.beforeBroadcast()

// Emit stream update
vueEventBus.emit('onStreamsUpdate', data)

// After broadcast - wait for Vue.nextTick and Angular $timeout
coordinator.afterBroadcast(() => {
  // Signal game engine that UI frame is complete
  window.beamng?.uiFrameCallback?.()
})

Coordinator Features

  • Vue sync - Waits for Vue.nextTick()
  • Angular sync - Waits for $timeout(0)
  • Safety timeout - Forces completion after 2000ms
  • Skip detection - Handles overlapping updates

Game Blurrer

Region-based blur system for UI elements. Each blur region is tracked by ID and synced to Lua.

import { gameBlurrer } from '@/bridge'

// Register a blur region (returns an ID string)
const id = gameBlurrer.register({ x: 0, y: 0, width: 100, height: 100 })

// Update a registered blur region
gameBlurrer.update(id, { x: 10, y: 10, width: 200, height: 200 })

// Unregister a blur region
gameBlurrer.unregister(id)

The blurrer lazily loads/unloads the ui_gameBlur Lua extension as needed. When the region list is empty, it unloads the extension automatically.


UI Units

Handle unit conversions (metric/imperial). The UIUnits class manages settings for length, speed, temperature, weight, torque, power, volume, pressure, etc.

import { useBridge } from '@/bridge'
const { units } = useBridge()

// Unit settings are keyed by category
// e.g. 'uiUnitLength', 'uiUnitTemperature', 'uiUnitWeight', etc.
// Categories: length, speed, temperature, weight, consumptionRate,
//             torque, energy, date, power, volume, pressure

// The units object provides conversion methods per category
// Settings can be 'metric'/'imperial' or specific unit strings like 'bhp', 'psi', 'gal'

Common Patterns

Fetch Data on Mount

import { onMounted, ref } from 'vue'
import { lua } from '@/bridge'

const data = ref(null)

onMounted(async () => {
  data.value = await lua.myExtension.getData()
})

React to Events

import { ref, onMounted, onUnmounted } from 'vue'
import { useBridge } from '@/bridge'

const { events } = useBridge()
const isActive = ref(false)

function onStateChange(data) {
  isActive.value = data.active
}

onMounted(() => {
  events.on('MyStateChanged', onStateChange)
})

onUnmounted(() => {
  events.off('MyStateChanged', onStateChange)
})

Stream + Event Combo

import { ref, onMounted, onUnmounted } from 'vue'
import { useLibStore } from '@/services'

const { $game } = useLibStore()
const speed = ref(0)
const vehicleName = ref('')

onMounted(() => {
  // Stream for continuous data
  $game.streams.add(['electrics'])
  $game.events.on('onStreamsUpdate', onStreams)
  
  // Event for discrete changes
  $game.events.on('VehicleChange', onVehicleChange)
})

onUnmounted(() => {
  $game.streams.remove(['electrics'])
  $game.events.off('onStreamsUpdate', onStreams)
  $game.events.off('VehicleChange', onVehicleChange)
})

function onStreams(streams) {
  speed.value = streams.electrics?.wheelspeed || 0
}

async function onVehicleChange() {
  const details = await $game.lua.core_vehicles.getCurrentVehicleDetails()
  vehicleName.value = details?.configs?.Name || 'Unknown'
}

Lua-Side Internals

How guihooks.trigger Works Internally

From guihooks.lua: arguments are JSON-encoded via jsonEncodeWorkBuffer, then passed to C++ be:queueHookJS() (GE) or obj:queueHookJS() (VE). CEF dispatches to registered JavaScript listeners. The tmpTab in guihooks.lua is reused and cleared after each trigger call.

Targeted Events

-- Send to a specific UI window/DSM target
guihooks.triggerClient(targetDsmId, 'EventName', data)

Raw JS Events

-- Send pre-formatted JSON (avoids encoding overhead)
guihooks.triggerRawJS('EventName', '{"key":"value"}')

How Streams Work Internally

From guihooks.lua: data is cached in a table. All cached streams are sent in one batch via guihooks.sendStreams() (called from main.lua's render loop).

  • VE: Data cached, sent during sendStreams() in the vehicle's graphics step
  • GE: Sets M.updateStreams = true, data cached, sent when main.lua calls guihooks.sendStreams()
-- Reuse data tables for efficiency
local streamData = {speed = 0}
local function onUpdate(dt)
  streamData.speed = getCurrentSpeed()
  guihooks.queueStream('myModSpeed', streamData)
end

Toast Messages (Lua)

-- Simple message popup
guihooks.message("Hello World!", 5, "info", "icon_name")
-- Args: message, ttl (seconds), category, icon

UI Graph App

Built-in graph visualization for debugging:

-- Each arg: {key, value, scale, unit, renderNegatives, color}
guihooks.graph(
  {"RPM", rpm, 8000, "rpm"},
  {"Throttle", throttle, 1, "%"},
  {"Speed", speed, 200, "km/h"}
)

Loading Screen / Fade Screen

-- Fade to black:
ui_fadeScreen.fadeToBlack(fadeTime, screenData, args)
-- screenData = {image, title, text} shown during black

-- Fade from black:
ui_fadeScreen.fadeFromBlack(fadeTime, args)

-- Full cycle (fade in → pause → fade out):
ui_fadeScreen.fadeSequence(fadeIn, pause, fadeOut, screenData, args)

onScreenFadeState hook receives state changes:

  • 1 = Started fading to black
  • 2 = Fully black (safe to do visual work like teleporting, swapping vehicles)
  • 3 = Started fading from black
local function onScreenFadeState(state)
  if state == 2 then
    teleportVehicle()
  end
end
M.onScreenFadeState = onScreenFadeState

Performance Notes

  • guihooks.trigger() JSON-encodes all arguments — avoid large tables
  • guihooks.queueStream() is more efficient for per-frame data (batched)
  • Reuse data tables passed to queueStream

Gotchas

  1. Lua calls return Promises - Always use await or .then()
  2. Stream data may be undefined - Check before accessing properties
  3. Event names are case-sensitive - Match Lua guihooks.trigger exactly
  4. Clean up subscriptions - Memory leaks if you don't
  5. Streams are reference-counted - Multiple subscribers OK, but each must unsubscribe

See Also

  • GUIHooks Reference - Lua side of communication
  • Services - useEvents, useStreams wrappers
  • Lua Integration - Full round-trip patterns

UI Apps - HUD Widgets

UI Apps are in-game HUD widgets like the tachometer, compass, minimap, and lap times. They run as Vue components overlaid on the game view.

BeamNG UI Development Cheat Sheet

40+ high-frequency one-liners and snippets for UI development.

On this page

OverviewUsing the BridgeImport MethodsEventsListening for EventsCommon EventsEmitting Events (UI → UI)StreamsUsing useStreams ComposableManual Stream ManagementCommon StreamsStream Data ExamplesCalling Lua FunctionsUsing Typed SignaturesFunction Signature TypesRaw Lua ExecutionUsing API for Vehicle LuaHooks SystemHow Hooks WorkInternal Hook HandlerStream Update FlowStream CoordinatorCoordinator FeaturesGame BlurrerUI UnitsCommon PatternsFetch Data on MountReact to EventsStream + Event ComboLua-Side InternalsHow guihooks.trigger Works InternallyTargeted EventsRaw JS EventsHow Streams Work InternallyToast Messages (Lua)UI Graph AppLoading Screen / Fade ScreenPerformance NotesGotchasSee Also