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

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.

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.


Overview

UI Apps are:

  • Small, persistent overlays - visible during gameplay
  • Data-driven - consume streams or events from Lua
  • Self-contained - each app is a single Vue component
  • User-configurable - position, size, visibility via app selector

Creating a UI App

1. Create App Folder Structure

modules/apps/
└── myApp/
    └── app.vue        # Main component (required name)

2. Create the Component

<!-- modules/apps/myApp/app.vue -->
<template>
  <div class="my-app">
    <span class="value">{{ displayValue }}</span>
    <span class="unit">{{ unit }}</span>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useLibStore } from '@/services'

const { $game } = useLibStore()

// State
const rawSpeed = ref(0)
const unit = ref('km/h')

// Computed
const displayValue = computed(() => Math.round(rawSpeed.value * 3.6))

// Lifecycle
onMounted(() => {
  $game.streams.add(['electrics'])
  $game.events.on('onStreamsUpdate', onStreamsUpdate)
})

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

// Stream handler
function onStreamsUpdate(streams) {
  if (!streams.electrics) return
  rawSpeed.value = streams.electrics.wheelspeed || 0
}
</script>

<style scoped lang="scss">
.my-app {
  background: rgba(0, 0, 0, 0.6);
  color: white;
  padding: 0.5em;
  border-radius: 4px;
  font-family: 'Overpass', sans-serif;
  
  .value {
    font-size: 2em;
    font-weight: bold;
  }
  
  .unit {
    font-size: 0.8em;
    opacity: 0.7;
  }
}
</style>

3. Register the App

Add export to modules/apps/index.js:

// modules/apps/index.js
// ... existing exports ...
export { default as myApp } from '@/modules/apps/myApp/app.vue'

Alternative Pattern: Using useStreams/useEvents

The newer pattern uses composables that auto-cleanup:

<script setup>
import { ref } from 'vue'
import { useStreams, useEvents } from '@/services/events'

const speed = ref(0)

// Auto-cleanup on unmount
useStreams(['electrics'], (streams) => {
  if (streams.electrics) {
    speed.value = streams.electrics.wheelspeed || 0
  }
})

// Or for events
const events = useEvents()
events.on('VehicleChange', () => {
  // Handle vehicle change
})
</script>

Real Examples

Compass App (Simple Streams)

<!-- modules/apps/compass/app.vue -->
<template>
  <svg width="100%" height="100%" viewBox="0 0 244 244">
    <g :transform="`rotate(${yawDegrees} 122 122)`">
      <circle r="122" cy="122" cx="122" class="circle" />
      <text x="115" y="34" class="text">N</text>
      <text x="210" y="115" class="text">E</text>
      <text x="129" y="210" class="text">S</text>
      <text x="34" y="122" class="text">W</text>
    </g>
    <path d="M122 90 L105 154 L139 154 Z" class="arrow" />
  </svg>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useLibStore } from '@/services'

const { $game } = useLibStore()
const yawDegrees = ref(0)

onMounted(() => {
  $game.events.on('onStreamsUpdate', onStreamsUpdate)
  $game.streams.add(['sensors'])
})

onUnmounted(() => {
  $game.events.off('onStreamsUpdate', onStreamsUpdate)
  $game.streams.remove(['sensors'])
})

function onStreamsUpdate(streams) {
  if (!streams.sensors) return
  // Convert radians to degrees, offset by 180 for north
  yawDegrees.value = streams.sensors.yaw * 180 / Math.PI + 180
}
</script>

Tacho App (Child Component Pattern)

<!-- modules/apps/tacho2/app.vue -->
<template>
  <div class="tacho-container">
    <tacho ref="tachoRef"></tacho>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useLibStore } from '@/services'
import tacho from './tacho.vue'  // Complex child component

const { $game } = useLibStore()
const tachoRef = ref(null)

onMounted(() => {
  // Wire up unit conversion
  tachoRef.value.wireThroughUnitSystem((val, func) => UiUnits[func](val))
  
  $game.streams.add(['electrics', 'engineInfo'])
  $game.events.on('onStreamsUpdate', onStreamsUpdate)
  $game.events.on('VehicleChange', onVehicleChange)
})

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

function onStreamsUpdate(streams) {
  if (!tachoRef.value) return
  tachoRef.value.update(streams)
}

function onVehicleChange() {
  if (!tachoRef.value) return
  tachoRef.value.vehicleChanged()
}
</script>

Messages App (Event-Based)

<template>
  <div class="messages-app">
    <div v-for="item in messagesList" :key="item._key" class="message-row">
      <BngIcon v-if="item.icon" :type="item.icon" class="msg-icon" />
      <span>{{ translateText(item.text) }}</span>
    </div>
  </div>
</template>

<script setup>
import { reactive, computed, onMounted, onUnmounted } from 'vue'
import { BngIcon } from '@/common/components/base'
import { useEvents } from '@/services/events'
import { $translate } from '@/services'

const events = useEvents()
const messagesByCategory = reactive({})

const messagesList = computed(() => Object.values(messagesByCategory))

function translateText(val) {
  if (!val) return ''
  if (typeof val === 'string') return $translate.instant(val)
  if (val.txt) return $translate.contextTranslate(val)
  return String(val)
}

onMounted(() => {
  events.on('Message', onMessage)
  events.on('ClearAllMessages', onClearAll)
})

function onMessage(args) {
  const category = args?.category || 'default'
  if (args?.clear || !args?.text) {
    delete messagesByCategory[category]
    return
  }
  messagesByCategory[category] = {
    _key: category,
    text: args.text,
    icon: args.icon,
    ttl: args.ttlMs || 5000
  }
}

