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
This is the whole example mod, Mods/InventoryList/UI/index.tsx: an overlay that lists what the selected hero carries. The same code ships with the game as _ModExample/ next to your Mods folder, so you can look at the finished thing instead of typing it in.
import { useEffect, useState } from 'react';
import { render, View, Label } from 'onejs-react';
const { CS } = globalThis;
/** The character of the hero the player currently controls, or null before there is one. */
function selectedCharacter(): any {
try {
const party = CS.GrindFest.PartyController.LocalParty;
const hero = party?.SelectedHero;
return hero ? hero.Character : null;
} catch {
return null; // during boot the party is not built yet
}
}
function InventoryPanel({ character }: { character: any }) {
// InventoryController.Items is an ObservableList<ItemBehaviour>: Count + get_Item(i).
const items = character?.Inventory?.Items;
const count = Number(items?.Count) || 0;
let totalWeight = 0;
const rows = [];
for (let i = 0; i < count; i++) {
const item = items.get_Item(i);
if (!item) continue;
const amount = Number(item.Amount) || 1;
const weight = Number(item.Weight) || 0;
totalWeight += weight;
rows.push(
<View key={i} style={{ flexDirection: 'row', justifyContent: 'space-between', paddingTop: 3, paddingBottom: 3 }}>
<Label style={{ fontSize: 12, color: '#cccccc' }}>
{amount > 1 ? `${amount}x ${item.Name}` : String(item.Name)}
</Label>
<Label style={{ fontSize: 11, color: '#7a6a50' }}>{weight.toFixed(1)}</Label>
</View>
);
}
return (
<View style={{ padding: 12, backgroundColor: '#1a1612', borderWidth: 1, borderColor: '#3a3025', minWidth: 220 }}>
<Label style={{ fontSize: 13, color: '#d4af37' }}>{`Inventory (${count})`}</Label>
{count === 0
? <Label style={{ fontSize: 12, color: '#7a6a50', marginTop: 6 }}>Empty</Label>
: <View style={{ marginTop: 6 }}>{rows}</View>}
<Label style={{ fontSize: 11, color: '#7a6a50', marginTop: 6 }}>
{`Weight: ${totalWeight.toFixed(1)} lbs`}
</Label>
</View>
);
}
function InventoryRoot() {
const gameManager = CS.GrindFest.GameManager.Instance;
const [character, setCharacter] = useState<any>(() => selectedCharacter());
// The overlay only makes sense once the world is running and a hero exists. Both are
// polled: Party.SelectedHero is a plain property, not an eventful one, so there is
// nothing to subscribe to for "the player switched hero".
useEffect(() => {
const id = setInterval(() => {
const next = selectedCharacter();
setCharacter((prev: any) => (prev === next ? prev : next));
}, 500);
return () => clearInterval(id);
}, []);
if (!gameManager || !character) return null;
return <InventoryPanel character={character} />;
}
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: useEventSync(instance, 'PropertyName', [deps]) re-renders when a C# property marked [EventfulProperty] changes (that is how IsGameStarted works in Step 2). That is the mechanism to reach for; the polling above is only because Party.SelectedHero is a plain property with no event behind it. If you bind to a C# ObservableList (Inventory.Items is one, and it fires a Changed event), useEventSync(list, 'Changed', [list]) is the event-driven alternative.
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 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