Skip to content

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.

  • Directorystatic_bloom_playground/
    • open77.lua the manifest
    • client.lua creates and wires the page
    • Directorydist/ the vite build output
      • 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.

  1. 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()],
    })
  2. 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>
  3. 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() calls window.Open77?.ready(). window.Open77 only 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.

  4. Build.

    Terminal window
    pnpm --filter playground build
open77.lua
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 true starts the resource with the resource set.
  • web_ui_page is the page the client creates automatically when the resource starts. Add web_ui_auto_create false to create it yourself instead.
  • web_files declares the files the page may load. ** is fine here, where the folders exist.
  • client_script lists Lua entry points one by one. Never glob script entries: a pattern that matches nothing stops the resource from starting.
  • input.actions is the permission RegisterKeyMapping and Open77.input.keyFor need.
client.lua
-- 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 page
local ready = false
local 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) end
end
-- 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 keys
end
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))) end
end
-- /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
end
end)

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 }

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.

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:

open77.lua
resource "static_bloom_playground"
version "0.1.0"
auto_start true
web_ui_page "dist/index.html"
web_ui_auto_create false
web_files { "dist/**" }
client_script "client.lua"
permissions { "input.actions" }
client.lua
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/.

  • base: './' in the Vite config.
  • data-flavor="blackout" data-theme="dark" on <html>, and a transparent document.
  • Open77.ready() after mount; the first page:send from the ui:ready handler.
  • No native <select>, date, time or color inputs and no title tooltips: 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.