User Interfaces with CReact#
See the CReact guide.
Mods ship CReact UI as pre-built JavaScript bundles. Panels use panel.bundle.js. Popups use popup.bundle.js. Compile TSX under creact/ when you build the game or package the mod. There is no bundler at runtime.
First time building a panel?
See Build a CReact mod for Bun setup and a counter walkthrough.
New to CReact?
Read the CReact guide for primitives and layout. For general React, see the React docs.
Note: GPU shaders under
ui/shaders/(.msl,.spirv) are for CReact rendering internals, not mod panels. Mod UI compiles to JavaScript for QuickJS.
Layout#
my-mod/
├── mod.json
├── main.luau (or src/main.ts compiled to out/main.luau)
└── creact/
├── panel.tsx menu panel source (optional in a shipped mod)
├── panel.bundle.js required for UI.show
├── popup.tsx in-game overlay source (optional)
└── popup.bundle.js required for UI.showPopup
A mod can ship a panel, a popup, or both. Folder name and mod.json id must match the strings passed to UI.show / UI.showPopup.
Build flow#
When CR_BUILD_REACT_UI=ON (default), the game build runs:
bun run build # main menu UI
bun run compile-mod-ui # compiles mods/*/creact/{panel,popup}.tsx to *.bundle.js
During development, put mods in the game mods/ folder (e.g. pc/build/mods/ next to a local build). Mods with creact/panel.tsx or creact/popup.tsx get compiled when the UI is built. Ship the matching .bundle.js files with the mod.
To compile one mod by hand:
cd pc/ui
bun run compile-mod-ui:one /path/to/my-mod # panel (default)
bun run compile-mod-ui:one /path/to/my-mod my-mod popup # popup only
Courage.UI#
API reference: Courage.UI.
Panel source (creact/panel.tsx)#
Use CReact primitives (box, text, ...) or HTML tags (div, span, button). The mod compiler rewrites HTML tags to primitives.
Every panel gets modId, state, and emit. Luau owns game logic and panel data via UI.setState and UI.getState. emit(action, data?) sends input back to Luau as <modId>:<action> on Petrichor.Events.
A minimal panel reads from state and sends actions with emit:
import type { ModUiPanelProps } from "Courage.ModUi";
export const title = "My Panel";
export default function Panel({ state, emit }: ModUiPanelProps) {
return (
<box style={{ flexDirection: "column", gap: 12 }}>
<text>{(state.message as string) ?? ""}</text>
<box role="button" onClick={() => emit("confirm")}>
<text>Confirm</text>
</box>
</box>
);
}
Use inline style={{ ... }} for now. Tailwind is not wired up in the mod compiler yet.
The compiler writes creact/panel.bundle.js or creact/popup.bundle.js, which register at load time through globalThis.__cr_mod_register or globalThis.__cr_mod_register_popup. You do not export the panel from Luau or TypeScript entry code.
Use UI.show for menu-style panels (settings, mod list entries). Use UI.showPopup for overlays during gameplay (prompts, HUD widgets). See Courage.UI.
To show retail BLO art inside a panel, rasterize with Courage.Layout and pass the captured asset key through state. See Courage.Assets for image files under assets/.
The Cat Companion section below walks through a full mod (mod.json, Luau wiring, and styled panel.tsx) with a live preview.
Cat Companion example#
This mod shows a popup overlay when Link carries a tame cat. The panel asks Adopt or Not now. Luau handles adoption and closes the overlay.
{ "prompt": "Adopt this cat?", "variant": 2 } mod.json#
{
"id": "cat-companion",
"name": "Cat Companion",
"type": "luau",
"entry": "main.luau",
"version": "1.0.0",
"hostVersion": "0.8.0",
"engineVersion": "1.0.0"
}
Entry script (UI wiring)#
When the player carries a tame cat, Luau pushes coat variant into state and opens the panel. Button clicks arrive as cat-companion:adopt and cat-companion:dismiss.
local Events = require("Petrichor.Events")
local UI = require("Courage.UI")
local MOD_ID = "cat-companion"
function CatCompanion:init()
UI.setState(MOD_ID, { prompt = "Adopt this cat?" })
Events.on(MOD_ID .. ":adopt", function()
self:_onAdoptPressed()
end)
Events.on(MOD_ID .. ":dismiss", function()
self:_hideAdoptPrompt("dismiss")
end)
end
function CatCompanion:_showAdoptPrompt(variant)
UI.setState(MOD_ID, {
prompt = "Adopt this cat?",
variant = variant,
})
UI.showPopup(MOD_ID)
end
function CatCompanion:_hideAdoptPrompt(reason)
UI.hidePopup()
end
import * as Events from "Petrichor.Events"
import * as UI from "Courage.UI"
const MOD_ID = "cat-companion"
CatCompanion.init = function () {
UI.setState(MOD_ID, { prompt: "Adopt this cat?" })
Events.on(`${MOD_ID}:adopt`, () => {
this._onAdoptPressed()
})
Events.on(`${MOD_ID}:dismiss`, () => {
this._hideAdoptPrompt("dismiss")
})
}
CatCompanion._showAdoptPrompt = function (variant: number) {
UI.setState(MOD_ID, { prompt: "Adopt this cat?", variant })
UI.showPopup(MOD_ID)
}
CatCompanion._hideAdoptPrompt = function (_reason?: string) {
UI.hidePopup()
}
UI.showPopup opens the in-game overlay and loads creact/popup.bundle.js. If you call it from init() before the UI host is ready, the game queues it and runs it when the menu loads.
List panels (settings screen or debugging):
creact/panel.tsx#
import type { ModUiPanelProps } from "Courage.ModUi";
export const title = "Cat Companion";
const FONT_SANS = "Raleway, sans-serif";
export default function Panel({ state, emit }: ModUiPanelProps) {
const prompt = (state.prompt as string) ?? "Adopt this cat?";
const variant = state.variant as number | undefined;
return (
<box style={{ flexDirection: "column", gap: 16, width: "100%" }}>
<text style={{ fontSize: 16, color: "#f3e6c2", lineHeight: "22px", fontFamily: FONT_SANS }}>
{prompt}
</text>
{variant !== undefined ? (
<text style={{ fontSize: 13, color: "#a88848", fontFamily: FONT_SANS }}>
Coat variant: {variant}
</text>
) : null}
<box style={{ flexDirection: "row", gap: 12, alignItems: "center" }}>
<box
role="button"
style={{
paddingVertical: 10,
paddingHorizontal: 18,
backgroundColor: "#283228",
borderWidth: 1,
borderColor: "#4a8c4a",
borderRadius: 8,
}}
onClick={() => emit("adopt")}
>
<text style={{ fontSize: 14, color: "#e8f5e8", fontFamily: FONT_SANS, fontWeight: 600 }}>
Adopt
</text>
</box>
<box
role="button"
style={{
paddingVertical: 10,
paddingHorizontal: 16,
backgroundColor: "#1e261e",
borderWidth: 1,
borderColor: "#8a7340",
borderRadius: 8,
}}
onClick={() => emit("dismiss")}
>
<text style={{ fontSize: 14, color: "#d4b35a", fontFamily: FONT_SANS, fontWeight: 600 }}>
Not now
</text>
</box>
</box>
</box>
);
}
Build and install#
- Put the mod under
mods/cat-companion/(or your install's mods directory). - From
pc/ui, runbun run compile-mod-ui:one /path/to/mods/cat-companion, or build the game withCR_BUILD_REACT_UI=ON. - Check that
creact/popup.bundle.js(orpanel.bundle.js) sits next to the TSX source. - In-game, pick up a tame cat while adoption is allowed. The popup opens via
UI.showPopup("cat-companion").
The string passed to UI.showPopup must match the mod folder name and the id baked into the bundle.
Patterns#
| Goal | Approach |
|---|---|
| Show a settings screen | Luau calls UI.show, panel reads state for current values |
| In-world prompt during play | Ship popup.bundle.js, Luau calls UI.showPopup |
| Button applies a change | Panel emit("apply"), Luau listener updates game state and calls UI.setState |
| Switch to another panel | Luau listener calls UI.show("other-mod") or push a screen field in state and branch in TSX |
| Close the overlay | Panel emit("close") and Luau calls UI.hidePopup() (or UI.hide() for menu panels), or use the host Close button |
| Show retail menu art | Courage.Layout capture, pass asset key in state, use <image asset={...} /> |
Panel emit uses the same Petrichor.Events bus as Events.fire. Event names are always <modId>:<action>.
See also#
- CReact guide, primitives, layout, styling, events
- Build a CReact mod, counter walkthrough and Bun compile steps
- Courage.UI,
show,showPopup,hide,hidePopup,setState,getState - Courage.Layout, rasterize BLO screens to panel textures
- Courage.Assets, PNG/JPEG assets for
imageprimitives - Working with events, pub/sub between mods