Skip to content

In-game UIs

OPEN//77 resources draw their interfaces as WebUI pages: HTML, CSS and JavaScript rendered by a Chromium browser (CEF) that ships with the client, composited over the game. Static Bloom’s CSS, Tailwind theme and Vue components run there as they do on the web. This page lists what is different; every platform fact links to the OPEN//77 documentation.

  • One page, one browser. Each page created with WebUI.create, or declared with web_ui_page, is a Chromium browser with its own renderer process. Modern CSS works: cascade layers, color-mix(), oklch(), nesting and :has(), all of which Static Bloom uses.
  • Full viewport, transparent. Every page covers the whole game window, and transparent = true is the default, so whatever the page does not paint shows the game. width and height only choose the CSS pixel size the page lays out in. (WebUI budget)
  • Layers. hud (the default) and menu are open to every resource; modal needs the webui.modal permission. (Resource runtime)

Keep the document itself transparent. The full CSS bundle paints <html> with the canvas color for websites, so override it on in-game pages and put backgrounds on panels instead:

html {
background-color: transparent;
}

The Tailwind setup (@import 'tailwindcss' with @static-bloom/tailwind) leaves the document transparent unless you add a background class.

open77.lua
resource "night_hud"
version "1.0.0"
web_ui_page "dist/index.html"
web_files { "dist/**" }
client_script "client.lua"
  • Relative URLs. A bundled page is served from an isolated virtual HTTPS origin, so build with Vite’s base: './'.
  • Everything bundled. With the default strict policy, a page loads only the files its resource declares (Open77.webui.create). Static Bloom bundles Oxanium, Geist and JetBrains Mono as local woff2 files, and Lucide icons are inline SVG, so Vite copies everything the page needs into dist/. Do not link web fonts or icon sets from a CDN.

The resource template puts this together.

Pages talk to the resource’s client script through window.Open77:

Open77.on('hud:state', (state) => render(state)) // from page:send
Open77.emit('job:accept', { id: 'courier-7' }) // to page:on
const answer = await Open77.invoke('shop:buy', { item: 'medkit' }) // answered by page:reply
Open77.ready() // the client script's page:on('ui:ready') fires

The client creates a page asynchronously, and anything page:send sends before the page is ready is dropped. Call Open77.ready() once the app has mounted, and send the first state from the ui:ready handler (Open77.webui.create):

client.lua
CreateThread(function()
local page = assert(WebUI.default())
page:on("ui:ready", function()
page:send("hud:state", { health = 72, stamina = 45 })
end)
page:on("job:accept", function(payload)
TriggerServerEvent("jobs:accept", payload.id)
end)
end)

The bridge exists only in the game. Declare it as optional, and the same build runs in a desktop browser during development:

src/open77.d.ts
interface Window {
Open77?: {
on(event: string, handler: (payload: unknown) => void): void
emit(event: string, payload?: unknown): void
invoke(event: string, payload?: unknown): Promise<unknown>
ready(): void
}
}

A native <select> opens a popup that does not render reliably in the CEF compositor, and OPEN//77’s own test suite rejects any native select under resources/*/web (UI kit). Static Bloom treats every browser-drawn popup the same way:

Instead of Use
<select> SbSelect, SbCombobox or SbListbox
<input type="date"> SbDatePicker or SbDateField
<input type="time"> SbTimeField
<input type="color"> SbColorPicker
<datalist> SbCombobox or SbAutocomplete
title="…" tooltips SbTooltip, or visible text with an aria-label

The Vue components draw their lists, calendars and tooltips in the page through portals. In the CSS layer, sb-select (a styled native select) and sb-tooltip are for websites only.

A page gets no keyboard or mouse input until the client script gives it focus. page:setFocus takes the keyboard first and the cursor second (WebUI.Page):

page:show()
page:setFocus(true, true) -- open: keyboard and mouse
-- …
page:setFocus(false, false) -- close: hand both back to the game
page:hide()
  • Release focus on every exit path. A page that keeps focus keeps the player from moving.
  • Escape belongs to the platform. It can open the pause menu while your page has focus, and preventDefault() in the page cannot stop that. With the webui.keep_input permission, call page:setConsumedKeys({ "escape" }) before page:setFocus(true, false, true) to deliver Escape to the page while movement keeps working; check that setConsumedKeys exists first (WebUI input). Always give menus a visible close button too.
  • Cursor. While a page owns the mouse, OPEN//77 draws its own cursor. page:setCursor("css") makes it follow the page’s CSS, so the cursor: pointer on Static Bloom buttons shows a hand (WebUI cursor). Older clients lack setCursor; check that it exists first.

Every page is a browser with GPU textures the size of the game window: about 32 MiB at 1920×1080, 56 MiB at 2560×1440 and 127 MiB at 3840×2160, plus the renderer process. A client keeps at most 32 pages alive for all of a server’s resources together, OPEN//77’s own included, and 24 per resource. A hidden page still holds its slot; only destroy() frees it (WebUI budget).

  • Put the HUD’s widgets (stat bars, key hints, toasts, minimap frame) on one transparent hud page.
  • Create rarely used pages when they are first opened, and destroy pages you are done with.
  • Mind image sizes: a page keeps every image decoded at width × height × 4 bytes.

HUD patterns shows how to lay several widgets out on one page.

On clients with the shared asset protocol, a page can load files declared by another running resource through open77://RESOURCE/PATH (Shared WebUI assets). One provider resource can serve the Static Bloom stylesheet to every interface on the server:

shared_ui/open77.lua
resource "shared_ui"
version "1.0.0"
files { "shared/**" }

Copy @static-bloom/css’s dist/static-bloom.css and its dist/fonts/ folder into shared_ui/shared/. Its font URLs are relative, and relative URLs inside a shared file resolve from that file, so the fonts load from the provider too. Consumers declare the dependency and link the file:

night_hud/open77.lua
dependency "shared_ui >=1.0.0"
<link rel="stylesheet" href="open77://shared_ui/shared/static-bloom.css" />

This shares the CSS layer only. Tailwind utilities and the Vue components are compiled into each resource’s own build.

Point a page at Vite’s dev server to get hot reload in the game. Set web_ui_auto_create false in the manifest and create the page yourself:

client.lua
local page, reason = WebUI.create({ entry = "http://localhost:5173/", layer = "menu" })
if not page then print("dev page refused: " .. tostring(reason)) end

localhost is the player’s own PC: other testers need a reachable host. A page reload injects the bridge again, and the app calls Open77.ready() again on load, so the client script can resend its state (remote pages).