Building Slack-Style Chat from Scratch: Architecture and Trade-offs
Chat is deceptively complex — rooms, direct messages, moderation, blocked users, and message persistence all interact in non-obvious ways. This walks through the full implementation we built for a live multiplayer platform and the decisions that shaped it.
Building a production-ready chat system requires more than just sending messages. This deep dive covers the complete implementation from ft_transcendence: a dual-layer messaging architecture with direct messages and group channels, real-time delivery via Socket.io namespaces, role-based moderation with timed bans and mutes, hashed room IDs, blocked user filtering, and message persistence with Prisma and PostgreSQL.
Note: All code examples are taken directly from the actual ft_transcendence codebase. Variable names like recieverId, generateHashedRommId, unbanneTime, and hachedChannelPswd reflect the original source code as-is.
Real-Time Communication Fundamentals
Before diving into the implementation, it is essential to understand the core technologies and concepts that power the chat system.
WebSocket Protocol
Traditional HTTP follows a request-response cycle — the client asks, the server answers, and the connection closes. But chat requires the opposite: the server needs to push messages to clients instantly, without waiting for them to ask. WebSocket solves this by upgrading an HTTP connection into a persistent, full-duplex channel where both sides can send data at any time.
HTTP (Request-Response):
Client ──── GET /messages ────▶ Server
Client ◀──── 200 OK ────────── Server
(connection closed)
WebSocket (Full-Duplex):
Client ──── HTTP Upgrade ────▶ Server
Client ◀──── 101 Switching ── Server
Client ◀────────────────────▶ Server (persistent, bidirectional)
Socket.io & NestJS Gateways
Socket.io builds on top of raw WebSockets and adds critical features for production use: automatic reconnection, fallback to HTTP long-polling, room-based broadcasting, and acknowledgements. In our NestJS backend, Socket.io is integrated through Gateways — classes decorated with @WebSocketGateway that define event handlers using @SubscribeMessage.
// NestJS Gateway Pattern
@WebSocketGateway({ namespace: 'chatGateway' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
// Fired when a client connects
handleConnection(client: Socket): void { ... }
// Listen for 'message' events from clients
@SubscribeMessage('message')
handleMessage(@MessageBody() data: any, @ConnectedSocket() socket: Socket) { ... }
// Fired when a client disconnects
handleDisconnect(client: Socket): void { ... }
}
Namespaces & Rooms
Socket.io provides two levels of message scoping:
- Namespaces are separate communication channels on the same server. Each namespace has its own set of event handlers, connected clients, and rooms. We use three:
/appGateway,/chatGateway, and/channelGateway. - Rooms are arbitrary groupings within a namespace. A socket can join or leave rooms at any time. When you emit to a room, only sockets in that room receive the message — this is how we scope DMs to two users and channel messages to group members.
Prisma ORM & PostgreSQL
Prisma provides type-safe database access with auto-generated TypeScript types from the schema. Every model in the Prisma schema maps directly to a PostgreSQL table, and relations are enforced at the database level with foreign keys. This means our chat data — messages, channels, memberships — is always consistent and queryable with full type safety.
Chat System Architecture
The ft_transcendence chat system is built on a dual-layer architecture: Direct Messages for 1-on-1 conversations and Channels for group communication. Each layer has its own dedicated Socket.io namespace, service, and gateway — keeping concerns separated and scalable.
+--------------------------------------------------------+
| FRONTEND |
| |
| +-----------+ +-----------+ +----------+ +-----------+ |
| | Chat UI | | Channel | | Member | | Friend | |
| | (1-on-1) | | Room | | List | | List | |
| +-----+-----+ +-----+-----+ +----+-----+ +-----+-----+ |
| | | | | |
| +-------------+------------+-------------+ |
| | |
| Socket.io Client (3 Namespaces) |
+----------------------------+---------------------------+
|
WebSocket
|
+----------------------------+---------------------------+
| NestJS Backend |
| | |
| +----------------------------------------------------+ |
| | Socket.io Gateways | |
| | | |
| | /appGateway - Friends, Invitations, Blocking | |
| | /chatGateway - Direct Messages (1-on-1) | |
| | /channelGateway - Group Channels, Moderation | |
| +-----------------------+----------------------------+ |
| | |
| +-----------------------+----------------------------+ |
| | Services & Client Tracking | |
| | | |
| | ChatService ChannelService | |
| | UsersService ConnectedClientsService | |
| +-----------------------+----------------------------+ |
| | |
+----------------------------+---------------------------+
|
Prisma ORM
|
+-------------------+
| PostgreSQL |
| |
| - User |
| - DirectMessage |
| - Channel |
| - ChannelMember |
| - BlockedUser |
+-------------------+
Database Schema Design
The schema separates direct messaging from channel-based communication. Direct messages use a SHA-256 hashed room ID derived from both user IDs, while channels support public, protected (password), and private types with granular role-based membership.
Core Models — Users & Direct Messages
enum Status {
ONLINE
OFFLINE
INAGAME
}
model User {
id String @id @default(uuid())
name String? @unique
email String @unique
intraId String @unique
Avatar String?
status Status @default(ONLINE)
isAuth Boolean @default(true)
twoFactorAuthSecret String?
isTwoFactorEnabled Boolean @default(false)
// Relations
userThatBlock BlockedUser[] @relation("Bloking")
blockedUser BlockedUser[] @relation("blocked")
senders DirectMessage[] @relation("sendeMessage")
receivers DirectMessage[] @relation("receiveMessage")
channelOwner Channel[] @relation("channelOwner")
channelMember ChannelMember[] @relation("channelMember")
kickedMember KickedMember[] @relation("kickedMember")
channelMessage ChannelMessage[] @relation("sendChannelMessage")
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
model DirectMessage {
id String @id @default(uuid())
content String
sender User @relation("sendeMessage", fields: [senderId], references: [id])
senderId String
reciever User @relation("receiveMessage", fields: [recieverId], references: [id])
recieverId String
roomId String // SHA-256 hash of sorted sender+receiver IDs
seen Boolean @default(false)
created_at DateTime @default(now())
}
model BlockedUser {
id String @id @default(uuid())
userThatBlock User @relation("Bloking", fields: [userId], references: [id])
userId String
blockedUser User @relation("blocked", fields: [blockedUserId], references: [id])
blockedUserId String
created_at DateTime @default(now())
}
Channel Models — Group Communication
enum ChannelType {
PUBLIC // Open to all users
PROTECTED // Requires password to join
PRIVATE // Invite-only via UUID
}
enum Role {
OWNER
ADMIN
MEMBER
MUTED_MEMBER // Temporarily silenced
BANNED_MEMBER // Temporarily banned
MUTED_ADMIN // Admin temporarily silenced
BANNED_ADMIN // Admin temporarily banned
}
model Channel {
id String @id @default(uuid())
channelName String @unique
channelType ChannelType @default(PUBLIC)
channelPassword String?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation("channelOwner", fields: [channelOwnerId], references: [id])
channelOwnerId String
channelMember ChannelMember[] @relation("channelMember")
kickedMember KickedMember[] @relation("kickedMember")
recievers ChannelMessage[] @relation("receiverRelation")
}
model ChannelMember {
id String @id @default(uuid())
user User @relation("channelMember", fields: [userId], references: [id])
userId String
channel Channel @relation("channelMember", fields: [channelId], references: [id])
channelId String
role Role @default(MEMBER)
bannedTime DateTime?
unbanneTime DateTime?
mutedTime DateTime?
unmuteTime DateTime?
created_at DateTime @default(now())
@@unique([userId, channelId], name: "userAndChannel")
}
model KickedMember {
id String @id @default(uuid())
user User @relation("kickedMember", fields: [userId], references: [id])
userId String
channel Channel @relation("kickedMember", fields: [channelId], references: [id])
channelId String
created_at DateTime @default(now())
@@unique([userId, channelId], name: "kickedMemberAndChannel")
}
model ChannelMessage {
id String @id @default(uuid())
content String
sender User @relation("sendChannelMessage", fields: [userId], references: [id])
userId String
recievers Channel @relation("receiverRelation", fields: [channelId], references: [id])
channelId String
created_at DateTime @default(now())
}
Backend Implementation
Connected Clients Service
A centralized service tracks all connected clients across different gateways. Separate maps are maintained for the app gateway and chat gateway, enabling cross-namespace notifications — for example, sending a notification via the app gateway when a message arrives on the chat gateway.
import { Injectable } from '@nestjs/common';
import { Socket } from 'socket.io';
import { PrismaService } from 'prisma/prisma.service';
export const connectedClients: Map<string, string> = new Map();
@Injectable()
export class ConnectedClientsService {
private connectedClientsInChat: Map<string, string> = new Map();
constructor(private prisma: PrismaService) {}
addClient(client: Socket) {
const userId = client.handshake.auth.userId as string;
if (userId && client.id) {
if (!connectedClients.has(client.id)) {
connectedClients.set(client.id, userId);
}
}
}
addClientInchat(client: Socket) {
const userId = client.handshake.auth.userId as string;
if (userId && client.id) {
if (!this.connectedClientsInChat.has(client.id)) {
this.connectedClientsInChat.set(client.id, userId);
}
}
}
getAllClients(): Map<string, string> {
return connectedClients;
}
getAllClientsFromChat(): Map<string, string> {
return this.connectedClientsInChat;
}
removeClient(client: Socket) {
connectedClients.delete(client.id);
}
removeClientFromChat(client: Socket) {
this.connectedClientsInChat.delete(client.id);
}
}
Direct Message Service
The chat service handles 1-on-1 messaging with a clever room ID strategy: both user IDs are sorted and joined, then SHA-256 hashed. This ensures the same room ID is generated regardless of which user initiates the conversation — no duplicate rooms, no lookups needed.
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { CreateChatDto } from './dto/create-chat.dto';
import { PrismaService } from '../../prisma/prisma.service';
import { DirectMessage } from '@prisma/client';
import { createHash } from 'crypto';
import { UpdateChatDto } from './dto/update-chat.dto';
@Injectable()
export class ChatService {
constructor(private prisma: PrismaService) {}
async generateHashedRommId(
senderId: string,
recieverId: string
): Promise<string> {
const roomID = [senderId, recieverId].sort().join('-');
const hasshedRoomName = createHash('sha256')
.update(roomID)
.digest('hex');
return hasshedRoomName;
}
async createChat(createChatDto: CreateChatDto): Promise<DirectMessage> {
const hasshedRoomName = await this.generateHashedRommId(
createChatDto.senderId,
createChatDto.recieverId
);
return this.prisma.directMessage.create({
data: {
content: createChatDto.content,
senderId: createChatDto.senderId,
recieverId: createChatDto.recieverId,
roomId: hasshedRoomName,
},
});
}
async findAllChats(hashedRoomId: string): Promise<DirectMessage[]> {
return this.prisma.directMessage.findMany({
where: { roomId: hashedRoomId },
orderBy: { created_at: 'asc' },
});
}
async findAllReceivedChats(userId: string): Promise<DirectMessage[]> {
return this.prisma.directMessage.findMany({
where: { recieverId: userId, seen: false },
orderBy: { created_at: 'asc' },
});
}
async updateChat(
senderId: string,
receiverId: string,
updateChatDto: UpdateChatDto
): Promise<void> {
const hasshedRoomName = await this.generateHashedRommId(senderId, receiverId);
await this.prisma.directMessage.updateMany({
where: {
roomId: hasshedRoomName,
recieverId: senderId,
seen: false,
},
data: updateChatDto,
});
}
}
Chat Gateway — Direct Messages
The chat gateway operates on the /chatGateway namespace. When a user joins a room, the gateway hashes the sender+receiver IDs, loads message history, and manages Socket.io room subscriptions. Message sending includes blocked user checks and cross-namespace notifications.
@WebSocketGateway({
namespace: 'chatGateway',
cors: {
origin: `${process.env.APP_URI}:5173/chat`,
},
})
export class ChatGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
constructor(
private readonly chatService: ChatService,
private readonly connectedClientsService: ConnectedClientsService,
private readonly usersService: UsersService,
private prisma: PrismaService,
private appGateway: AppGateway,
) {}
@WebSocketServer()
server: Server;
private logger: Logger = new Logger('CHAT Gateway Log');
handleConnection(client: Socket): void {
this.connectedClientsService.addClientInchat(client);
this.logger.log(`Client connected: ${client.id}`);
}
@SubscribeMessage('joinRoom')
async handleJoinRoom(
@MessageBody() payload: { senderId: string; recieverId: string },
@ConnectedSocket() socket: Socket,
): Promise<void> {
const hasshedRoomName = await this.chatService.generateHashedRommId(
payload.senderId,
payload.recieverId,
);
const messages = await this.chatService.findAllChats(hasshedRoomName);
// Bootstrap: create an empty message for first-time conversations
if (messages.length === 0) {
await this.prisma.directMessage.create({
data: {
content: '',
senderId: payload.senderId,
recieverId: payload.recieverId,
roomId: hasshedRoomName,
seen: true,
},
});
}
// Leave all previous rooms (except socket's own room)
Array.from(socket.rooms)
.filter((id) => id !== socket.id)
.forEach((id) => socket.leave(id));
// Join the hashed room
if (!socket.rooms.has(hasshedRoomName)) {
socket.join(hasshedRoomName);
this.server.to(socket.id).emit('joined', { roomName: hasshedRoomName });
}
}
@SubscribeMessage('message')
async handleEvent(
@MessageBody() payload: CreateChatDto,
@ConnectedSocket() socket: Socket,
): Promise<void> {
// Check if sender is blocked by receiver
const blockedUser = await this.usersService.findOneBlockedUser(
payload.recieverId,
payload.senderId,
);
if (!blockedUser) {
const hasshedRoomName = await this.chatService.generateHashedRommId(
payload.senderId,
payload.recieverId,
);
if (!socket.rooms.has(hasshedRoomName)) {
socket.join(hasshedRoomName);
}
// Broadcast to room participants
this.server.to(hasshedRoomName).emit('getMessage', {
senderId: payload.senderId,
receiverId: payload.recieverId,
text: payload.content,
room: hasshedRoomName,
});
// Persist message to database
await this.chatService.createChat(payload);
// Notify receiver in chat namespace (refresh their conversation list)
for (const [key, val] of this.connectedClientsService.getAllClientsFromChat()) {
if (val === payload.recieverId) {
this.server.to(key).emit('refresh');
}
}
// Cross-namespace notification via app gateway
for (const [key, val] of this.connectedClientsService.getAllClients()) {
if (val === payload.recieverId) {
this.appGateway.server.to(key).emit('notifMessage', payload);
}
}
}
}
handleDisconnect(client: Socket): void {
this.connectedClientsService.removeClientFromChat(client);
this.logger.log(`Client disconnected: ${client.id}`);
}
}
Channel System — Group Communication
Channel Gateway
The channel gateway handles group chat with support for public channels, password-protected channels, and private (invite-only) channels. It manages the full lifecycle: creation, joining, messaging, role management, and deletion.
Channel Creation with Password Protection
@WebSocketGateway({
namespace: 'channelGateway',
cors: {
origin: `${process.env.APP_URI}:5173/channels`,
},
})
export class ChannelGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private connectedClientsInChannel: Map<string, string> = new Map();
@SubscribeMessage('createChannel')
async handleCreateChannel(
@MessageBody() payload: CreateChannelDto,
@ConnectedSocket() socket: Socket,
): Promise<void> {
if (payload.channelPassword) {
// Hash password with SHA-256 before storing
const hachedChannelPswd = createHash('sha256')
.update(payload.channelPassword)
.digest('hex');
const channelProtected = await this.prisma.channel.create({
data: {
channelName: payload.channelName,
channelType: payload.channelType,
channelPassword: hachedChannelPswd,
channelOwnerId: payload.channelOwnerId,
},
});
await this.channelService.createChannelOwner(
channelProtected.channelOwnerId,
channelProtected.id,
);
socket.join(channelProtected.id);
} else {
const channel = await this.channelService.createChannel(payload);
await this.channelService.createChannelOwner(
channel.channelOwnerId,
channel.id,
);
socket.join(channel.id);
}
this.server.to(socket.id).emit('refrechCreateChannel');
}
}
Channel Messaging
Once a member has joined a channel, sending messages is straightforward: ensure the socket is in the room, persist the message to the database, then broadcast to all members in the channel.
@SubscribeMessage('messageChannel')
async handleMessage(
@MessageBody() payload: CreateChannelMessageDto,
@ConnectedSocket() socket: Socket,
): Promise<void> {
// Ensure the sender is in the channel room
if (!socket.rooms.has(payload.channelId)) {
socket.join(payload.channelId);
}
// Persist message to database
await this.channelService.createChannelMessage(payload);
// Broadcast to all channel members
this.server.to(payload.channelId).emit('onMessage', payload);
}
Joining Channels with Access Control
Joining a channel involves multiple validation layers: checking existing membership, verifying kicked status, and for protected channels, validating the hashed password.
@SubscribeMessage('joinChannel')
async joinChannel(
@MessageBody() payload: {
userId: string;
channelId: string;
channelPasword: string;
},
@ConnectedSocket() socket: Socket,
): Promise<void> {
const channel = await this.channelService.findOneChannel(payload.channelId);
const member = await this.prisma.channelMember.findUnique({
where: {
userAndChannel: {
userId: payload.userId,
channelId: payload.channelId,
},
},
});
if (member) {
// Existing member — verify password for PROTECTED channels
if (channel.channelType === 'PROTECTED' && payload.channelPasword) {
const hachedChannelPswd = createHash('sha256')
.update(payload.channelPasword)
.digest('hex');
if (channel.channelPassword === hachedChannelPswd) {
socket.join(channel.id);
this.server.to(socket.id).emit('joinedSuccessfully');
} else {
this.server.to(socket.id).emit('error', 'Invalid password');
}
} else {
socket.join(channel.id);
this.server.to(socket.id).emit('joinedSuccessfully');
}
} else {
// New member — check if kicked
const kickedMember = await this.channelService.findOneKickedMember(
payload.channelId,
payload.userId,
);
if (!kickedMember) {
const user = await this.usersService.findOne(payload.userId);
await this.channelService.createChannelMember({
userId: payload.userId,
channelId: payload.channelId,
role: 'MEMBER',
});
socket.join(payload.channelId);
this.server.to(payload.channelId).emit('refrechMember');
} else {
this.server.to(socket.id).emit('error', 'You are kicked from this Channel');
}
}
}
Moderation System
The moderation system uses role-based permissions with time-based bans and mutes. A @Cron job runs every second to automatically unban or unmute members when their punishment expires — preserving their original role (MEMBER or ADMIN).
Role State Machine
Each channel member has a role that transitions based on moderation actions. The key insight is that MEMBER and ADMIN are treated as parallel tracks — a muted/banned admin returns to ADMIN, not MEMBER:
+--------------------------------------------------+ | ROLE TRANSITIONS | +--------------------------------------------------+ | | | MEMBER track: | | +---------+ mute +---------------+ | | | MEMBER |----------->| MUTED_MEMBER | | | | |<-----------| (timed) | | | +---------+ unmute +---------------+ | | | | | | ban +---------------+ | | +---------------->| BANNED_MEMBER | | | |<----------------| (timed) | | | | unban +---------------+ | | | | ADMIN track: | | +---------+ mute +---------------+ | | | ADMIN |----------->| MUTED_ADMIN | | | | |<-----------| (timed) | | | +---------+ unmute +---------------+ | | | | | | ban +---------------+ | | +---------------->| BANNED_ADMIN | | | |<----------------| (timed) | | | | unban +---------------+ | | | | Cross-track: | | MEMBER <--- promote/demote ---> ADMIN | | OWNER (immutable — channel creator) | +--------------------------------------------------+
Timed Bans & Auto-Unban
// Ban a member — transitions MEMBER → BANNED_MEMBER or ADMIN → BANNED_ADMIN
async updateChannelMemberBannedTime(
channelId: string,
userId: string,
bannedTime: string,
): Promise<ChannelMember> {
const unbanneTime = new Date();
const bannTime = new Date(bannedTime);
if (bannTime.getMinutes() > unbanneTime.getMinutes()) {
unbanneTime.setMinutes(
unbanneTime.getMinutes()
+ (bannTime.getMinutes() - unbanneTime.getMinutes()),
);
}
const status = await this.findOneChannelMemberStatus(channelId, userId);
if (status === 'MEMBER') {
return this.prisma.channelMember.update({
where: { userAndChannel: { userId, channelId } },
data: { role: 'BANNED_MEMBER', bannedTime, unbanneTime },
});
} else if (status === 'ADMIN') {
return this.prisma.channelMember.update({
where: { userAndChannel: { userId, channelId } },
data: { role: 'BANNED_ADMIN', bannedTime, unbanneTime },
});
}
}
// Cron: auto-unban expired members → restores MEMBER role
@Cron(CronExpression.EVERY_SECOND)
async handleUnbanneMember(): Promise<{ count: number }> {
const currentDate = new Date();
return this.prisma.channelMember.updateMany({
where: {
role: 'BANNED_MEMBER',
unbanneTime: { lte: currentDate },
},
data: { role: 'MEMBER', bannedTime: null, unbanneTime: null },
});
}
// Cron: auto-unban expired admins → restores ADMIN role
@Cron(CronExpression.EVERY_SECOND)
async handleUnbanneAdmin(): Promise<{ count: number }> {
const currentDate = new Date();
return this.prisma.channelMember.updateMany({
where: {
role: 'BANNED_ADMIN',
unbanneTime: { lte: currentDate },
},
data: { role: 'ADMIN', bannedTime: null, unbanneTime: null },
});
}
Timed Mutes & Auto-Unmute
Muting follows the same pattern as banning — the member's role transitions to MUTED_MEMBER or MUTED_ADMIN, and a cron job restores the original role when the mute expires.
// Mute a member — transitions MEMBER → MUTED_MEMBER or ADMIN → MUTED_ADMIN
async updateChannelMemberMutedTime(
channelId: string,
userId: string,
mutedTime: string,
): Promise<ChannelMember> {
const unmuteTime = new Date();
const muteTime = new Date(mutedTime);
if (muteTime.getMinutes() > unmuteTime.getMinutes()) {
unmuteTime.setMinutes(
unmuteTime.getMinutes()
+ (muteTime.getMinutes() - unmuteTime.getMinutes()),
);
}
const status = await this.findOneChannelMemberStatus(channelId, userId);
if (status === 'MEMBER') {
return this.prisma.channelMember.update({
where: { userAndChannel: { userId, channelId } },
data: { role: 'MUTED_MEMBER', mutedTime, unmuteTime },
});
} else if (status === 'ADMIN') {
return this.prisma.channelMember.update({
where: { userAndChannel: { userId, channelId } },
data: { role: 'MUTED_ADMIN', mutedTime, unmuteTime },
});
}
}
// Cron: auto-unmute expired members → restores MEMBER role
@Cron(CronExpression.EVERY_SECOND)
async handleUnmuteMember(): Promise<{ count: number }> {
const currentDate = new Date();
return this.prisma.channelMember.updateMany({
where: {
role: 'MUTED_MEMBER',
unmuteTime: { lte: currentDate },
},
data: { role: 'MEMBER', mutedTime: null, unmuteTime: null },
});
}
// Cron: auto-unmute expired admins → restores ADMIN role
@Cron(CronExpression.EVERY_SECOND)
async handleUnmuteAdmin(): Promise<{ count: number }> {
const currentDate = new Date();
return this.prisma.channelMember.updateMany({
where: {
role: 'MUTED_ADMIN',
unmuteTime: { lte: currentDate },
},
data: { role: 'ADMIN', mutedTime: null, unmuteTime: null },
});
}
Kick Members with Cross-Namespace Redirect
@SubscribeMessage('kickMember')
async handleKickMember(
@MessageBody() payload: CreateKickedMemberDto,
@ConnectedSocket() socket: Socket,
): Promise<void> {
const member = await this.channelService.findOneChannelMember(
payload.channelId,
payload.userId,
);
if (member && (member.role === 'MEMBER' || member.role === 'ADMIN')) {
const channel = await this.channelService.findOneChannel(payload.channelId);
const user = await this.usersService.findOne(payload.userId);
// Record the kick — prevents rejoin
await this.channelService.createKickedMember(payload);
// Remove from channel membership
await this.channelService.removeChannelMember(
payload.channelId,
payload.userId,
);
// Notify the channel
this.server
.to(payload.channelId)
.emit('onMessage', `${user.name} is kicked from channel: ${channel.channelName}`);
this.server.to(payload.channelId).emit('refrechMember');
// Force-redirect the kicked user via their socket
for (const [key, val] of this.connectedClientsInChannel) {
if (val === payload.userId) {
this.server.to(key).emit('redirectAfterGetKicked');
}
}
}
}
Blocked User Filtering in Channel Messages
async findAllChannelNonBlockedMessages(
channelId: string,
senderId: string,
): Promise<ChannelMessage[]> {
// Fetch the user's block list
const user = await this.prisma.user.findUnique({
where: { id: senderId },
include: { userThatBlock: {} },
});
const blockedUserIds = user.userThatBlock.map(
(blocked) => blocked.blockedUserId,
);
// Return messages excluding blocked users
return this.prisma.channelMessage.findMany({
where: {
channelId: channelId,
userId: { notIn: blockedUserIds },
},
orderBy: { created_at: 'asc' },
});
}
Conclusion
Building a production-ready chat system demands careful separation of concerns. The dual-layer architecture (DMs + Channels) with isolated Socket.io namespaces scales naturally, while the role-based moderation system with timed punishments provides the control needed for community management.
Key takeaways:
- Deterministic room IDs — SHA-256 hashed user ID pairs eliminate duplicate conversations without any lookup
- Namespace isolation — Separate gateways for DMs, channels, and social features keep concerns clean
- Cross-namespace notifications — Inject gateways to send alerts across Socket.io namespaces
- Role state machine — MEMBER ↔ MUTED/BANNED transitions preserve privileges after expiration
- Cron-based moderation — Automatic unban/unmute with original role restoration
- Server-side filtering — Blocked user messages filtered at the query level, not on the client
This architecture powers real-time communication for a multiplayer game platform, handling concurrent users across direct messages, group channels, and game invitations simultaneously.
Explore the Code
Check out the ft_transcendence repository on GitHub to see these patterns in a real-world multiplayer game platform.
Written by

Technical Lead and Full Stack Engineer leading a 5-engineer team at Fygurs (Paris, Remote) on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.