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

Vue Modules - Full-Page Screens

Vue Modules are full-page screens like the career menu, garage, vehicle config, and bigmap. They use Vue Router for navigation and can contain complex multi-view layouts.

Vue Modules are full-page screens like the career menu, garage, vehicle config, and bigmap. They use Vue Router for navigation and can contain complex multi-view layouts.


Overview

Vue Modules are:

  • Full-screen views - replace the game view or overlay it
  • Route-based - accessed via Vue Router paths
  • Feature-rich - contain sub-views, state management, complex UI
  • Menu-integrated - shown when entering specific game states

Creating a Vue Module

1. Create Module Folder Structure

modules/
└── myModule/
    ├── routes.js           # Route definitions (required)
    ├── views/
    │   ├── Main.vue        # Main view component
    │   └── Detail.vue      # Sub-view component
    ├── components/
    │   └── MyWidget.vue    # Module-specific components
    └── stores/
        └── myStore.js      # Pinia stores (optional)

2. Define Routes

// modules/myModule/routes.js
import MainView from './views/Main.vue'
import DetailView from './views/Detail.vue'

export default [
  {
    path: '/myModule',
    name: 'myModule',
    component: MainView,
    meta: {
      // Hide HUD apps while in this screen
      uiApps: {
        shown: false
      },
      // Show info bar at bottom
      infoBar: {
        visible: true,
        showSysInfo: false
      },
      // Show top navigation bar
      topBar: {
        visible: true
      }
    }
  },
  {
    path: '/myModule/detail/:id',
    name: 'myModule.detail',
    component: DetailView,
    props: true,  // Pass route params as props
    meta: {
      uiApps: { shown: false }
    }
  }
]

Routes are auto-discovered via glob import in router/index.js:

// router/index.js
routes: [
  ...Object.values(
    import.meta.glob('@/modules/*/routes.js', { eager: true, import: 'default' })
  ).flatMap(routes => routes || [])
]

3. Create View Components

<!-- modules/myModule/views/Main.vue -->
<template>
  <div class="my-module-view">
    <BngScreenHeading>
      <template #title>{{ $t('ui.myModule.title') }}</template>
    </BngScreenHeading>
    
    <div class="content">
      <BngCard v-for="item in items" :key="item.id">
        <h3>{{ item.name }}</h3>
        <BngButton @click="viewDetail(item.id)">
          {{ $t('ui.common.viewDetails') }}
        </BngButton>
      </BngCard>
    </div>
    
    <div class="actions">
      <BngButton @click="goBack" :accent="ACCENTS.secondary">
        {{ $t('ui.common.back') }}
      </BngButton>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useBridge } from '@/bridge'
import { BngScreenHeading, BngCard, BngButton, ACCENTS } from '@/common/components/base'

const router = useRouter()
const { lua } = useBridge()

const items = ref([])

onMounted(async () => {
  // Fetch data from Lua
  const data = await lua.myMod_myModule.getItems()
  items.value = data || []
})

function viewDetail(id) {
  router.push({ name: 'myModule.detail', params: { id } })
}

function goBack() {
  router.back()
}
</script>

<style lang="scss" scoped>
.my-module-view {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: 2em;
  color: white;
  
  .content {
    flex: 1;
    overflow-y: auto;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 1em;
  }
  
  .actions {
    margin-top: 1em;
    display: flex;
    gap: 0.5em;
  }
}
</style>

Real Examples

Career Routes (Complex Module)

// modules/career/routes.js
import Pause from './views/Pause.vue'
import Computer from './views/ComputerMain.vue'
import VehicleInventory from './views/VehicleInventoryMain.vue'
import Tuning from './views/TuningMain.vue'
import Painting from './views/PaintingMain.vue'

export default [
  // Career Pause Menu
  {
    path: '/menu.careerPause',
    name: 'menu.careerPause',
    component: Pause,
    props: true,
    meta: {
      clickThrough: true,  // Allow clicking through to game
      infoBar: {
        withAngular: true,
        visible: true,
        showSysInfo: true
      },
      uiApps: { shown: false },
      topBar: { visible: true }
    }
  },
  
  // Nested career routes
  {
    path: '/career',
    children: [
      {
        path: 'computer',
        name: 'computer',
        component: Computer,
        meta: { uiApps: { shown: false } }
      },
      {
        path: 'vehicleInventory',
        name: 'vehicleInventory',
        component: VehicleInventory
      },
      {
        path: 'tuning',
        name: 'tuning',
        component: Tuning
      },
      {
        path: 'painting',
        name: 'painting',
        component: Painting
      }
    ]
  }
]

