DEV Community

darkdejavu
darkdejavu

Posted on

I built a chat app solo — rooms, voice/video calls, E2EE, and in-room games


I didn't want to build another messenger. There are plenty. What I wanted was a place where you walk into a room built around a shared interest — movies, music, gaming — and people are just... there, and you can start talking. No "add friend" ceremony, no app store install.

That became PTT Chat (pttchat.ru). I build and maintain it solo.

A note up front: the UI is currently Russian-only. English localization is on the roadmap but not done. This post is for the architecture and the war stories — not an invitation to use the app in English today, though you're welcome to poke around.

What's in it
Topic rooms: General, Newcomers, Movies & TV, Music, Gamers — plus user-created rooms, some password-protected
Geo-channels: the app detects your city and spins up a room for it automatically if one doesn't exist yet
1:1 and group voice/video calls
E2E-encrypted DMs
Two in-room games: Mafia, and an AI-generated guessing game
An AI bot you can talk to in rooms
Installable as a PWA — no App Store, no Google Play
Login via Telegram, VK ID, email, or guest.

Architecture, briefly

Everything runs through one Node.js process: Express for REST, Socket.io for realtime, SQLite (better-sqlite3, WAL mode) as the only datastore. No microservices. For this scale, one process is easier to reason about and debug than ten, and the honest ceiling is "when I need more than one instance" — which hasn't happened yet.

Mesh vs SFU

For calls, I run two modes:
Mesh: every participant connects directly to every other participant via WebRTC. Fine for 2–3 people — minimal latency, zero server load. Uplink cost grows quadratically with group size, though.
SFU (mediasoup): each participant sends one stream to the server, which fans it out to everyone else. Flat uplink cost regardless of group size, at the cost of running media server infrastructure.

The mode is set server-side at startup, not switched dynamically per call size — a deliberate simplicity trade-off I might revisit.

The hard part wasn't group calls — it was calling one friend

Group rooms are "easy": everyone's already in the same place. A 1:1 "call this specific friend" button turned out to be the most fiddly part of the whole project.

You need to know: is the person mid-call with someone else, online but not answering, or just offline? Three different outcomes, three different notification paths. If they're offline, a push notification goes out. If they're online but silent for a few seconds, a push goes out too, in case the tab is backgrounded.

Then there's handshake ordering: you can't fire a WebRTC offer the instant someone taps "accept" — their media stream isn't ready yet. The flow had to become "accepted → peer ready → now start the connection." Skip a step and the call either doesn't connect or connects silently with no audio.

And occasionally a 1:1 call needs to become a group call mid-conversation, without dropping the existing connection.

Games in rooms

Rooms can be flagged as "game rooms," which unlocks two features:
Mafia: host-run lobby, minimum 4 players, private role assignment, alternating night/day phases with voting
"Who am I?": an LLM generates a character, one player asks yes/no questions to guess who it is. Play solo and the AI answers your questions instead of a human.

The tricky part wasn't the rules — it was disconnects. What happens when the host leaves mid-game? When the guesser closes the tab while waiting on an AI response? Each of those needed explicit handling or the round just hangs forever.

E2EE — and what it doesn't give you

DMs are encrypted client-side: ECDH on P-256 for the shared secret, AES-GCM for messages. The private key is generated as non-extractable and lives in IndexedDB, so it can't be read out via JavaScript.

I want to be upfront about the limits, because overselling crypto is worse than not having it:

Keys are static. No ratchet, so no forward secrecy — if a key is ever compromised, past messages are compromised too.
No key fingerprint verification. Public keys live server-side with no client-side defense against substitution.
Public rooms aren't encrypted at all — the server sees everything there, and metadata (who talks to whom, when) is visible regardless.

So it's protection against reading stored messages, not against a compromised server. A proper ratchet (Signal-style) is on the list, not something I'd implement from scratch myself.

Bugs that taught me something

The homepage occasionally turned into a browser error page. One fallback branch in the service worker could resolve to undefined instead of a Response, which Chrome doesn't forgive — you get a hard network error instead of your app. It only reproduced with an empty cache and no network, which meant it almost never showed up on my dev machine with a warm cache. Lesson: every fallback path in a service worker has to guarantee a Response, full stop.

A verification function existed and was never called. Found during review — the code looked complete, read like a complete auth check, and just... wasn't wired into the route that mattered. Nothing broke under normal use. It would only have mattered against a deliberately crafted request. That's the scary category of bug: invisible until someone looks for it on purpose.

General takeaway: the bugs that bite are in the paths nobody manually tests, not in the happy path you click through every day.

What's next:

  • Dynamic mesh/SFU switching based on participant count, instead of one mode for the whole server
  • E2EE improvements: key fingerprint verification, key rotation, multi-device support
  • Scaling beyond a single instance: moving off SQLite for storage, Redis adapter for sockets
  • English UI, if there's enough interest

If you poke around and hit something confusing (especially the Russian-only UI), I'd genuinely like to hear about it. pttchat.ru

Top comments (0)