Resource template
An OPEN//77 resource is a folder with a manifest (open77.lua), scripts and files. An in-game interface adds a built web app to it. This repository’s playground, a Vite, Vue and Static Bloom mock of in-game screens, keeps its resource files in apps/playground/open77/: open77.lua, client.lua and a README that walks through building and packaging. This page explains each piece; the code blocks are the template’s own files, so you can do the same with your own app.
The resource is called static_bloom_playground. One transparent page draws the HUD and four menus (inventory, phone, job board, character), and the client script opens a menu with F2 to F5.
Layout
Section titled “Layout”Directorystatic_bloom_playground/
- open77.lua the manifest
- client.lua creates and wires the page
Directorydist/ the
vite buildoutput- index.html
Directoryassets/
- index-….js
- index-….css
- …woff2 the bundled Static Bloom fonts
Everything the page loads is inside dist/: Vite copies the Static Bloom fonts next to the CSS, and the icons are compiled into the JavaScript.
Build the page
Section titled “Build the page”-
Build with relative asset URLs. Bundled pages are served from an isolated virtual origin, so absolute
/assets/…URLs would not resolve.vite.config.ts import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'import tailwindcss from '@tailwindcss/vite'// Bundled WebUI pages are served from an isolated virtual origin: every asset URL must be relative.export default defineConfig({base: './',build: { outDir: 'dist' },plugins: [vue(), tailwindcss()],}) -
Start in Blackout and keep the document transparent, so the game shows through.
index.html <!doctype html><html lang="en" data-flavor="blackout" data-theme="dark"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>Static Bloom Playground</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html> -
Mount the app, then tell the client script the page is ready.
src/main.ts import { createApp } from 'vue'import App from './App.vue'import { ready } from './open77'import './style.css'createApp(App).mount('#app')// App registers its Open77.on listeners while it sets up, so Lua's first page:send is never missed.ready()ready()callswindow.Open77?.ready().window.Open77only exists in the game; the playground declares it as optional (In-game UIs has the declaration), and the same build runs in a desktop browser. -
Build.
Terminal window pnpm --filter playground build
The manifest
Section titled “The manifest”resource "static_bloom_playground"version "0.1.0"auto_start true
web_ui_page "dist/index.html"web_files { "dist/**" }client_script "client.lua"
permissions { "input.actions" }auto_start truestarts the resource with the resource set.web_ui_pageis the page the client creates automatically when the resource starts. Addweb_ui_auto_create falseto create it yourself instead.web_filesdeclares the files the page may load.**is fine here, where the folders exist.client_scriptlists Lua entry points one by one. Never glob script entries: a pattern that matches nothing stops the resource from starting.input.actionsis the permissionRegisterKeyMappingandOpen77.input.keyForneed.
The client script
Section titled “The client script”-- Static Bloom playground: one transparent WebUI page draws the HUD and every menu.-- The page is the manifest's web_ui_page; the client creates it when the resource starts.
local MENUS = { { id = "static_bloom_playground_inventory", screen = "inventory", label = "Playground: inventory", key = "F2" }, { id = "static_bloom_playground_phone", screen = "phone", label = "Playground: phone", key = "F3" }, { id = "static_bloom_playground_jobs", screen = "jobs", label = "Playground: job board", key = "F4" }, { id = "static_bloom_playground_character", screen = "character", label = "Playground: character", key = "F5" },}
local pagelocal ready = falselocal hud = { health = 72, stamina = 58, armor = 40, heat = 64, credits = 12480, zone = "Harbor Ward", wanted = 0 }
-- Anything sent before the page called Open77.ready() is dropped.local function send(event, payload) if page and ready then page:send(event, payload) endend
-- The player's own bindings, so the key hints on the page match them.local function bindings() local keys = { hud = "F1" } for _, menu in ipairs(MENUS) do local bound = Open77.input and Open77.input.keyFor and Open77.input.keyFor(menu.id) keys[menu.screen] = bound or menu.key end return keysend
local function sendHudState() send("hud:state", { health = hud.health, stamina = hud.stamina, armor = hud.armor, heat = hud.heat, credits = hud.credits, zone = hud.zone, wanted = hud.wanted, bindings = bindings(), })end
local function openMenu(screen) if not page or not ready then return end -- Keyboard and cursor. Key mappings pause while the page owns input, so the page handles F1-F5. page:setFocus(true, true) send("menu:open", { screen = screen })end
CreateThread(function() page = assert(WebUI.default()) -- Follow the page's CSS cursor (a hand over buttons). Older clients have no setCursor. if page.setCursor then page:setCursor("css") end
page:on("ui:ready", function() ready = true sendHudState() send("hud:toast", { title = "Static Bloom playground", description = "Press F2 to open the inventory.", intent = "info" }) end)
-- The page's close button, F1 and the open screen's own key all end here. page:on("menu:close", function() page:setFocus(false, false) end)
-- Demo only: a real resource sends these to the server, which validates them. page:on("job:accept", function(payload) print(("[static_bloom_playground] contract accepted: %s"):format(tostring(payload.id))) end) page:on("job:abandon", function(payload) print(("[static_bloom_playground] contract abandoned: %s"):format(tostring(payload.id))) end)end)
for _, menu in ipairs(MENUS) do local ok, reason = RegisterKeyMapping(menu.id, menu.label, menu.key, function() openMenu(menu.screen) end) if not ok then print(("[static_bloom_playground] key mapping %s refused: %s"):format(menu.id, tostring(reason))) endend
-- /playground [inventory|phone|jobs|character]RegisterCommand("playground", function(source, args) openMenu(args[1] or "inventory")end, false)
-- Demo vitals: stamina recovers and cyberware heat bleeds off. One batched hud:state per change.CreateThread(function() while true do Wait(1000) local changed = false if hud.stamina < 100 then hud.stamina = math.min(100, hud.stamina + 2) changed = true end if hud.heat > 20 then hud.heat = math.max(20, hud.heat - 1) changed = true end if changed then send("hud:state", { stamina = hud.stamina, heat = hud.heat }) end endend)The page listens with Open77.on('hud:state', …), Open77.on('hud:toast', …), Open77.on('menu:open', …) and Open77.on('menu:close', …), and its close button calls Open77.emit('menu:close', {}). F1 and the open screen’s own key close a menu the same way, and so does Escape when the page receives it (Input and focus). Release focus on every way out of a menu.
| Event | Direction | Payload |
|---|---|---|
ui:ready |
page to Lua | none; fired by Open77.ready() |
hud:state |
Lua to page | any of health, stamina, armor, heat, credits, zone, wanted, objective, bindings |
hud:toast |
Lua to page | { title, description?, intent?, durationMs? } |
menu:open |
Lua to page | { screen = "inventory" }, "phone", "jobs" or "character" |
menu:close |
both | {} |
job:accept, job:abandon |
page to Lua | { id } |
Install it
Section titled “Install it”Put the folder, with its dist/, in your server’s resources directory and enable it in the server’s resource selection. Copy dist/ next to open77.lua rather than linking it. Players download the resource like any other, and auto_start true starts it with the resource set.
Develop with hot reload
Section titled “Develop with hot reload”During development, load the page from Vite’s dev server instead of dist/. Stop the client from creating the manifest page, and create the page yourself:
resource "static_bloom_playground"version "0.1.0"auto_start true
web_ui_page "dist/index.html"web_ui_auto_create falseweb_files { "dist/**" }client_script "client.lua"
permissions { "input.actions" }CreateThread(function() page = assert(WebUI.default()) page = assert(WebUI.create({ entry = "http://localhost:5173/", layer = "menu" }))Run pnpm dev:playground and start the resource. localhost is the player’s own PC, so this works for one developer; testers on other machines need a reachable host. A page reload injects the bridge again and the app calls Open77.ready() again, so the state arrives after every hot reload. Before you ship, remove web_ui_auto_create false, go back to WebUI.default(), and build dist/.
Checklist
Section titled “Checklist”base: './'in the Vite config.data-flavor="blackout" data-theme="dark"on<html>, and a transparent document.Open77.ready()after mount; the firstpage:sendfrom theui:readyhandler.- No native
<select>, date, time or color inputs and notitletooltips: use the Vue components. - Fonts and icons bundled; nothing loaded from a CDN.
- One page for the whole HUD (this template draws its menus on it too); a menu that most players rarely open can be its own page, created on demand.
- Focus released on every exit path, and a visible close button on every menu.