Building a UI Mod
In this tutorial you'll build a small overlay panel that reacts to the running game. UI mods are written in TypeScript/TSX and rendered with React against Unity UI Toolkit — there is no browser, no DOM and no HTML.
Prerequisites: Node.js (v20+) and Your First Mod.
Step 1: Set Up the Project
The game deploys the scaffolding next to your Mods folder:
%LOCALAPPDATA%Low/GrindFest/GrindFest/
├── Mods/ <- the game loads mods from here
├── typings/ <- global.d.ts (runtime globals) + app.d.ts (the game's C# API)
├── _ModTemplate/ <- copy this to start a new mod
└── _ModExample/ <- the finished example from this tutorial: read it, or copy it to run it
- Copy
_ModTemplateintoMods/and rename the copy, e.g.InventoryList. - Edit
InventoryList/mod.json— the manifest is required, a folder without it is not a mod:
{
"Name": "InventoryList",
"Description": "Shows your inventory as an overlay.",
"Author": "YourName",
"Version": "1.0.0",
"Tags": ["ui"]
}
- In
InventoryList/UI/install and build:
npm install
npm run build # -> UI/dist/index.js, which is what the game loads
- Start the game. Your mod loads after the game's own UI and you get a "Hello from My Mod!" panel with a button. Everything from here on is editing
UI/index.tsx.
Tip:
npm run watchrebuilds on every save, and the game hot-reloads the running UI whenUI/dist/index.jschanges — no restart, no recompile of the game. That includes a released build: the watcher and the loader are runtime code, not editor-only.
One exception, worth knowing from the start: the game discovers your mod folder at boot. Adding or renaming a mod folder while the game is running does not show up until you restart it; editing files inside a mod you already have does hot-reload.
Step 2: The Anatomy of a UI Mod
import { useState } from 'react';
import { render, View, Label, Button, useEventSync } from 'onejs-react';
const { CS } = globalThis;
const GrindFest = (CS as any).GrindFest; // the game's own C# classes (see "C# access" below)
function MyModPanel() {
const [clicks, setClicks] = useState(0);
// Read a C# property reactively. The third argument is the dependency list.
const gameManager = GrindFest.GameManager.Instance;
const isGameStarted = useEventSync(gameManager, 'IsGameStarted', [gameManager]);
if (!isGameStarted) return null; // nothing to show before the world is running
return (
<View style={{ padding: 12, backgroundColor: '#1a1612', borderWidth: 1, borderColor: '#3a3025' }}>
<Label style={{ fontSize: 14, color: '#d4af37' }}>Hello from My Mod!</Label>
<Button text={`Clicked ${clicks}x`} onClick={() => setClicks(clicks + 1)} />
</View>
);
}
// Mount: there is no DOM in the game's UI. __root is the UI Document's panel root.
const container = new CS.UnityEngine.UIElements.VisualElement();
container.name = 'MyMod-root';
container.style.position = CS.UnityEngine.UIElements.Position.Absolute;
container.style.left = 20 as any;
container.style.top = 20 as any;
render(<MyModPanel />, container as any);
// Do not attach it synchronously: the game renders its own UI into __root right
// before loading mods, and React's first commit wipes that root - an element added
// too early is thrown away. Attach after the commit, and re-check, because the same
// wipe happens again on every hot reload.
function ensureAttached() {
if (typeof __root === 'undefined' || !__root) return;
if (!__root.Contains(container)) __root.Add(container);
}
ensureAttached();
setTimeout(ensureAttached, 250);
setTimeout(ensureAttached, 1500);
Why the imports look like this
| You might expect | Use instead | Why |
|---|---|---|
document.createElement('div') |
new CS.UnityEngine.UIElements.VisualElement() |
there is no DOM; __root is a VisualElement |
<div style={{...}}> |
<View style={{...}}> |
onejs-react's components map to UI Toolkit elements |
useState(obj) for a C# property |
useEventSync(obj, 'Prop', [obj]) |
reads the C# property and re-renders when it changes |
import { DraggablePanel } from 'grindfest' |
build your own panel | the game's own components run on the game's React instance — see below |
Bundle React into your mod. esbuild.mjs bundles react and onejs-react into dist/index.js, and that is deliberate: a mod's React instance has to stay consistent with the components the mod renders. Mixing two React copies in one tree breaks hooks — which is also why you should not use the game's own components (globalThis.__grindfest.DraggablePanel, …) from your mod.
Styling
Unity UI Toolkit, not CSS:
| CSS | UI Toolkit | Notes |
|---|---|---|
background-color |
backgroundColor |
solid hex only — rgba() does not work |
border: 1px solid #333 |
borderWidth: 1 + borderColor: '#333' |
separate properties |
display: flex |
default | every element is a flex container |
text-align: center |
unityTextAlign: 'MiddleCenter' as any |
UI Toolkit's own values |
Values that are plain numbers (like left, top, width) work at runtime, but unity-types types them strictly — write 20 as any when TypeScript complains.
C# access
CS gives you every C# type, and await works directly on a C# Task<T>. The game's own classes are typed by the declarations the game writes to <persistentDataPath>/typings/, which the template's tsconfig.json already includes:
const hero = CS.GrindFest.PartyController.LocalParty?.SelectedHero;
const character = hero?.Character;
The game refreshes those declarations when they change, so npm run typecheck knows the C# API — including the game's own classes, not just the engine.
Step 3: Show Live Game Data
The part that matters, from the example mod that ships as _ModExample/UI/index.tsx (copy that folder into Mods/ to run it): an overlay that lists what the selected hero carries, and keeps up with it. The shipped file also styles the rows and gives each one its own component — this is the plumbing.
import { render, View, Label, useEventSync } from 'onejs-react';
import { useObservableList } from './hooks/useObservableList'; // a copy of the game's own hook
const { CS } = globalThis;
/** Reads a value off a C# object, or the fallback when the read throws — see "Reading a C# object can throw". */
function readOr<T>(read: () => T, fallback: T): T {
try { return read(); } catch { return fallback; }
}
function InventoryPanel({ party }: { party: any }) {
// WHICH hero the player controls comes from an event: Party.SelectedHero raises OnSelectedHeroChanged, and
// useEventSync subscribes to party.add_OnSelectedHeroChanged and re-reads the property. Nothing polls it.
const hero = useEventSync(party, 'SelectedHero', [party]) as any;
// WHAT the hero carries comes from the list's own event: Inventory.Items is an ObservableList, so the hook
// subscribes to its Changed and hands back a fresh array on every add and remove.
const character = readOr(() => hero?.Character, null);
const items = useObservableList<any>(readOr(() => character?.Inventory?.Items, null));
if (!hero) return null; // no hero selected (before character creation, or after the last one died)
let totalWeight = 0;
for (const item of items) totalWeight += Number(readOr(() => item?.Weight, 0)) || 0;
return (
<View style={{ padding: 12, backgroundColor: '#1a1612', borderWidth: 1, borderColor: '#3a3025', minWidth: 220 }}>
<Label style={{ fontSize: 13, color: '#d4af37' }}>{`Inventory (${items.length})`}</Label>
{items.length === 0
? <Label style={{ fontSize: 12, color: '#7a6a50', marginTop: 6 }}>Empty</Label>
: items.map((item, i) => {
const amount = Number(readOr(() => item?.Amount, 1)) || 1;
const name = String(readOr(() => item?.Name, '') ?? '');
const weight = Number(readOr(() => item?.Weight, 0)) || 0;
return (
<Label key={i} style={{ fontSize: 12, color: '#cccccc', paddingTop: 3 }}>
{`${amount > 1 ? `${amount}x ${name}` : name} ${weight.toFixed(1)}`}
</Label>
);
})}
<Label style={{ fontSize: 11, color: '#7a6a50', marginTop: 6 }}>
{`Weight: ${totalWeight.toFixed(1)} lbs`}
</Label>
</View>
);
}
function InventoryRoot() {
// Nothing here polls. `IsGameStarted` is an [EventfulProperty] on the game manager, so the panel mounts on the
// event the game already raises when the world is running - and by the time it fires, the session's party exists
// (the hero is created before StartGame, which is what sets the flag). `useEventSync` may not be handed null, so
// the game manager is checked after the hook, not before it.
const gameManager = CS.GrindFest.GameManager.Instance;
const started = useEventSync(gameManager, 'IsGameStarted', [gameManager]);
if (!gameManager || !started) return null;
const party = readOr(() => CS.GrindFest.PartyController.LocalParty, null);
if (!party) return null;
return <InventoryPanel party={party} />;
}
Mount it the same way as the panel in Step 2. Reading C# objects is ordinary property access — item.Name, item.Amount, item.Weight — and the values arrive as C# numbers, so wrap them in Number(...) before doing maths on them.
On reactivity. Never read C# state during render and hope to be re-rendered; subscribe to it, and bind to whatever the C# side already raises:
| The C# thing | What to use |
|---|---|
A property with a change event ([EventfulProperty], or a hand-written OnXChanged) |
useEventSync(obj, 'Prop', [obj]) — how IsGameStarted (Step 2) and Party.SelectedHero work |
An ObservableList<T> |
useEventSync(list, 'Changed', [list]), or useObservableList(list) for a plain array back (what this example uses for Inventory.Items) |
A property with no event, or a plain List<T> |
Nothing can tell you it changed. Poll it behind a cheap primitive signature, or ask for the event to be added on the C# side. |
Reading items.Count during render instead (what this example used to do) looks right and never updates: the panel only redrew when some other state change re-rendered it, so taking an item out left the old contents on screen.
There is no poll anywhere in the finished example: the inventory comes from the list's event, the hero from the party's event, and the mount from GameManager.IsGameStarted.
Reading a C# object can throw
A destroyed Unity object does not read as null over the bridge — accessing a property on it throws (MissingReferenceException: ... has been destroyed), and a throw during render unmounts your whole panel: the game keeps running, your overlay is simply gone. It is not theoretical: these rows read item.Name, and the panel can still be holding an item for one frame after the game destroyed it (a drop destroys the item while an event-driven re-render is in flight).
So wrap reads off C# objects and treat a throw as "that object is gone":
function readOr<T>(read: () => T, fallback: T): T {
try { return read(); } catch { return fallback; }
}
const character = readOr(() => hero?.Character, null);
const name = String(readOr(() => item?.Name, '') ?? '');
Party.SelectedHero answers null for a destroyed hero for the very same reason — one uncaught throw used to take the game's whole UI down with it.
Step 4: Build, Run, Iterate
npm run build # one-off
npm run watch # rebuild on every save
npm run typecheck # tsc --noEmit
With watch running, saving index.tsx rebuilds UI/dist/index.js and the game reloads its UI within about a second. If you break something, the game keeps running and the console tells you what happened:
[index.tsx]: ❌ UI mod 'InventoryList' failed: ReferenceError: ...
Troubleshooting
| Problem | Cause / fix |
|---|---|
UI mod 'X' failed: ReferenceError: 'process' is not defined |
React ships as CommonJS and reads process.env.NODE_ENV. Keep define: { "process.env.NODE_ENV": '"production"' } in esbuild.mjs |
Method not found: UnityEngine.Object.Instantiate |
Generic C# overloads do not bind through the JS bridge. Do that work on the C# side, or use a non-generic overload |
| Panel appears, then vanishes | You attached it synchronously — attach after React's commit (setTimeout, see above) |
Panel vanishes when something is picked up or dropped, and the console says MissingReferenceException: ... has been destroyed |
You read a property off a C# object that was destroyed since the render. Wrap the reads (readOr, see "Reading a C# object can throw") |
| Panel shows contents that no longer match the game | You read C# state during render instead of subscribing to it — see "On reactivity" |
| Panel blocks clicks on the game | Give the container no width/height so it hugs your panel, or use a full-screen container with pickingMode = Ignore (children still receive clicks) |
ReferenceError: document is not defined |
There is no DOM. Use __root, CS.* and onejs-react's components |
Invalid hook call |
Two React copies in one tree — do not mix your components with the game's (__grindfest) components |
| Mod is not in the Mods list | Missing mod.json, or the folder was added after the game booted — restart the game |
Exercises
- Rarity colours — colour each item row by its rarity tier (
item.rank). - Filter box — add a
TextFieldthat filters the list by name. - Sort — show the heaviest items first.
Useful Links
- OneJS documentation — the framework behind the game's UI (UI Toolkit + React)
- React — the rendering library mods use