Skip to content

Courage.UI#

require("Courage.UI") opens and closes CReact UI from mods. A panel (creact/panel.bundle.js) opens in the in-game menu host. A popup (creact/popup.bundle.js) draws on top of gameplay without opening the menu. The game registers this module on the shared Luau VM at startup (before any mod entry file runs).

For CReact primitives and layout, see the CReact guide. Panel layout, TSX source, and the compile step are in User Interfaces with CReact.

Loading the module#

local UI = require("Courage.UI")
import * as UI from "Courage.UI"

Discovery#

At mod load, the engine scans each enabled mod folder for CReact bundles:

On disk API Host behavior
mods/hello-ui/creact/panel.bundle.js UI.show("hello-ui") Opens the menu host and loads the panel
mods/hello-ui/creact/popup.bundle.js UI.showPopup("hello-ui") Overlay on gameplay. Menu stays closed when paused

The mod folder name becomes the id passed to UI.show / UI.showPopup. Panel bundles register with globalThis.__cr_mod_register. Popup bundles use globalThis.__cr_mod_register_popup. The mod UI compiler emits both calls for you.

If a mod has creact/panel.tsx or creact/popup.tsx but no matching bundle, the game logs a warning at scan time. Build bundles before shipping or before testing locally.

Panels vs popups#

Panel Popup
Bundle panel.bundle.js popup.bundle.js
Source panel.tsx / panel.jsx popup.tsx / popup.jsx
Open UI.show(modId) UI.showPopup(modId)
Close UI.hide() UI.hidePopup()
Best for Settings screens, mod menus In-world prompts, HUD-style overlays

UI.setState and UI.getState work the same for both. A mod may ship a panel, a popup, or both under the same mod id.

API#

Function Returns Description
UI.show(modId) (none) Open the menu host and load the panel bundle for modId. No-op with a log line if unknown.
UI.hide() (none) Close the menu panel and push mod-ui/hide to the React host.
UI.showPopup(modId) (none) Show the popup bundle for modId as an in-game overlay. No-op with a log line if unknown.
UI.hidePopup() (none) Close the popup overlay and push mod-ui/popup/hide.
UI.list() { modId, title }[] All panels found under mods/ with a panel.bundle.js. Luau: 1-based array. title comes from the bundle when set, otherwise the mod id. Popups are opened by id via UI.showPopup.
UI.setState(modId, state) (none) Push a Luau table to the open panel as JSON. Replaces the panel's state prop and triggers a re-render.
UI.getState(modId) table Read the last state pushed for modId. Returns {} when unset.
UI.getClipboard() string Returns the clipboard text if Ctrl+V (or Cmd+V on macOS) was pressed this frame, or an empty string.
UI.registerMoreItem(spec) boolean Register an entry in the in-game More menu for a mod. Requires modId, id, and label; optional hint and scope ("both" | "game" | "menu").
UI.unregisterMoreItems(modId) (none) Remove all More menu entries registered by modId.

Luau and panel interop#

Panels receive three props from the host: modId, state, and emit.

Direction Mechanism
Luau -> panel UI.setState(modId, { count = 5 }) updates the state prop
Panel -> Luau emit("increment", { delta = 1 }) fires Petrichor.Events as <modId>:increment
Luau reads panel-driven values Keep authoritative state in Luau, update it inside an Events.on("<modId>:action", fn) listener, then call UI.setState again

Panel clicks run on the UI thread. When the panel dispatches mod-ui/emit, the game forwards the action to Luau immediately and runs Petrichor.Events listeners on the main Luau thread. The overlay also re-queries mod-ui/state after each emit so the panel updates even if a push event is missed.

Panel source is always TSX. Luau drives the panel through Courage.UI and Petrichor.Events.

-- Panel markup is TSX. Import types only in creact/panel.tsx or popup.tsx.
import type { ModUiPanelProps } from "Courage.ModUi"

Usage notes#

  • modId must match the mod directory name and the id registered inside panel.bundle.js or popup.bundle.js.
  • Calling UI.show or UI.showPopup from init() before the menu host exists is safe. The request is queued and replayed once the UI is ready.
  • UI.show / UI.showPopup do not compile TSX. Sources under creact/ are built when you run the game UI build or bun run compile-mod-ui in pc/ui.
  • Only one menu panel is active at a time. A second UI.show replaces the visible panel.
  • Only one popup overlay is active at a time. A second UI.showPopup replaces the visible popup.
  • A popup can show during gameplay. It does not require the menu to be open.

Minimal example#

local Log = require("Petrichor.Log")
local UI = require("Courage.UI")

local HelloUi = mod("hello-ui")

function HelloUi:init()
    Log.info("hello-ui", "opening panel")
    UI.show("hello-ui")
end

function HelloUi:shutdown()
    UI.hide()
end

return HelloUi
import * as Log from "Petrichor.Log"
import * as UI from "Courage.UI"

declare function mod(name: string): any

const HelloUi = mod("hello-ui")

HelloUi.init = function () {
    Log.info("hello-ui", "opening panel")
    UI.show("hello-ui")
}

HelloUi.shutdown = function () {
    UI.hide()
}

export = HelloUi

Listing panels#

local UI = require("Courage.UI")

for _, panel in ipairs(UI.list()) do
    print(panel.modId, panel.title)
end
import * as UI from "Courage.UI"

for (const panel of UI.list()) {
    print(panel.modId, panel.title)
}

Counter with Luau-owned state#

local Events = require("Petrichor.Events")
local UI = require("Courage.UI")

local HelloUi = mod("hello-ui")

function HelloUi:init()
    UI.setState("hello-ui", { count = 0 })
    Events.on("hello-ui:increment", function()
        local state = UI.getState("hello-ui")
        state.count = (state.count or 0) + 1
        UI.setState("hello-ui", state)
    end)
    UI.show("hello-ui")
end

return HelloUi
import * as Events from "Petrichor.Events"
import * as UI from "Courage.UI"

declare function mod(name: string): any

const HelloUi = mod("hello-ui")

HelloUi.init = function () {
    UI.setState("hello-ui", { count: 0 })
    Events.on("hello-ui:increment", () => {
        const state = UI.getState("hello-ui") as { count?: number }
        state.count = (state.count ?? 0) + 1
        UI.setState("hello-ui", state)
    })
    UI.show("hello-ui")
}

export = HelloUi

Panel (creact/panel.tsx)

-- Luau logic is in the tabbed example above. Panel markup is TSX:
import type { ModUiPanelProps } from "Courage.ModUi";

export const title = "Hello from my mod";

export default function Panel({ state, emit }: ModUiPanelProps) {
  const count = (state.count as number) ?? 0;
  return (
    <box role="button" onClick={() => emit("increment")}>
      <text>Clicked {count} times</text>
    </box>
  );
}

Settings panels#

For generator-style option screens, define a schema and push defaults through UI.setState using Courage.Settings (defaults, validate, toUiState).