Every Indian friend group has the same four games in its memory: Raja Mantri Chor Sipahi on torn notebook chits, Naam Jagah Janwar Cheez in the last period of school, Tambola at every family function, and Ludo on somebody's phone. I wanted all of them in one place, playable the way they are actually played: a group, talking over each other, someone accusing someone else of cheating.
That became Mandli, a free party-games app with 49 games. One person makes a room and drops the link in a WhatsApp group. Friends open it and they are in, with just a name, no login and no OTP. The same room works in the Android app and in a browser, so nobody has to install anything to join. And inside every game there is a mic button, because the point of these games was always the arguing.
This post is the engineering underneath: the shape of the system, the one contract every game follows, the three things that broke voice on the web, and what the server actually costs to run, measured rather than guessed.

The shape of it
There are two servers and they never share a pipe. Game state is small JSON over one WebSocket to a Node process. Voice is Opus audio over WebRTC to a LiveKit media server. The only thing connecting them is a token: the room server signs a LiveKit token only for a player who is currently connected to that room.
The split matters more than it looks. Game messages are tiny and must arrive in order. Audio is big, continuous and happy to drop a packet rather than wait for it. Putting them on one connection means one of them always suffers. Keeping them apart also meant voice could be switched off per player, for example during the night phase of the mafia-style game, without the game server knowing anything about audio.
Both servers run in Docker behind nginx on a VPS that already hosts another product. That was a deliberate constraint: I wanted to know whether a hobby-sized box could carry this, and the numbers further down say it can.
One codebase for the app and the invite link
The growth loop for a party game is the invite. If the person receiving the link has to install an app before they can play, most of them won't, and the group moves on. So the web is not a separate product here. It is the same Expo app, SDK 57 with expo-router, exported to static web files. A link like /r/K7PX opens the room in a browser today, and once the Play Store listing is live and its signing key is published for Android App Links, it will open the app for anyone who has it installed.
What that bought me:
- One set of screens for 49 games. Each game's UI is written once in React Native and runs in both places.
- Over-the-air updates. Most changes are JavaScript, so a fixed rule or a new game reaches installed phones through EAS Update without a store review.
- A link preview that sells the invite. The server renders a share image per room code, with the host's name, so the WhatsApp preview says who is calling you.
What it cost: every native-only API needs a web answer, and the web answers are where the surprises were. More on that in the voice section.
Games as pure functions
The decision that made 49 games possible was a single contract. Every game is a module of pure functions that the room server calls. The module never touches a socket, a timer or the clock; it is handed the time and returns a new state.
export interface GameModule<State, Action, View> {
parseAction(raw: unknown): Action | null
init(init: GameInit): State
handle(state: State, playerId: string, action: Action, now: number): State
tick(state: State, now: number): State
playerLeft(state: State, playerId: string, now: number): State
viewFor(state: State, playerId: string | null): View
isOver(state: State): boolean
scores(state: State): Record<string, number>
remember?(state: State, memory: unknown): unknown
silenced?(state: State): string[]
}Four properties fall out of that shape, and each one solved a real problem.
The server decides everything. A client sends intentions ("I claim a line", "I play this card") and the module decides whether they count. In Tambola the app checks every claim against the numbers actually called. Nobody can mark a number the caller never said.
Views, not state. viewFor builds what one specific player is allowed to see. In a card game, your view has your hand, everyone's card counts, and the table. It does not have anyone else's hand with a flag saying "hidden". Whatever isn't in the view isn't on the wire, so there is nothing for a curious player to find in the browser's network tab.
Randomness is seeded. Shuffles and deals come from a seeded generator stored in the state, and the seed is never put in a view. That makes every game replayable in a test, which is what made the next part possible.
Timers are just tick. Turn clocks, auto-play when someone's time runs out, the pause before the next number in Tambola: all of it is tick(state, now). Tests move time by passing a bigger now.
Proving nothing leaks
For a game where the whole fun is a secret, a leak is the worst bug you can ship: the imposter's identity, the Chor's chit, the hidden trump in Dehla Pakad. Every game with hidden information has a test that plays 1,000 seeded rounds with random legal moves and, at every step, checks each player's view for things they must not see: other hands, the deck order, the seed. Card games also play 300 full random games each to prove every round actually ends.
The whole suite is 1,189 tests across 68 files. It is the reason I trust changes to games I haven't opened in a week.
| Guarantee | How it is enforced |
|---|---|
| No secret in any view | 1,000 seeded rounds per game with hidden information, every view checked at every step |
| Every round ends | 300 random full games per card game, plus timeouts that auto-play |
| Reconnects are harmless | seat held for 60 seconds; the view is rebuilt from state, not replayed |
| Rooms don't pile up | a room with nobody in it for two hours is forgotten |
| Big groups still play | up to 50 in a room; two-player games seat winners and queue everyone else |
That last row is worth a sentence. Chess is two players; a room can hold fifty. Board and card games seat as many as the game allows and let the rest watch. The winner keeps their seat, the loser goes to the back of the queue, and after three wins in a row even the champion rotates out, so a big room keeps moving.
Voice chat for free, and the three things that broke
Voice was the feature I most wanted and the one I was most wary of paying for. Hosted voice APIs charge by the participant-minute, and a party game is many people talking for a long time. So Mandli self-hosts LiveKit, an open-source SFU, in the same Docker setup as the room server: signalling goes through nginx, media on UDP 7882 with TCP 7881 as a fallback.
An SFU forwards each speaker's audio to everyone else without mixing it. That keeps the server's CPU low and makes bandwidth the real cost: every speaker is copied to every listener. Latency matters too, but far less than in a voice AI agent, where every stage eats into a fixed budget: friends shouting over a Ludo board forgive a few hundred milliseconds that a bot conversation would not.
The LiveKit client's default is to publish audio with a 48 kbps cap, DTX on and redundant audio on. DTX is what makes this affordable: when a player is quiet, their stream drops to almost nothing. Plan for the talking, not for the silence. The calculator below does the arithmetic for your own room sizes.
calculator · group voice through an SFU
How much bandwidth does voice chat in a game room use?
An SFU forwards each speaker's stream to every other player without mixing, so upload grows with speakers × listeners. These are upper bounds while people talk: with DTX on, a silent mic sends almost nothing, and real Opus usually runs below its cap. Packets are counted at 50 a second (20 ms frames). Redundant audio (RED) and TURN relaying add to this; measure your own before you size a link.
It worked on Android on the first try. On the web it broke three separate ways.
1. The mic was blocked by a header I had forgotten about. The VPS serves other sites, and its shared nginx config sent Permissions-Policy: microphone=() on every response. That header tells the browser no page may use the microphone, and the browser obeys without any useful error. The fix was a per-site header set allowing microphone=(self), and later camera=(self) for Dumb Charades. If your getUserMedia call silently fails in production but works on localhost, check the response headers before you check your code.
2. People could see who was talking, but nobody could hear them. The speaking indicator lit up, so audio was clearly arriving. On native, LiveKit plays remote audio for you. On the web it doesn't: each remote track has to be attached to an audio element, and the browser has to be allowed to start audio after a user gesture. Attaching tracks as they are subscribed, and starting audio from the mic button tap, fixed it.
3. The Play build asked for a permission I never used. The WebRTC library declares a foreground service for screen sharing. Mandli never shares a screen, but the declaration alone puts the app into a category that needs extra justification on Google Play. A small Expo config plugin removes the service from the Android manifest, and a blocked-permissions list keeps the rest out.
What the server actually costs
I load-tested the production bundle rather than guess. The same bundle that runs on the VPS was started locally, rooms were filled with scripted players, and a round was started in each. Memory was read with ps once the rooms had been playing for a few seconds.
Fifty rooms with 300 connected players sat at 115 MB of resident memory, slightly less than an empty server that had just rendered a share image. The rooms are genuinely small: a Tambola round is some tickets, a list of called numbers and a few claims. What stays resident is Node itself, a 2.5 MB bundle that carries every game's word banks and questions, and the renderer for link previews.
That matches what I found measuring how many WebSocket connections one Node server can hold: connections are cheap, and what runs out first is deliveries per second. A party game sends a message when someone acts, not sixty times a second, so the room server is nowhere near its ceiling. Voice is where the bytes are, and the bytes go to LiveKit, not Node.
The same load test also turned up a limit I had set myself: room creation is rate-limited per IP address. That is right for abuse and wrong for mobile networks in India, where carrier-grade NAT can put thousands of users behind one address. So the limit now counts per device, 20 rooms an hour, and the per-IP cap is raised to a thousand, which only stops one machine flooding the server.
quick check
Eight players are in a voice room and two of them talk at once. Each stream costs about 68 kbps with headers. Roughly how much does the SFU upload for this room?
Each of the 2 speakers is forwarded to the 7 other players: 2 × 7 × 68 kbps is 952 kbps. The SFU copies, it never mixes, so upload grows with speakers × listeners.
A website that works for the app
A game nobody can find is a game nobody plays, so the site at mandli.speedsharma.com is built from the same source as the app. Game names, how-to steps and the privacy text are imported from the app code at build time, so the site and the app can't disagree about the rules.
Each game has its own rules page, like Dehla Pakad (Mendikot) rules or Raja Mantri Chor Sipahi. Those pages are written against the code, not against how I remember the game, so they say exactly what the app does.
There are also two free tools that need no app at all: a Tambola number caller that reads numbers aloud and runs full screen on a TV, and a printable ticket generator that uses the same ticket code as the game.

