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