Skip to content

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.

local UI = require("Courage.UI")

local panels = UI.list()
UI.show("my-mod")
UI.hide()
UI.showPopup("my-mod")
UI.hidePopup()
import * as UI from "Courage.UI"

const panels = UI.list()
UI.show("my-mod")
UI.hide()
UI.showPopup("my-mod")
UI.hidePopup()

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:

-- Panel markup is TSX. Push data from Luau:
UI.setState("my-mod", { message = "Hello from Luau" })
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.

In-game overlay
Cat Companion

Overlay closed (UI.hide)

Mock Luau / events
UI.getState("cat-companion")
{ "prompt": "Adopt this cat?", "variant": 2 }
Loading...

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):

for _, p in ipairs(UI.list()) do
    print(p.modId, p.title)
end
for (const p of UI.list()) {
    print(p.modId, p.title)
}

creact/panel.tsx#

-- Luau logic is in the tabbed example above. Panel markup is 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, run bun run compile-mod-ui:one /path/to/mods/cat-companion, or build the game with CR_BUILD_REACT_UI=ON.
  • Check that creact/popup.bundle.js (or panel.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#