About Nick Loubser
Nick Loubser is an IT manager and full-stack developer based in South Africa. Since 2021 he has built web and mobile applications with React, React Native, Next.js, Vue, Nuxt, TypeScript, and Python for Northroom Technologies / Evolver Systems, and since 2024 he manages business IT operations at North Support — covering server installation, network health, client websites, and email services.
Under the ShadowsKeep name he builds free game companion apps: Stardew Valley Tracker and NMS Tracker for Windows, and Horror Quest, a multiplayer AI-generated murder mystery that runs as a Discord Activity. He is available for remote freelance and contract work worldwide.
Services
- Full-stack web & mobile development (React, React Native, Next.js, Vue/Nuxt, TypeScript)
- Email migration, backup & IMAP tooling (Python)
- Network installation, monitoring & administration
- Python automation & scripting
- Website & email hosting management (Cloudflare, Docker)
Case Study: How I Built Horror Quest — an AI-Generated Multiplayer Discord Activity
Nick Loubser · July 2026 · Source on GitHub
Horror Quest is a multiplayer horror murder mystery that runs inside a Discord voice channel. Up to 10 players investigate a procedurally generated murder case together: every game has a unique AI-generated story, suspects, clues, and red herrings, with fresh scene artwork each chapter and optional voice narration. The whole thing runs serverless on Cloudflare Workers and costs about $0.10 per full game session.
What is a Discord Activity?
A Discord Activity is a web app embedded directly inside a Discord voice channel via an iframe, using the @discord/embedded-app-sdk. Everyone in the channel shares the same session — which makes Activities a natural fit for party games, but also means you inherit an unusual runtime: your app lives inside Discord's sandbox, authenticates through Discord's OAuth2, and every network request is proxied through Discord's content security rules.
Architecture
| Layer | Technology |
|---|
| Hosting + API | Cloudflare Workers (Hono router) |
| Multiplayer state | Durable Objects, SQLite-backed |
| Real-time sync | WebSockets with hibernation |
| Story generation | Gemini 2.5 Flash |
| Scene images | Imagen 3.0 Fast |
| Narration | Gemini 2.5 Flash TTS ("Charon" voice) |
| Frontend | Vite + TypeScript, no framework |
One Worker serves the static frontend, exposes the API, and upgrades WebSocket connections. Each Discord channel maps to exactly one Durable Object instance (idFromName(channelId)), which is the single source of truth for that room's game state — players, votes, chapters, clues. No separate game server, no Redis, no sticky sessions: the platform guarantees one instance per room globally.
Multiplayer voting without a game server
The core loop is vote-gated: each chapter presents choices, and the story only advances when every connected player has voted. The Durable Object holds the vote tally and broadcasts state over WebSockets. Because Durable Objects support WebSocket hibernation, the object can be evicted from memory between messages while connections stay open — you pay for compute only in the moments someone actually votes. A 3-minute chapter timer creates urgency, but it's deliberately visual-only: the game waits for all votes, because getting kicked out of a story beat by a timer feels terrible in a group setting.
The AI pipeline
Three models cooperate per game:
Story. Gemini 2.5 Flash generates the full mystery scaffold — setting, suspects, the actual culprit, clues and red herrings — then writes each chapter in response to the group's votes, keeping earlier clues persistent so the mystery stays solvable.
Images. Imagen 3.0 Fast renders a new scene image every chapter, displayed with a Ken Burns pan/zoom so static images feel alive.
Narration. Gemini TTS reads scenes aloud on demand. TTS requests route through the Durable Object so the generated audio is cached per chapter — ten players clicking "Listen" costs one generation, not ten. That single routing decision is most of the cost model.
What running inside Discord's iframe breaks
Things that are trivial on the open web need care inside an Activity:
OAuth. The SDK's authorize() hands the client a code; the Worker exchanges it server-side so the DISCORD_CLIENT_SECRET never ships to the browser.
Autoplay. Narration audio needs Permissions-Policy: autoplay=(self) and a relaxed Cross-Origin-Embedder-Policy on every response, or Discord's iframe silently blocks playback.
URLs. All traffic goes through Discord's proxy under URL mappings you configure in the Developer Portal — local development means tunneling your dev server (cloudflared) and pointing the Activity at the tunnel.
What a game costs
| Item (per ~8-chapter, 4-player game) | Cost |
|---|
| Imagen 3.0 Fast — 8 scene images | ~$0.08 |
| Gemini 2.5 Flash — full story | ~$0.002 |
| Gemini TTS — per cached "Listen" | ~$0.01 |
| Cloudflare Workers + Durable Objects | free tier |
| Total | ~$0.10 |
A $10 API budget cap runs 100+ complete group games. Cost ceilings were a design input, not an afterthought — image count per chapter, TTS caching, and model tier were all chosen against that number.
Takeaways
Durable Objects are the right primitive for room-based multiplayer: one object per room deletes a whole category of distributed-state problems. Cache AI output at the room level, not the client level. And design for the sandbox you actually run in — half the engineering in a Discord Activity is respecting Discord's iframe, proxy, and OAuth constraints rather than fighting them.