TTRPG Companion
A real-time table for a tabletop campaign — and a server nobody has to trust.

- My role
- Solo developer — the API, the real-time hub, the rules engine and the client.
- Status
- Live
The problem
Running a tabletop campaign means tracking hit points, initiative order, inventory, status effects and relationships across paper sheets and a group chat. When part of the table is remote, all of that shared state lives in one person's head — and so does every dice roll, which everyone else simply has to believe.
What I built
A turn-based companion for the campaign I run with friends. One Expo client — iOS, Android and, through react-native-web, a plain browser link — talks to a .NET 8 API over a SignalR hub, so every player sees the same sheets, the same initiative order and the same combat turn as it happens. The rules live on the server: around forty domain services covering dice, combat phases, traits, status effects, triggered reactions, arena modifiers and a day-and-rest cycle.
What it looks like

A contested attack in progress. The attacker enters their roll, the defence opens on the other player's device, and the server decides the outcome. 
Three playable characters, each with stats and the part of the story they walked in from. 
The sheet every player sees update live — the badge in the corner is the SignalR connection, not decoration.
Architecture
Client
One codebase for iOS, Android and the browser. The web build is the one that gets used: a link in the group chat beats asking five people to install something.
- Expo
- React Native
- expo-router
- react-native-web
Real-time hub
Every action at the table — joining, rolling, attacking, ending a turn — is a hub method broadcast to the room. Rejoining re-reads the current state instead of replaying what was missed.
- SignalR
- .NET 8
Rules engine
Dice, combat phases, traits, status effects, triggered reactions and arena modifiers. Each rule is one service, so a new mechanic is a new file rather than another branch in an existing one.
- C#
- Domain services
Persistence
Rooms, characters, initiative and the live combat turn are rows, not memory. SQLite locally and PostgreSQL in deployment, behind the same context.
- EF Core 8
- PostgreSQL
- SQLite
Decisions & trade-offs
The server rolls the dice
A client that generates its own numbers is a client that can be persuaded to generate better ones. The raw d20 is rolled server-side, and the modifiers from stats, equipment and relationships are applied there too — so the result that reaches the table is one nobody could have edited on the way.
But a real die still counts
People at a physical table are not going to put their dice away, and an app that tells them to is an app they close. Alongside the server roll there is a path for submitting a number you rolled yourself; the same rules apply to it. The anti-cheat protects the remote players without disciplining the ones in the room.
Connections in memory, the game in the database
SignalR connections are disposable — a phone sleeps, a tunnel drops, a deploy restarts the process. Treating them as the source of truth would mean a lost connection is a lost session. The room state is persisted instead, so reconnecting is a read rather than a recovery.
What a game master may do is a server rule
Spectator, player and director join through separate hub methods with their own access level, instead of the client hiding buttons it hopes nobody finds. Authority that only exists in the UI is authority anyone can grant themselves with developer tools.
Outcome
Friends join from a link with nothing installed, the session survives a mid-game deploy, and no one has to take anyone's word for a roll.