function onClearAll() {
  for (const k in messagesByCategory) delete messagesByCategory[k]
}
</script>

Lap Times App (Multiple Streams)

<script setup>
import { ref, onMounted } from 'vue'
import { useStreams } from '@/services/events'

const fastData = ref({})
const slowData = ref({})
const staticData = ref({})

onMounted(() => {
  // Subscribe to multiple specialized streams
  useStreams(
    ['lapTimes_fast', 'lapTimes_slow', 'lapTimes_static'],
    (streams) => {
      if (streams.lapTimes_fast) fastData.value = streams.lapTimes_fast
      if (streams.lapTimes_slow) slowData.value = streams.lapTimes_slow
      if (streams.lapTimes_static) staticData.value = streams.lapTimes_static
    }
  )
})

// Fast data: currentTimeFormatted, currentLapTimeFormatted (updated every frame)
// Slow data: currentLap, combinedTimes, status (updated on lap/segment change)
// Static data: totalLaps, totalSegments (set once at race start)
</script>

Common Stream Data

electrics Stream

{
  wheelspeed: 15.5,       // m/s
  rpm: 3500,              // engine RPM
  gear: 3,                // current gear
  fuel: 0.75,             // fuel level (0-1)
  lowbeam: true,          // lights on
  highbeam: false,
  hazard: false,
  signal_L: false,
  signal_R: false,
  ignitionLevel: 2        // 0=off, 1=acc, 2=on
}

sensors Stream

{
  yaw: 1.57,              // radians
  pitch: 0.02,
  roll: 0.01,
  gravity: { x, y, z },
  acceleration: { x, y, z }
}

engineInfo Stream

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

App Registration System

Apps are registered through multiple exports:

// modules/apps/index.js
export { default as tacho2 } from '@/modules/apps/tacho2/app.vue'
export { default as compass } from '@/modules/apps/compass/app.vue'
export { default as navigation } from '@/modules/apps/navigation/app.vue'
export { default as messages } from '@/modules/apps/messages/app.vue'
export { default as tasklist } from '@/modules/apps/tasklist/app.vue'
export { default as lapTimes } from '@/modules/apps/lapTimes/app.vue'
// Add your app here
export { default as myApp } from '@/modules/apps/myApp/app.vue'

The app manager handles spawning:

// modules/apps/appManager.js
export function spawnUiApp(appName, appId, params, apps) {
  const props = params?.props || null
  const appKey = `${appName}${appId}`
  
  apps.push({
    name: appName,
    appId: appId,
    appKey: appKey,
    comp: appName,
    props: props,
    teleport: `#${appName + appId}`
  })
}

export function destroyUiApp(appName, apps) {
  const index = apps.findIndex(x => x.name === appName)
  if (index > -1) apps.splice(index, 1)
}

Best Practices

1. Always Check for Data

function onStreamsUpdate(streams) {
  // Guard against missing streams
  if (!streams.electrics) return
  
  // Guard against missing properties
  const speed = streams.electrics.wheelspeed || 0
}

2. Clean Up Everything

onUnmounted(() => {
  // Remove stream subscriptions
  $game.streams.remove(['electrics', 'sensors'])
  
  // Remove event listeners
  $game.events.off('onStreamsUpdate', onStreamsUpdate)
  $game.events.off('VehicleChange', onVehicleChange)
  
  // Clear timers
  if (timerId) clearInterval(timerId)
})

3. Use Refs for Mutable State

// Good: Reactive ref
const speed = ref(0)

// Bad: Plain variable (won't trigger re-render)
let speed = 0

4. Debounce Expensive Updates

let updateTimer = null

function onStreamsUpdate(streams) {
  // Immediate updates for critical data
  speed.value = streams.electrics?.wheelspeed || 0
  
  // Debounced updates for expensive calculations
  if (updateTimer) clearTimeout(updateTimer)
  updateTimer = setTimeout(() => {
    expensiveCalculation()
  }, 100)
}

5. Use Consistent Styling

.my-app {
  // Use CSS variables for theming
  background: rgba(var(--bng-off-black-rgb), 0.5);
  color: var(--bng-off-white);
  border-radius: var(--bng-corners-1);
  font-family: var(--fnt-defs);
}

Common Gotchas

  1. Stream names are case-sensitive - electrics not Electrics
  2. Streams arrive asynchronously - Always null-check
  3. Event names match Lua guihooks - Check spelling exactly
  4. Component must be named app.vue - Convention required
  5. Export name in index.js becomes app ID - Keep it simple

See Also

  • Bridge API - How Lua communicates with apps
  • Services - useEvents, useStreams composables
  • Components - Reusable Bng* components

ObjectPool main Reference

Module defined in `lua/objectpool/main.lua`. Object pool Lua VM that manages vehicle objects in a shared pool, providing spawn, get, move, set, and delete operations. Acts as the bridge between the Be

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.

On this page

OverviewCreating a UI App1. Create App Folder Structure2. Create the Component3. Register the AppAlternative Pattern: Using useStreams/useEventsReal ExamplesCompass App (Simple Streams)Tacho App (Child Component Pattern)Messages App (Event-Based)Lap Times App (Multiple Streams)Common Stream Dataelectrics Streamsensors StreamengineInfo StreamApp Registration SystemBest Practices1. Always Check for Data2. Clean Up Everything3. Use Refs for Mutable State4. Debounce Expensive Updates5. Use Consistent StylingCommon GotchasSee Also