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.
How a page is drawn
Section titled “How a page is drawn”- One page, one browser. Each page created with
WebUI.create, or declared withweb_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 = trueis the default, so whatever the page does not paint shows the game.widthandheightonly choose the CSS pixel size the page lays out in. (WebUI budget) - Layers.
hud(the default) andmenuare open to every resource;modalneeds thewebui.modalpermission. (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.
Manifest and build
Section titled “Manifest and build”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
strictpolicy, a page loads only the files its resource declares (Open77.webui.create). Static Bloom bundles Oxanium, Geist and JetBrains Mono as localwoff2files, and Lucide icons are inline SVG, so Vite copies everything the page needs intodist/. Do not link web fonts or icon sets from a CDN.
The resource template puts this together.
The bridge
Section titled “The bridge”Pages talk to the resource’s client script through window.Open77:
Open77.on('hud:state', (state) => render(state)) // from page:sendOpen77.emit('job:accept', { id: 'courier-7' }) // to page:onconst answer = await Open77.invoke('shop:buy', { item: 'medkit' }) // answered by page:replyOpen77.ready() // the client script's page:on('ui:ready') firesThe 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):
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:
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 }}No browser-drawn popups
Section titled “No browser-drawn popups”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.
Input and focus
Section titled “Input and focus”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 gamepage: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 thewebui.keep_inputpermission, callpage:setConsumedKeys({ "escape" })beforepage:setFocus(true, false, true)to deliver Escape to the page while movement keeps working; check thatsetConsumedKeysexists 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 thecursor: pointeron Static Bloom buttons shows a hand (WebUI cursor). Older clients lacksetCursor; check that it exists first.
Budget
Section titled “Budget”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
hudpage. - 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.
Sharing the CSS between resources
Section titled “Sharing the CSS between resources”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:
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:
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.
Development
Section titled “Development”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:
local page, reason = WebUI.create({ entry = "http://localhost:5173/", layer = "menu" })if not page then print("dev page refused: " .. tostring(reason)) endlocalhost 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).