Shipping a Companion App: Mod Management, Save Analytics, and OBS Overlays
Fields of Mistria is a farming sim with an active modding community and a lot of streamers. The community had three pain points: mod installation was manual and error-prone, there was no way to see save data analytics without editing save files, and streamers wanted overlays showing game state but nothing existed.
I built a companion app that solves all three. React frontend, Express backend, WebSocket real-time sync, Cloudflare Worker for API security, and OBS-ready HTML overlays.
Mod Management Without Exposing API Keys
The Nexus Mods API requires an API key for downloads. The typical approach is making users generate their own key and paste it into the app. That's bad UX and most users won't do it.
I put a Cloudflare Worker between the app and the Nexus API. The Worker holds the API key server-side, validates incoming requests against an allowlist of safe endpoints, and rate-limits to 30 requests per minute per IP.
// Cloudflare Worker (simplified)
const ALLOWED_ROUTES = [
/^\/v1\/games\/fieldsofmistria\/mods\/\d+\.json$/,
/^\/v1\/games\/fieldsofmistria\/mods\/\d+\/files\/\d+\/download_link\.json$/,
/^\/v2\/graphql$/,
];
async function handleRequest(request, env) {
const url = new URL(request.url);
if (!ALLOWED_ROUTES.some(r => r.test(url.pathname))) {
return new Response("Forbidden", { status: 403 });
}
// Rate limit check via KV...
// Forward with API key from env...
}The allowlist blocks access to user account endpoints entirely. Even if someone reverse-engineers the proxy URL, they can only search mods and download files for Fields of Mistria. They can't access account data, endorsements, or other games.
The mod installation flow: user browses mods in-app (trending, latest, updated tabs via GraphQL search), clicks install, the backend downloads through the Worker, extracts the archive (handles .zip, .rar, .7z, .tar.gz), and activates via MOMI CLI (a third-party mod manager binary).
Conflict Detection
Before installing, the backend reads manifest.json from every installed mod and compares file paths. If mod A and mod B both modify the same file, that's a conflict. The UI shows exactly which files overlap and warns before proceeding.
This is more accurate than category-based conflict detection ("both mods are texture packs"). Two texture packs might not conflict at all if they modify different files. Two seemingly unrelated mods might conflict because they both patch data.win.
The backup system snapshots critical game files (data.win, JSON configs) before any mod installation. One-click restore reverts to the pre-mod state.
Save Data Parsing
Fields of Mistria saves are binary .sav files. I use vaultc (a third-party binary) to decompress them into JSON, then parse the JSON into structured data.
The parser extracts:
- Player info: level, playtime, farm name, inventory
- NPC relationships: 32 NPCs with heart points, daily interaction status (chatted? gifted?), gift preferences from a wiki database
- Skills: 6 farming skills with XP tracking toward next level
- Museum: 80 sets, 447 items across 4 wings with per-wing completion percentages
- Crops and fish: collected catalog, growth requirements, selling prices
- Recipes: unlocked cooking recipes with ingredient lists and profit per recipe
- Profit analysis: revenue by season, expense tracking, net trends over game time
The tricky part was that the actual save format differs from what the community wiki documents. I had to reverse the format from real save files rather than relying on wiki research.
Real-Time Updates
The backend uses chokidar to watch the save file for changes. When the player saves in-game, chokidar detects the file modification (with 500ms debounce to avoid partial-write reads), the parser runs, and the result broadcasts to all connected WebSocket clients.
%%MERMAID_START%%graph LR A[Game saves] --> B[chokidar detects change] B --> C[500ms debounce] C --> D[Parse save file] D --> E[WebSocket broadcast] E --> F[Dashboard updates] E --> G[OBS overlays refresh]%%MERMAID_END%%
The frontend hook (useSaveData.js) establishes a WebSocket connection on mount, auto-reconnects every 3 seconds on disconnect, and updates React state when new data arrives. No polling, sub-second latency from save to UI update.
Streaming Overlays
Five self-contained HTML overlays for OBS browser sources:
- Gifts: Ungifted NPCs for today, upcoming birthdays, gift preferences
- Museum: Completion percentage per wing with progress bars
- NPCs: Relationship grid showing hearts and daily interaction flags
- Checklist: Customizable task list (talk to NPCs, gift items, farm chores)
- Poll: Twitch chat integration (viewers vote by typing numbers)
Each overlay is a single HTML page with embedded CSS and JavaScript. A WebSocket client in the script tag connects to the backend. When save data changes, the overlay receives the update and re-renders without a full page reload.
The overlays support light/dark themes and compact mode for tight OBS layouts. The Twitch poll uses TMI.js to listen to chat, tally votes, and announce results.
Auto-Detection
On first launch, the backend scans for Fields of Mistria by:
- Parsing Steam's
libraryfolders.vdfto find all Steam library paths - Checking each library for the game's app ID
- Scanning
AppData\Local\FieldsOfMistria(Windows) or.proton_compatdata(Linux) for save files - Returning paths sorted by modification time
No manual path configuration. The app finds the game and loads the most recent save automatically.
Testing
Unit tests with Vitest cover the save parser (with fixture save files), mod detection and conflict logic, file watcher event emission, and API route validation. E2E tests with Playwright exercise the full user flow in a headless browser.
The test fixtures include real (anonymized) save files at different game stages so the parser is tested against actual game output, not hand-crafted test data.
What I'd Do Differently
The save parser should handle format changes more gracefully. Game updates sometimes shift offsets in the save file. Right now a game update can break parsing until I update the offset constants. A more resilient approach would be to detect the game version from the save header and load the correct offset table dynamically.
The Cloudflare Worker rate limiting should use Durable Objects instead of KV. KV has eventual consistency, which means two rapid requests from the same IP might both pass the rate limit check before either write lands. Durable Objects provide strong consistency per key. For 30 req/min this rarely matters in practice, but it's architecturally wrong.
The mod backup should be more granular. Right now it snapshots the entire data.win (which can be 200MB+) before every mod install. Diffing the actual files the mod touches and only backing up those would save disk space and make restores faster.