Skip to content

CReact guide#

CReact is the in-game UI renderer. You write React components in TSX. At runtime they become GPU primitives (box, text, image, ...) in the QuickJS host.

New to mod panels? Start with Build a CReact mod. See User Interfaces with CReact and Courage.UI for wiring and API reference.

React basics#

CReact uses real React (components, props, hooks, JSX). If you are new to React, start with the official docs:

In mod panels, game logic and saved values belong in Luau, not just React useState. The panel reads Luau data from state and sends actions with emit. See Luau and panel interop.

useState is fine for local UI state (hover, uncommitted tab selection).

How CReact differs from the web#

Web React CReact
DOM elements (div, span, ...) GPU primitives (box, text, ...)
CSS in files or Tailwind Mostly inline style={{ ... }} in mod panels
fetch, window, browser APIs Not available in the game bundle
Layout with Grid Flexbox only

At build time, the CReact JSX shim rewrites common HTML tags to primitives (div becomes box, span and p become text, button becomes box with role="button", img becomes image). Either style works in TSX.

Primitives#

Tag Purpose
box Container, backgrounds, borders, flex layout, buttons (role="button")
text Labels, paragraphs, headings (rewritten from span, p, h1–h6, ...)
image Textures from logical asset keys (asset / src). See Courage.Assets.
nineslice Resizable bordered panels from a 9-slice image
sprite Sub-rectangle of a texture (uv)
textinput Single-line text entry

Children go inside box the same way you would nest div elements.

Image assets in mod panels#

Pass a logical asset key to image (from Courage.Assets or Courage.Layout capture):

local Assets = require("Courage.Assets")
local UI = require("Courage.UI")
UI.setState("my-mod", { banner = Assets("ui/banner.png") })
<image
  asset={(state.banner as string) ?? ""}
  style={{ width: 256, height: 64 }}
/>
  • Static files: put PNG/JPEG/BMP under mods/<id>/assets/ and push Assets("path.png") through UI.setState.
  • Rasterized BLO: Layout.capture("ui/screen.png") registers pixels in memory. Use the returned key in state.

Layout#

CReact lays out with flexbox. Typical panel structure:

-- Layout is declared in TSX (TypeScript tab).
<box style={{ flexDirection: "column", gap: 12, padding: 16 }}>
  <text style={{ fontSize: 18, color: "#fff" }}>Title</text>
  <box style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
    <text>Row content</text>
  </box>
</box>

Common style keys:

Key Notes
flexDirection "column" or "row"
gap, rowGap, columnGap Space between children
alignItems, justifyContent "start", "center", "end", "stretch", "space-between"
flex Grow/shrink on a child
padding, paddingX, paddingY Insets
margin, marginX, marginY Outer spacing
width, height, alignSelf Sizing

CSS Grid (display: grid, grid-column, ...) is not supported. Use flex rows and columns instead.

Styling#

Mod panels (creact/panel.tsx) should use inline style={{ ... }} objects. The mod compiler does not run the full game UI Tailwind pipeline, and .css imports are stripped.

Supported inline examples:

-- Colors and borders are set in TSX inline styles (TypeScript tab).
<box
  style={{
    backgroundColor: "#0f172a",
    borderRadius: 8,
    padding: 12,
    borderWidth: 1,
    borderColor: "#334155",
  }}
>
  <text style={{ fontSize: 14, color: "#cbd5e1", lineHeight: 20 }}>
    Body copy
  </text>
</box>

Colors are hex strings or { r, g, b, a? } objects. The main menu UI (built with Vite + vite-plugin-creact) can also use className and generated style tables, but mod bundles do not include that table today.

Input and events#

Pointer and keyboard handlers attach to primitives the same way as in React:

Handler When it fires
onClick Primary click on the element (bubbles from children)
onPointerDown / onPointerUp Press and release
onPointerEnter / onPointerLeave Hover
onFocus / onBlur Focus changes
onKeyDown / onKeyUp Keyboard

Make clickable areas explicit:

Events.on("my-mod:save", function()
    -- handle save from panel emit("save")
end)
<box
  role="button"
  style={{ padding: 10, backgroundColor: "#caa820", borderRadius: 8, cursor: "pointer" }}
  onClick={() => emit("save")}
>
  <text style={{ color: "#fff" }}>Save</text>
</box>

Clicks on a child text bubble up to a parent box that owns onClick, so you usually only need the handler on the outer box.

Event handler functions are kept in JavaScript. They are never serialized to the C++ engine.

Mod panel props#

When the game loads your panel, the host passes:

Prop Type Role
modId string Mod folder name
state Record<string, unknown> JSON-safe data pushed from Luau via UI.setState
emit (action, data?) => void Sends <modId>:<action> to Petrichor.Events
Events.on("my-mod:ping", function(data)
    print("panel ping", data)
end)
import type { ModUiPanelProps } from "Courage.ModUi";

export default function Panel({ modId, state, emit }: ModUiPanelProps) {
  const label = (state.label as string) ?? modId;
  return (
    <text onClick={() => emit("ping", { from: "panel" })}>{label}</text>
  );
}

Import ModUiPanelProps from Courage.ModUi (provided by the mod UI compiler shims).

What to avoid in mod TSX#

Avoid Why
import.meta.env Not available in the QuickJS runtime
Tailwind / CSS modules Not wired in the mod compiler
Calling fetch or Node APIs Bundle targets the in-game engine
Storing game state only in useState Luau cannot read it. Use emit and UI.setState
CSS Grid Use flex layout

Debugging tips#

  • If the panel is blank, check the log for [mod-ui] lines (missing panel.bundle.js or popup.bundle.js, evalScript failure, unknown modId).
  • If clicks do nothing, confirm the handler is on a box with role="button" (or a bubbling parent), and that Luau registered Events.on("<modId>:<action>", ...).
  • If the counter or labels never change, confirm Luau calls UI.setState after handling the event.
  • Rebuild the bundle after TSX edits: cd pc/ui && bun run compile-mod-ui:one /path/to/mod.

See also#