Skip to content

Build a CReact mod#

Build a mod with a CReact overlay. The example is a counter: the panel calls emit, Luau handles the event and pushes updates with UI.setState. TSX lives under creact/. Compile to panel.bundle.js (menu panel) or popup.bundle.js (in-game overlay) with Bun before you test or ship.

For a real-game panel with Adopt / Not now buttons, see User Interfaces with CReact (Cat Companion mod).

See the CReact guide for primitives and layout. See User Interfaces with CReact and Courage.UI for more patterns and API detail.

Prerequisites#

  • The game source tree (you need pc/ui/ to run the mod panel compiler).
  • Bun. The UI toolchain and mod compiler scripts use Bun, not Node.

Install Bun#

Follow the official guide: Bun installation.

Quick check after install:

bun --version

On macOS and Linux the installer usually puts bun in ~/.bun/bin. Add that directory to your PATH if the command is not found.

CMake can also find Bun when you build the game with -DCR_BUILD_REACT_UI=ON (default).

One-time UI dependencies#

From the repo root:

cd pc/ui
bun install

You only need to repeat this when pc/ui/package.json or the lockfile changes.

Create the mod folder#

Under your mods directory (for example pc/build/mods/ during local dev):

mods/
└── hello-ui/
    ├── mod.json
    ├── main.luau              # Luau entry (or src/main.ts compiled to out/main.luau)
    └── creact/
        └── panel.tsx

The folder name (hello-ui) must match the mod id and the string you pass to UI.show.

Write mod.json#

Luau entry:

{
  "id": "hello-ui",
  "name": "Hello UI",
  "version": "1.0.0",
  "hostVersion": "0.8.0",
  "engineVersion": "1.0.0",
  "type": "luau",
  "entry": "main.luau"
}

TypeScript entry (after ts-luau compile):

{
  "id": "hello-ui",
  "name": "Hello UI",
  "version": "1.0.0",
  "hostVersion": "0.8.0",
  "engineVersion": "1.0.0",
  "type": "luau",
  "entry": "out/main.luau"
}

Write creact/panel.tsx#

The host passes state from Luau and emit back to Luau. Use inline style objects in mod panels.

-- Panel markup is TSX. Luau entry script is in the next section.
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 style={{ gap: 12, flexDirection: "column", padding: 16 }}>
      <text style={{ fontSize: 16, color: "#cbd5e1" }}>
        Count is owned by Luau via UI.setState.
      </text>
      <box
        role="button"
        style={{
          padding: 10,
          backgroundColor: "#2563eb",
          borderRadius: 8,
          alignSelf: "flex-start",
        }}
        onClick={() => emit("increment")}
      >
        <text style={{ color: "#fff", fontSize: 14 }}>
          Clicked {count} times
        </text>
      </box>
    </box>
  );
}

Write the entry script#

Luau owns the counter. Clicks show up on Petrichor.Events as hello-ui:increment. Use main.luau, or compile src/main.ts to out/main.luau (TypeScript scripting guide).

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

local HelloUi = mod("hello-ui")
local MOD_ID = "hello-ui"

function HelloUi:init()
    UI.setState(MOD_ID, { count = 0 })
    Events.on(MOD_ID .. ":increment", function()
        local state = UI.getState(MOD_ID)
        state.count = (state.count or 0) + 1
        UI.setState(MOD_ID, state)
        Log.info(MOD_ID, "count is now " .. tostring(state.count))
    end)
    UI.show(MOD_ID)
end

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

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

declare function mod(name: string): any

const HelloUi = mod("hello-ui")
const MOD_ID = "hello-ui"

HelloUi.init = function () {
    UI.setState(MOD_ID, { count: 0 })
    Events.on(`${MOD_ID}:increment`, () => {
        const state = UI.getState(MOD_ID) as { count?: number }
        const count = (state.count ?? 0) + 1
        UI.setState(MOD_ID, { count })
        Log.info(MOD_ID, `count is now ${count}`)
    })
    UI.show(MOD_ID)
}

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

export = HelloUi

Compile the panel#

The game does not compile TSX at runtime. Build panel.bundle.js before you test or ship.

Compile this mod only:

cd pc/ui
bun run compile-mod-ui:one /path/to/mods/hello-ui

You should get mods/hello-ui/creact/panel.bundle.js next to panel.tsx.

To compile every mod under mods/ that has a creact/ entry:

cd pc/ui
bun run compile-mod-ui

A full game build with CR_BUILD_REACT_UI=ON runs the same compile step automatically for mods in the repo mods/ tree.

Run the game#

  • Confirm creact/panel.bundle.js exists in the mod folder.
  • Enable the mod in the in-game mod list (or place it in the scanned mods/ directory).
  • Start the game. UI.show("hello-ui") opens the overlay when the mod loads.

If the panel is blank, check the log for [mod-ui] messages (missing bundle, wrong modId, or compile error).

Day-to-day workflow#

Step Command / action
Edit layout or handlers Change creact/panel.tsx
Rebuild bundle cd pc/ui && bun run compile-mod-ui:one /path/to/mods/hello-ui
Edit game logic Change main.luau, or src/main.ts then re-run ts-luau
Ship the mod Include panel.bundle.js in the folder you distribute

Ship panel.bundle.js. You can omit panel.tsx from a release build if you only distribute the bundle.

See also#