Table of Contents

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
  1. Copy _ModTemplate into Mods/ and rename the copy, e.g. InventoryList.
  2. 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"]
}
  1. In InventoryList/UI/ install and build:
npm install
npm run build          # -> UI/dist/index.js, which is what the game loads
  1. 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 watch rebuilds on every save, and the game hot-reloads the running UI when UI/dist/index.js changes — 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

  1. Rarity colours — colour each item row by its rarity tier (item.rank).
  2. Filter box — add a TextField that filters the list by name.
  3. Sort — show the heaviest items first.
  • OneJS documentation — the framework behind the game's UI (UI Toolkit + React)
  • React — the rendering library mods use