Rektstudios, full-time
Rekt Game Chat
- Ongoing
- Web client
- Game client
- Backend team of 3
The game's messaging service: direct messages, public, private and global groups, friends, presence and typing. GraphQL handles reads and writes. Socket.IO carries live events. A Redis adapter with separate publish and subscribe clients keeps several instances in sync. Chat verifies JWTs that Core issued, RS256, and never signs. Core copies profiles over RabbitMQ. Chat does not call Core per message. Rooms are conversation, group and user. A user stays online until the last device leaves.
- Where
- Rektstudios (opens in a new tab), full-time employment
- Role
- One of the founding backend engineers, backend team of 3. We built Rekt Game Chat from scratch together. I review code within the team and coordinate with the game and frontend teams.
- Serves
- Web client and game client
- Stack
- NestJS 11, TypeScript 5.9, MongoDB, Prisma 6, Redis, RabbitMQ, Socket.IO, GraphQL
- message length cap
- 5,000
- edit window
- 15 min
- messages rate limit
- 5/s
- unread count cap
- 999
- pins per group
- 5
- test coverage threshold
- 100%
We built Rekt Game Chat from zero.
01Context and my role
I am one of the founding engineers in a backend team of three. We built Rekt Game Chat together from scratch. I work with the same team that built Rekt Game Core. Core issues the JWTs and owns the profiles. Chat verifies those tokens with Core's public key. Profile changes arrive on RabbitMQ. Chat upserts the local profile.
02Focus
Rekt Game Chat is the messaging service of the Rekt system. Web and game clients talk to it on GraphQL and Socket.IO. Core is the only issuer of tokens.
- GraphQL for reads and writes. No subscriptions. Live updates go through Socket.IO.
- A Redis adapter so several instances share rooms.
- RabbitMQ copies profiles from Core, so Chat does not fetch Core on every message.
- Presence: a Redis session with no TTL, a Mongo online flag, and a count of sockets in the user room.
03Features
Direct messages
One-to-one conversations with read receipts and delete. Edit within 15 minutes. Delete for everyone within 2 days. A message is at most 5,000 characters.
Groups
Public, private and global groups, with owner, admin and member roles, join requests and invites. Slow mode is off by default and set per group. At most 5 pins.
Friends
Friend requests, friends, blocking, mutual friends and friend search.
Unread
Unread counts cap at 999.
Live signals
Typing indicators in conversations and groups, and online presence across several devices.
Guests
An anonymous socket joins the global group automatically and can upgrade to a signed-in user on the same connection with authenticateGuest.
Other
Pinned items, in-app notifications, profanity filtering.
04How it works
A state-changing call from the client hits GraphQL, then a service. The service writes to MongoDB and emits an in-process event. One of 32 listeners pushes it to the conversation or group room on this instance. The Redis adapter publishes to the other instances. GraphQL is Apollo Server 5: 16 resolvers, 24 queries, 40 mutations, 42 resolved fields, no subscriptions. Socket.IO 4.8 has 9 handlers. Services are fat classes, not one use case per class.
- GraphQL limits: max depth 12, max fields 500. DataLoader batches reads. Cursor pagination.
- Throttlers: default 10/s, block 10 s. Messages 5/s, block 10 s. Strict 5 per 10 s, block 30 s. WebSocket 10/s, block 5 s.
- Bot detection: flags a client after 5 signals.
- Cache TTL: 5 minutes, on separate Redis cache modules for conversations, groups, messages and users.
- Delete for everyone: within 2 days.
- Slow mode: off by default, set per group.
Instances and the Redis adapter
The service runs as several copies. Every instance shares one Redis and one MongoDB replica set. The Socket.IO adapter is a custom @socket.io/redis-adapter with separate publish and subscribe clients, so a broadcast on one instance reaches sockets on every instance. Core sends profile changes on RabbitMQ. Chat verifies tokens with Core's public key.
@socket.io/redis-adapter with separate publish and subscribe clients, so a broadcast on one instance reaches sockets on every instance. Core sends profile changes on RabbitMQ. Chat verifies tokens with Core's public key.A message to every device
The client sends a GraphQL mutation. The service writes to MongoDB, emits an in-process event, and a listener pushes it to the conversation or group room. That room reaches sockets on this instance. The Redis adapter carries the event to the other instances, which reach every connected device of every member.
Three kinds of room
Clients join a conversation room, a group room and a user room. The user room sends personal events to every device, counts sockets for presence, and joins or leaves every device on a conversation or a group. A signed-in connect joins the user room, joined groups and conversations. Guests join the global group only. They are not counted in the user room.
Online until the last device leaves
On connect, a Redis session with no TTL marks the user online and Mongo stores the online flag. On disconnect, Chat counts sockets in the user room across instances. If that count is 0, Mongo goes offline, the session is deleted, and the socket leaves the user room. Another open device keeps the user online.
Core tokens, Chat only verifies
The handshake can carry a token, or GraphQL can send a bearer. Chat verifies RS256 with Core's public key. It never signs. The first connect may create a local user from the JWT payload. A guest connects with a guest id. authenticateGuest upgrades the same socket.
05Decisions and trade-offs
Infrastructure
Socket.IO instead of GraphQL subscriptions
- Why
- Rooms, acknowledgements and the Redis adapter come ready-made in Socket.IO.
- Cost
- Live updates are a second transport. GraphQL has no subscriptions, so a client speaks two protocols.
Profile data copied from Core over RabbitMQ
- Why
- Chat renders names and avatars without calling Core on every message.
- Cost
- A profile change is eventually consistent.
Other
Presence counted per socket, not per user
- Why
- A user with a phone and a laptop open stays online when one of them disconnects.
- Cost
- Disconnect has to count sockets in the user room across instances, not only close the local socket.
A user room for personal events, presence and joins
- Why
- Personal events, the presence count, and join or leave of every device share one room.
- Cost
- Every signed-in connection joins that room. Guests stay out of the presence count.
06Testing
- Tests: Jest unit tests with mocks. 45 spec files, 1,430 test cases, and a 100% global coverage threshold.
- Local quality gates: Husky, commitlint, lint-staged, ESLint, Prettier, cspell, ts-prune (dead exports).
07Stack
- Runtime and language
Node.js 20.11 or later
TypeScript 5.9
SWC
Yarn 1.22
- Framework
NestJS 11
NestJS GraphQL with Apollo Server 5
- DataLoader
- Real time
Socket.IO 4.8 with the Redis adapter
- Data
MongoDB (replica set) through Prisma 6
Redis (ioredis)
- Messaging
RabbitMQ (amqplib, amqp-connection-manager)
- Auth
JWT (RS256 public-key verification)
- Other
NestJS Throttler
NestJS event emitter
Sentry
Pino
- Testing
Jest 30
- @swc/jest
- Supertest