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!
- Copy
_ModTemplateintoMods/ - Rename the copied folder to
InventoryList - Open
InventoryList/mod.jsonand set the name:
{
"Name": "InventoryList",
"Description": "Shows your inventory as a real-time overlay.",
"Author": "YourName",
"Version": "1.0.0",
"Tags": ["ui", "inventory"]
}
- Open a terminal in
InventoryList/UI/and run:
npm install
- 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
- Equipment section — Show equipped items above inventory (via
character.Equipment) - Sort by rarity — Sort items by rarity tier before displaying
- Search filter — Text input to filter items by name