Garage View (Complex Component)

<!-- modules/garage/views/Garage.vue -->
<template>
  <div v-if="loaded.init" class="garage-view">
    <!-- Header with breadcrumb -->
    <div class="garage-row-title">
      <h2>{{ vehcomp ? $t('ui.garage.tabs.' + vehcomp) : vehicle.name }}</h2>
    </div>
    
    <!-- Main menu when no sub-view -->
    <div v-if="!vehcomp" class="garage-menu">
      <GarageButton
        v-for="tab in menuTabs"
        :key="tab.id"
        :icon="tab.icon"
        :active="vehcomp === tab.id"
        @click="menuOpen(tab.id)"
        v-bng-disabled="!loaded.vehicle"
      >
        {{ $t(tab.label) }}
      </GarageButton>
    </div>
    
    <!-- Dynamic sub-view -->
    <component 
      v-if="vehcomp && vehcompview" 
      :is="vehcompview" 
      with-background 
    />
  </div>
</template>

<script setup>
import { ref, reactive, markRaw, onBeforeMount, onUnmounted } from 'vue'
import { useBridge } from '@/bridge'
import { useEvents, useStreams } from '@/services/events'
import { icons } from '@/common/components/base'

// Sub-components for dynamic loading
import Paint from '@/modules/vehicleConfig/components/Paint.vue'
import Parts from '@/modules/vehicleConfig/components/Parts.vue'
import Tuning from '@/modules/vehicleConfig/components/Tuning.vue'

const components = { paint: Paint, parts: Parts, tuning: Tuning }

const { lua, api } = useBridge()
const events = useEvents()

// State
const loaded = reactive({ init: false, vehicle: false })
const vehicle = reactive({ name: 'Unknown' })
const vehcomp = ref('')
const vehcompview = ref(null)

// Menu configuration
const menuTabs = [
  { id: 'parts', icon: icons.engine, label: 'ui.garage.tabs.parts' },
  { id: 'tuning', icon: icons.wrench, label: 'ui.garage.tabs.tune' },
  { id: 'paint', icon: icons.sprayCan, label: 'ui.garage.tabs.paint' }
]

// Stream subscription for vehicle electrics
useStreams(['electrics'], (streams) => {
  if (streams.electrics) {
    // Update vehicle electric state
  }
})

// Menu navigation
async function menuOpen(mode) {
  vehcomp.value = vehcomp.value === mode ? '' : mode
  
  if (components[mode]) {
    lua.extensions.gameplay_garageMode.setGarageMenuState(mode)
    vehcompview.value = markRaw(components[mode])
  } else {
    vehcomp.value = ''
    vehcompview.value = null
  }
}

// Vehicle change handler
async function onVehicleChange() {
  loaded.vehicle = false
  const data = await lua.core_vehicles.getCurrentVehicleDetails()
  if (!data) return
  
  loaded.vehicle = true
  vehicle.name = data.model.Brand 
    ? `${data.model.Brand} ${data.model.Name}`
    : data.configs.Name
}

onBeforeMount(async () => {
  events.on('VehicleChange', onVehicleChange)
  api.activeObjectLua('electrics.setIgnitionLevel(1)')
  await onVehicleChange()
  loaded.init = true
})

onUnmounted(() => {
  // events auto-cleanup handled by useEvents()
})
</script>

Route Meta Options

uiApps

meta: {
  uiApps: {
    shown: false,           // Hide all HUD apps
    layout: 'tasklist'      // Use specific layout
  }
}

infoBar

meta: {
  infoBar: {
    visible: true,          // Show bottom info bar
    showSysInfo: true,      // Show FPS, ping, etc.
    withAngular: false,     // Angular compatibility mode
    hints: [                // Action hints
      { action: 'menu', label: 'ui.common.back' }
    ]
  }
}

topBar

meta: {
  topBar: {
    visible: true           // Show top navigation bar
  }
}

Other Options

meta: {
  clickThrough: true        // Allow clicks to pass through to game
}

Navigation Patterns

Using Vue Router

import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

// Navigate to route
router.push({ name: 'myModule.detail', params: { id: '123' } })

// Navigate with query
router.push({ name: 'myModule', query: { filter: 'active' } })

// Go back
router.back()

// Access current route
const currentId = route.params.id

Using bngVue (Legacy/Angular Bridge)

const bngVue = window.bngVue

// Navigate to Angular state
bngVue.gotoAngularState('menu.mainmenu')

// Navigate to Vue state
bngVue.gotoGameState('garagemode')

