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#
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.
Usage notes#
modIdmust match the mod directory name and the id registered insidepanel.bundle.jsorpopup.bundle.js.- Calling
UI.showorUI.showPopupfrominit()before the menu host exists is safe. The request is queued and replayed once the UI is ready. UI.show/UI.showPopupdo not compile TSX. Sources undercreact/are built when you run the game UI build orbun run compile-mod-uiinpc/ui.- Only one menu panel is active at a time. A second
UI.showreplaces the visible panel. - Only one popup overlay is active at a time. A second
UI.showPopupreplaces the visible popup. - A popup can show during gameplay. It does not require the menu to be open.
Minimal example#
Listing panels#
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)
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).