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 = 04. 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
- Stream names are case-sensitive -
electricsnotElectrics - Streams arrive asynchronously - Always null-check
- Event names match Lua guihooks - Check spelling exactly
- Component must be named
app.vue- Convention required - 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.