// Navigate with params
bngVue.gotoGameState('menu.vehicles', { 
  params: { mode: 'garageMode', garage: 'all' } 
})

Navigation Events from Lua

-- Navigate to Vue route
guihooks.trigger('ChangeState', {state = 'menu.careerPause'})

-- Navigate with params
guihooks.trigger('ChangeState', {
  state = 'career/vehicleInventory',
  params = {}
})

State Management

Using Pinia Stores

// stores/myStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useMyStore = defineStore('myStore', () => {
  // State
  const items = ref([])
  const loading = ref(false)
  
  // Getters
  const itemCount = computed(() => items.value.length)
  
  // Actions
  async function fetchItems() {
    loading.value = true
    try {
      const { lua } = useBridge()
      items.value = await lua.myExtension.getItems()
    } finally {
      loading.value = false
    }
  }
  
  return { items, loading, itemCount, fetchItems }
})

Using Stores in Components

<script setup>
import { storeToRefs } from 'pinia'
import { useMyStore } from '../stores/myStore'

const store = useMyStore()
const { items, loading } = storeToRefs(store)

// Call actions
store.fetchItems()
</script>

Scoped Navigation (Controller Support)

For gamepad/controller navigation:

<template>
  <div 
    class="my-view"
    v-bng-scoped-nav="{ 
      activateOnMount: true, 
      bubbleWhitelistEvents: ['menu'] 
    }"
    @deactivate="onExit"
  >
    <BngButton
      :bng-scoped-nav-autofocus="true"
      @click="doSomething"
    >
      First Button (auto-focused)
    </BngButton>
    
    <BngButton @click="doSomethingElse">
      Second Button
    </BngButton>
  </div>
</template>

<script setup>
import { vBngScopedNav, vBngOnUiNav } from '@/common/directives'

function onExit() {
  // Handle scope deactivation (e.g., back button)
}
</script>

Common Patterns

Loading State

<template>
  <div v-if="!loaded" class="loading">
    <BngSpinner />
    <span>{{ $t('ui.common.loading') }}</span>
  </div>
  <div v-else class="content">
    <!-- Content here -->
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const loaded = ref(false)

onMounted(async () => {
  await fetchData()
  loaded.value = true
})
</script>

Error Handling

<script setup>
import { ref } from 'vue'
import { openMessage } from '@/services/popup'

const error = ref(null)

async function saveData() {
  try {
    await lua.myExtension.save(data.value)
  } catch (e) {
    error.value = e.message
    await openMessage(
      $translate.instant('ui.common.error'),
      e.message
    )
  }
}
</script>

Confirmation Dialogs

<script setup>
import { openConfirmation } from '@/services/popup'
import { $translate } from '@/services'

async function deleteItem() {
  const confirmed = await openConfirmation(
    $translate.instant('ui.common.confirm'),
    $translate.instant('ui.myModule.deleteConfirm'),
    [
      { label: $translate.instant('ui.common.yes'), value: true },
      { label: $translate.instant('ui.common.no'), value: false }
    ]
  )
  
  if (confirmed) {
    await lua.myExtension.deleteItem(itemId)
  }
}
</script>

Best Practices

  1. Lazy load heavy components - Use markRaw() for dynamic imports
  2. Prefer composition API - Use <script setup> syntax
  3. Use stores for shared state - Avoid prop drilling
  4. Handle loading states - Always show feedback
  5. Clean up on unmount - Remove listeners, cancel requests
  6. Use translation keys - Never hardcode strings
  7. Test controller navigation - Ensure gamepad support works

See Also

  • UI Apps - HUD widgets
  • Services - Popup, translation, navigation
  • Components - Reusable Bng* components

Services - Shared Utilities

Services provide shared functionality across the UI: event handling, popups, translations, storage, and navigation.

BeamNG ImGui API Reference

All functions via `local im = ui_imgui`. Types: `BoolPtr`, `IntPtr`, `FloatPtr` are `{[0]=value}` tables. `ArrayChar` is a string buffer. `ImVec2(x,y)` and `ImVec4(x,y,z,w)` are engine structs.

On this page

OverviewCreating a Vue Module1. Create Module Folder Structure2. Define Routes3. Create View ComponentsReal ExamplesCareer Routes (Complex Module)Garage View (Complex Component)Route Meta OptionsuiAppsinfoBartopBarOther OptionsNavigation PatternsUsing Vue RouterUsing bngVue (Legacy/Angular Bridge)Navigation Events from LuaState ManagementUsing Pinia StoresUsing Stores in ComponentsScoped Navigation (Controller Support)Common PatternsLoading StateError HandlingConfirmation DialogsBest PracticesSee Also