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.jsonthe 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

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

  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