Table of Contents

Building a UI Mod

In this tutorial you'll build an Inventory Overlay — a draggable panel that shows your hero's items in real-time and updates automatically when you pick up or drop items.

Prerequisites: Node.js (v18+) installed, and you've read Your First Mod.

Step 1: Set Up the Project

The game ships a mod template. Find it next to your Mods folder:

%LOCALAPPDATA%Low/GrindFest/GrindFest/
├── Mods/
├── typings/
└── _ModTemplate/     <- copy this!
  1. Copy _ModTemplate into Mods/
  2. Rename the copied folder to InventoryList
  3. Open InventoryList/mod.json and set the name:
{
    "Name": "InventoryList",
    "Description": "Shows your inventory as a real-time overlay.",
    "Author": "YourName",
    "Version": "1.0.0",
    "Tags": ["ui", "inventory"]
}
  1. Open a terminal in InventoryList/UI/ and run:
npm install
  1. Test that it works:
npm run build

Start the game — you should see the template's "My Mod" panel in the top-right corner. Now let's replace it with the inventory overlay.

Step 2: Write the Inventory Overlay

Open InventoryList/UI/index.tsx and replace the template code with:

import { h, render, Fragment } from 'preact';
import { DraggablePanel, useObservableList, useEventfulState } from 'grindfest';

// Solid hex colors — rgba() does NOT work in Unity UI Toolkit!
const TEXT_GOLD  = '#d4af37';
const TEXT_DIM   = '#7a6a50';
const SEPARATOR  = '#1a1612';
const BORDER     = '#3a3025';

function InventoryPanel({ character }: { character: any }) {
    // Reactive: re-renders automatically when C# ObservableList.Changed fires
    const items = useObservableList(character?.Inventory?.Items);
    const totalWeight = items.reduce(
        (sum: number, it: any) => sum + (it.Weight ?? 0), 0
    );

    return (
        <DraggablePanel title={`Inventory (${items.length})`} width={220}>
            {/* Item rows */}
            <div style={{ paddingTop: 6, paddingBottom: 6, paddingLeft: 12, paddingRight: 12 }}>
                {items.length === 0
                    ? <label style={{ fontSize: 13, color: TEXT_DIM,
                          unityTextAlign: 'MiddleCenter' as any }}>
                          Empty
                      </label>
                    : items.map((item: any, i: number) => (
                        <div key={i} style={{
                            flexDirection: 'row' as const,
                            paddingTop: 4, paddingBottom: 4,
                            borderBottomWidth: i < items.length - 1 ? 1 : 0,
                            borderColor: SEPARATOR,
                        }}>
                            <label style={{ fontSize: 13, color: item.ColorHex || '#cccccc', flexShrink: 1 }}>
                                {item.Amount > 1 ? `${item.Amount}x ${item.Name}` : item.Name}
                            </label>
                        </div>
                    ))
                }
            </div>

            {/* Footer */}
            <div style={{
                borderTopWidth: 1, borderColor: BORDER,
                paddingTop: 5, paddingBottom: 5,
                paddingLeft: 12, paddingRight: 12,
            }}>
                <label style={{ fontSize: 11, color: TEXT_DIM }}>
                    {`Weight: ${totalWeight.toFixed(1)} lbs`}
                </label>
            </div>
        </DraggablePanel>
    );
}

// --- Root Component (observes game state) ---
function InventoryRoot() {
    const gameManager = CS.GrindFest.GameManager.Instance;

    // Reactively observe IsGameStarted via [EventfulProperty]
    const [isGameStarted] = useEventfulState(gameManager, 'IsGameStarted');

    // Don't render anything until the game has actually started
    if (!isGameStarted) return null;

    const character = CS.GrindFest.PartyController.LocalParty?.SelectedHero?.Character;
    if (!character) return null;

    return <InventoryPanel character={character} />;
}

// --- Mount mod ---
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.width = '100%';
container.style.height = '100%';
container.ve.pickingMode = 1; // PickingMode.Ignore — let clicks through to game
document.body.appendChild(container);

render(<InventoryRoot />, container);

Step 3: Build and Run

npm run build

Start the game — your inventory panel appears as a stone-themed draggable window that auto-sizes to fit its contents. Pick up items and watch the list update in real-time! Drag the title bar to reposition, or right-click to close.

For development, use watch mode so changes rebuild automatically:

npm run watch

How It Works

DraggablePanel

The game provides a built-in DraggablePanel component (via import { DraggablePanel } from 'grindfest'). It gives you:

  • Stone-themed panel with a title bar and close button
  • Drag by header to reposition anywhere on screen
  • Click to bring to front when multiple panels overlap
  • Right-click to close
<DraggablePanel title="My Panel" width={300} height={200} onClose={handleClose}>
    <label>Panel content here</label>
</DraggablePanel>

Props: title, width, height (optional — omit to auto-size), onClose (optional), initialPosition (optional { left, top }).

Waiting for Game Start

UI mods load when the game boots — before a hero exists. If you try to access PartyController.LocalParty.SelectedHero too early, you'll get a null reference crash.

The solution is useEventfulState — a reactive hook that observes a C# property marked with [EventfulProperty]:

const [isGameStarted] = useEventfulState(gameManager, 'IsGameStarted');
if (!isGameStarted) return null;

When the player clicks "Start Game", GameManager.IsGameStarted flips to true and your component automatically re-renders.

useObservableList

The key line is:

const items = useObservableList(character?.Inventory?.Items);

This hook subscribes to the C# ObservableList and re-renders your component whenever items are added or removed. No polling — it's event-driven.

Accessing C# Properties

Each item is a live C# object:

item.Name        // display name
item.ColorHex    // rarity color ("#ff8800" for Rare, etc.)
item.Weight      // weight in lbs
item.Amount      // stack count (>1 for gold, arrows)

Mounting

Every UI mod creates a full-screen container and renders into it. The critical line is container.ve.pickingMode = 1 — without it, your overlay blocks all mouse clicks on the game.

Styling

GrindFest uses Unity UI Toolkit, not HTML/CSS. Key differences:

CSS UI Toolkit Notes
background-color backgroundColor Use solid hex — no rgba()
border borderWidth + borderColor Separate properties
display: flex Default All elements are flex containers

Troubleshooting

Problem Solution
Panel blocks game clicks Set container.ve.pickingMode = 1
Colors look wrong Use solid hex (#rrggbb), not rgba()
UI doesn't update Use useObservableList, not manual reading
Build fails: "h is not defined" Add import { h, render, Fragment } from 'preact'; at the top of your index.tsx
Panel shows on main menu Wrap in useEventfulState(gameManager, 'IsGameStarted') check
Null reference on startup Character doesn't exist yet — guard with if (!isGameStarted)

Exercises

  1. Equipment section — Show equipped items above inventory (via character.Equipment)
  2. Sort by rarity — Sort items by rarity tier before displaying
  3. Search filter — Text input to filter items by name