UI Framework
Lua ↔ UI Integration - Full Round-Trip Patterns
Complete patterns for bidirectional communication between Lua game engine and Vue UI.
Complete patterns for bidirectional communication between Lua game engine and Vue UI.
Overview
Communication flows in two directions:
┌─────────────────────────────────────────────────────────────┐
│ Lua → UI (Push) │
│ • guihooks.trigger() - One-time events │
│ • guihooks.queueStream() - High-frequency data │
│ • guihooks.triggerRequest() - Request with callback │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ UI → Lua (Call) │
│ • lua.extension.function() - Typed function call │
│ • runRaw() - Arbitrary Lua execution │
│ • api.engineLua() - Low-level with callback │
└─────────────────────────────────────────────────────────────┘Pattern 1: Event-Based Communication
Lua → UI: Fire Event
-- Lua (GE extension)
local function onSomethingHappened()
guihooks.trigger('MyEventName', {
value = 42,
name = "Example",
items = {"a", "b", "c"}
})
endUI: Listen for Event
// Vue component
import { useEvents } from '@/services/events'
const events = useEvents()
events.on('MyEventName', (data) => {
console.log('Value:', data.value) // 42
console.log('Name:', data.name) // "Example"
console.log('Items:', data.items) // ["a", "b", "c"]
})Complete Example: State Change
-- Lua: career_myFeature.lua
local M = {}
local isActive = false
function M.setActive(active)
isActive = active
guihooks.trigger('MyFeatureStateChanged', {active = isActive})
end
function M.toggle()
M.setActive(not isActive)
end
return M<!-- Vue: MyFeatureWidget.vue -->
<template>
<div :class="{ active: isActive }">
{{ isActive ? 'Active' : 'Inactive' }}
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useEvents } from '@/services/events'
const events = useEvents()
const isActive = ref(false)
events.on('MyFeatureStateChanged', (data) => {
isActive.value = data.active
})
</script>Pattern 2: Stream-Based Communication
For high-frequency data (every frame or nearly).
Lua: Send Stream Data
-- Lua: In updateGFX hook (runs every frame)
local function updateGFX(dtReal, dtSim, dtRaw)
-- Queue stream data
guihooks.queueStream('myFeatureData', {
speed = currentSpeed,
position = {x = pos.x, y = pos.y, z = pos.z},
progress = currentProgress
})
-- Flush all queued streams to UI
guihooks.sendStreams()
end
M.updateGFX = updateGFXUI: Subscribe to Stream
<template>
<div class="my-widget">
<span>Speed: {{ speed.toFixed(1) }} m/s</span>
<span>Progress: {{ (progress * 100).toFixed(0) }}%</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useStreams } from '@/services/events'
const speed = ref(0)
const progress = ref(0)
useStreams(['myFeatureData'], (streams) => {
if (streams.myFeatureData) {
speed.value = streams.myFeatureData.speed || 0
progress.value = streams.myFeatureData.progress || 0
}
})
</script>Pattern 3: UI → Lua Function Calls
Define Lua Function Signature
// bridge/LuaFunctionSignatures.js
export default {
// ... existing signatures ...
myMod_myExtension: {
getData: () => {}, // No params
setConfig: (key, value) => [String, Any], // Typed params
processItems: (items) => Array, // Array param
calculate: (a, b) => [Number, Number] // Number params
}
}UI: Call Lua Function
import { lua } from '@/bridge'
// Call and await result
const data = await lua.myMod_myExtension.getData()
// Call with parameters
await lua.myMod_myExtension.setConfig('volume', 0.8)
// Call and use result
const result = await lua.myMod_myExtension.calculate(10, 20)
console.log('Result:', result)Lua: Handle Function Call
-- Lua: myMod/myExtension.lua
local M = {}
function M.getData()
return {
items = {"a", "b", "c"},
count = 3,
active = true
}
end
function M.setConfig(key, value)
settings[key] = value
-- Update state
end
function M.calculate(a, b)
return a + b
end
return MPattern 4: Request-Response (Async)
UI requests data, Lua responds via event.
UI: Request Data
import { onMounted } from 'vue'
import { lua } from '@/bridge'
import { useEvents } from '@/services/events'
const events = useEvents()
const data = ref(null)
onMounted(() => {
// Listen for response
events.on('MyDataResponse', (responseData) => {
data.value = responseData
})
// Request data
lua.myMod_myExtension.requestData()
})Lua: Handle Request, Send Response
-- Lua: myMod/myExtension.lua
local M = {}
function M.requestData()
-- Do async work or gather data
local data = gatherComplexData()
-- Send response via event
guihooks.trigger('MyDataResponse', data)
end
return MPattern 5: UI Opens Lua Menu/State
UI: Navigate via Lua
import { lua } from '@/bridge'
// Open career menu
await lua.career_career.closeAllMenus()
// Open vehicle shopping
await lua.career_modules_vehicleShopping.openShop(null, computerId)
// Start tuning mode
await lua.extensions.gameplay_garageMode.setGarageMenuState('tuning')Lua: Trigger UI Navigation
-- Navigate to UI route
guihooks.trigger('ChangeState', {state = 'career/vehicleInventory'})
-- Open menu module
guihooks.trigger('MenuOpenModule', 'vehicleselect')
-- Navigate back
guihooks.trigger('UINavigation', 'back', 1)
-- Hide menu
guihooks.trigger('MenuHide')Pattern 6: Notifications
Toast Messages
-- Lua: Show toast notification
guihooks.trigger("toastrMsg", {
type = "success", -- "info", "warning", "error", "success"
title = "Saved!",
msg = "Your vehicle has been saved."
})// UI: Programmatic toast (if needed)
import { lua } from '@/bridge'
lua.guihooks.trigger('toastrMsg', JSON.stringify({
type: 'info',
title: 'Notice',
msg: 'Something happened'
}))HUD Messages
-- Lua: Show HUD message
guihooks.trigger('Message', {
category = 'myFeature',
text = 'Mission started!',
icon = 'raceFlag',
ttl = 5 -- seconds
})
-- Clear message
guihooks.trigger('Message', {
category = 'myFeature',
clear = true
})Pattern 7: Confirmation Flow
UI: Show Confirmation, Execute Lua
<script setup>
import { openConfirmation } from '@/services/popup'
import { $translate } from '@/services'
import { lua } from '@/bridge'
async function confirmPurchase() {
const confirmed = await openConfirmation(
$translate.instant('ui.career.confirmPurchase'),
$translate.instant('ui.career.purchaseMessage'),
[
{ label: $translate.instant('ui.common.yes'), value: true },
{ label: $translate.instant('ui.common.no'), value: false }
]
)
if (confirmed) {
await lua.career_modules_vehicleShopping.buyFromPurchaseMenu('vehicle', options)
}
}
</script>Lua: Trigger Confirmation
-- Lua can request UI confirmation
guihooks.trigger('ConfirmDialog', {
title = "Confirm Action",
message = "Are you sure?",
confirmLabel = "Yes",
cancelLabel = "No",
onConfirm = "myExtension.doAction()",
onCancel = "myExtension.cancelAction()"
})Pattern 8: Complex Data Round-Trip
Full Example: Shopping Cart
-- Lua: career_modules_shopping.lua
local M = {}
local cart = {}
function M.getCartData()
return {
items = cart,
total = calculateTotal(),
canCheckout = #cart > 0
}
end
function M.addItem(itemId)
table.insert(cart, itemId)
M.sendCartUpdate()
end
function M.removeItem(itemId)
-- Remove from cart
M.sendCartUpdate()
end
function M.sendCartUpdate()
guihooks.trigger('ShoppingCartUpdated', M.getCartData())
end
function M.checkout()
local success = processPayment()
if success then
cart = {}
guihooks.trigger('CheckoutComplete', {success = true})
else
guihooks.trigger('CheckoutComplete', {success = false, error = "Payment failed"})
end
end
return M<!-- Vue: ShoppingCart.vue -->
<template>
<div class="cart">
<div v-for="item in items" :key="item.id" class="cart-item">
{{ item.name }} - ${{ item.price }}
<BngButton @click="removeItem(item.id)">Remove</BngButton>
</div>
<div class="cart-total">
Total: ${{ total }}
</div>
<BngButton
:disabled="!canCheckout"
@click="checkout"
>
Checkout
</BngButton>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useEvents } from '@/services/events'
import { lua } from '@/bridge'
import { openMessage } from '@/services/popup'
const events = useEvents()
const items = ref([])
const total = ref(0)
const canCheckout = ref(false)
onMounted(async () => {
// Listen for cart updates
events.on('ShoppingCartUpdated', updateCart)
events.on('CheckoutComplete', onCheckoutComplete)
// Get initial cart state
const data = await lua.career_modules_shopping.getCartData()
updateCart(data)
})
function updateCart(data) {
items.value = data.items
total.value = data.total
canCheckout.value = data.canCheckout
}
async function removeItem(itemId) {
await lua.career_modules_shopping.removeItem(itemId)
}
async function checkout() {
await lua.career_modules_shopping.checkout()
}
function onCheckoutComplete(data) {
if (data.success) {
openMessage('Success', 'Purchase complete!')
} else {
openMessage('Error', data.error)
}
}
</script>Best Practices
1. Always Use Events for State Changes
-- ✅ Good: Trigger event when state changes
function M.setActive(active)
M.isActive = active
guihooks.trigger('MyStateChanged', {active = active})
end
-- ❌ Bad: UI might be out of sync
function M.setActive(active)
M.isActive = active
-- UI never knows!
end2. Guard Stream Data
// ✅ Good: Check for undefined
useStreams(['myData'], (streams) => {
if (streams.myData) {
value.value = streams.myData.field || 0
}
})
// ❌ Bad: Crash on undefined
useStreams(['myData'], (streams) => {
value.value = streams.myData.field // Error if undefined!
})3. Cleanup Listeners
// ✅ Good: Use useEvents (auto-cleanup)
const events = useEvents()
events.on('MyEvent', handler)
// ⚠️ Manual: Must clean up yourself
onMounted(() => {
$game.events.on('MyEvent', handler)
})
onUnmounted(() => {
$game.events.off('MyEvent', handler)
})4. Handle Async Properly
// ✅ Good: Await Lua calls
const data = await lua.myExtension.getData()
processData(data)
// ❌ Bad: Data undefined when used
const data = lua.myExtension.getData()
processData(data) // data is a Promise, not the result!5. Throttle High-Frequency Updates
-- Lua: Throttle UI updates
local uiUpdateTimer = 0
function M.updateGFX(dt)
uiUpdateTimer = uiUpdateTimer + dt
if uiUpdateTimer > 0.1 then -- 10 Hz
uiUpdateTimer = 0
guihooks.trigger('MyUpdate', M.getState())
end
endDebug Tips
Log All Events (UI)
// Temporary debug: log all events
const originalEmit = window.vueEventBus.emit
window.vueEventBus.emit = function(name, ...args) {
console.log('Event:', name, args)
return originalEmit.apply(this, arguments)
}Log Stream Data (UI)
useStreams(['electrics'], (streams) => {
console.log('Streams:', JSON.stringify(streams, null, 2))
})Verify Lua Function Exists
// Check if function is callable
if (lua.myExtension?.myFunction) {
await lua.myExtension.myFunction()
} else {
console.error('Function not found in signatures')
}See Also
- Bridge API - Low-level bridge details
- GUIHooks Reference - Lua side documentation
- Services - useEvents, useStreams composables