What I would do differently
- Measure voice on day one. I built the voice feature, then found out how it failed on the web. Two-browser tests from the start would have caught all three bugs before a user did.
- Decide seat rules before writing card games. "Everyone in the room plays" breaks as soon as a game has four seats. Winner-stays seating became a shared helper late; it should have been part of the contract.
- Send drawing deltas, not whole drawings. The drawing game currently re-sends the full stroke list as people draw. It works at party sizes, but it is the one place the bandwidth grows faster than it should.
questions people ask
Can you build a multiplayer game app with voice chat without paying per minute?
Yes. Mandli self-hosts LiveKit, an open-source SFU, on an existing VPS. The cost moves from per-minute pricing to bandwidth, which is roughly speakers × listeners × about 68 kbps while people talk, and close to nothing during silence.
Does Expo work for real-time multiplayer games?
For turn-based and party games, yes. Mandli's Android app and its web version are one Expo codebase talking to a WebSocket server. Fast-twitch action games are a different problem, but most party games send a message only when someone acts.
How do you stop players from cheating in an online card game?
Keep the rules and the secrets on the server. Each player receives a view built only for them, so other hands, the deck and the random seed never leave the server. Mandli tests this with 1,000 random seeded rounds for every game that has hidden information.
Why does my microphone not work in the browser even though permission was granted?
Check the Permissions-Policy response header. A shared server config sending microphone=() blocks the mic for every page, silently. Also check that remote audio tracks are attached to audio elements, since browsers do not play them automatically.
How much server does a party game need?
Less than you'd think. Fifty live rooms with 300 players used about 115 MB of memory in my test, the same as an empty server. Voice bandwidth, not game logic, is what you size for.
Is Mandli free?
Yes. It has no ads, no betting and no purchases; every game uses points only. The Android app is in beta testing, and the browser version works today.
The short version
Put the rules and the secrets on the server, and give each player a view instead of the state. Keep voice on its own path, self-host the SFU if per-minute pricing scares you, and plan bandwidth for people talking over each other. Ship the web and the app from one codebase so an invite never asks anyone to install anything first.
If you want to see how it plays, open Mandli in your browser, make a room and send the link to three friends. The mic is in the top corner.