A technical deep dive into the nested group matchmaking engine, bot injection system, and game state manager powering Beta Gamer, a multi-tenant Games as a Service API built solo from Cameroon.
This is the technical post I promised in the previous one.
If you haven't read the story behind Beta Gamer yet, the short version is: I built a Games as a Service API that lets any developer embed real-time multiplayer games into their product with a single integration. 10 games. Multi-tenant. WebSocket-based. Built solo from Scratch.
This post is about three specific engineering problems I had to solve that I hadn't seen clearly documented anywhere else.
Problem 1: A Matchmaking Engine That Thinks in Nested Groups
Most matchmaking systems think in players. Two players join a queue, their ratings are close enough, they get matched. Simple.
That works for 1v1. It falls apart the moment you need group or team games like battle royale, or anything where the structure of a match is more complex than two individuals facing each other.
I needed a different mental model entirely.
The structure I landed on is a nested array:
[
[player1, player2], // group 1
[player3, player4], // group 2
[player5], // group 3
]
The outer array is the match. The inner arrays are the groups. The engine doesn't care how many groups there are or how many players are in each. It just reads the structure and executes.
This means:
- 1v1 Chess is
[[p1], [p2]] - 4-player Ludo is
[[p1], [p2], [p3], [p4]] - 2v2 team game is
[[p1, p2], [p3, p4]] - 2v2v2 three-way team game is
[[p1, p2], [p3, p4], [p5, p6]] - A game with 100 solo players is 100 arrays of one player each
One data structure. Zero special cases. Every game type falls out of configuration, not separate codepaths.
Each game defines its own rules via a config:
interface MatchConfig {
groupMinSize: number; // minimum players per group
groupMaxSize: number; // maximum players per group
minGroups: number; // minimum groups needed to start a match
maxGroups: number; // maximum groups per match
groupWaitMs: number; // how long to wait for a group to fill
lobbyWaitMs: number; // how long to wait for the pool to fill
}
A fixed-size game sets groupMinSize === groupMaxSize. A flexible game sets them differently and the match starts when the minimum is met.
Two-Stage Pipeline
The matchmaker runs in two stages.
Stage 1 - Queue to Group. Players join a queue. The matchmaker collects them into forming groups. When a group hits groupMaxSize it promotes immediately. When groupWaitMs fires, any group at or above groupMinSize promotes. Groups below minimum wait or dissolve.
Player joins queue
|
Assigned to forming group
|
Group hits maxSize? -> Promote immediately
|
Timer fires -> group >= minSize? -> Promote
-> below minSize? -> Wait or dissolve
Stage 2 - Group to Match. Promoted groups enter a pool. When the pool reaches maxGroups, the match fires immediately. When lobbyWaitMs fires, if the pool has at least minGroups, the match fires with whatever is available.
Group promoted -> enters pool
|
pool.length >= maxGroups? -> Fire match immediately
|
lobbyWaitMs fires -> pool.length >= minGroups? -> Fire match
-> Wait
This two-stage design is what makes flexible group sizes possible. A lobby of 4 that only fills 3 does not cancel. It checks against groupMinSize and proceeds if the threshold is met.
One Critical Boundary
The matchmaker answers exactly one question: who plays against whom?
It assembles Array<Array<Player>> and hands it to the game engine. What happens inside the match, turn order, whether a whole group moves together or players alternate individually, how simultaneous moves are resolved, that is the game engine's responsibility entirely. The matchmaker does not know and does not need to know.
This separation is intentional. A Chess engine and a Ludo engine have completely different turn structures. The matchmaker serving both of them should be agnostic to both.
Tenant (Developers) Hooks
Tenants on hosted matchmaking can bypass either stage:
-
submitGroup(game, group, config)- skip the queue, inject a pre-formed group directly into the pool -
startMatch(game, groups, config)- skip the pool, force a match to start immediately
// Tenant submits a pre-formed group of their own players
matchmaker.submitGroup('chess', {
id: 'group-abc',
players: [{ playerId: 'u1', username: 'Alice', ... }],
createdAt: Date.now(),
}, chessConfig);
// Tenant forces two groups into a match immediately
matchmaker.startMatch('chess', [group1, group2], chessConfig);
The matchmaker does not care how the groups were formed on the tenant's side. It just executes.
Problem 2: Injecting Bots Into Live WebSocket Sessions via REST
This one took me longer than I expected.
The problem: a tenant has a hosted session in a pending state. Not enough real players are online. They want to fill the empty slots with bots. They call a REST endpoint to trigger this. But the game room might already exist as a live in-memory WebSocket session on the server.
A REST handler and a WebSocket server are two different runtime contexts. The REST handler can write to the database. It cannot directly reach into the socket server and mutate a live room.
The solution is an internal event bus.
// REST endpoint - bot injection handler
if (room) {
socketEventbus.dispatch('bot:inject', {
game,
roomId: room.id,
botPlayer: {
id: botPlayer.id,
socketId: '',
username: botPlayer.displayName,
isBot: true,
difficulty: botPlayer.difficulty,
},
});
}
// Socket server - listens for injection events
socketEventbus.on('bot:inject', (payload: BotInjectPayload) => {
const room = multiGameStateManager.getRoom(payload.roomId);
if (!room) return;
room.players.push({
id: payload.botPlayer.id,
socketId: payload.botPlayer.socketId,
username: payload.botPlayer.username,
isBot: true,
difficulty: payload.botPlayer.difficulty,
});
// notify existing players that a bot joined
});
The REST layer and the WebSocket layer are fully decoupled. Neither knows about the other's internals. The event bus is the only bridge.
One detail that matters: if the room does not exist in memory yet, the session is pending and no one has connected, the endpoint only updates the database. When players connect and the room initialises, it reads the updated player list from the database and the bot is already there. No injection event needed.
Tenants can also bring their own bots. If the request body contains a playerId, that specific bot is used. If not, the platform picks from the tenant's configured bot pool automatically.
Problem 3: Game State That Survives Everything
Reconnection
When a player disconnects, the game does not end. The room switches to paused and a 60-second reconnection window opens.
handleDisconnect(socketId: string, reconnectWindow = 60_000): void {
// pause room, start countdown
if (room.state === 'playing') room.state = 'paused';
const timeout = setTimeout(() => this.handlePlayerQuit(playerId), reconnectWindow);
this.disconnectedPlayers.set(playerId, { roomId, timeout });
}
handleReconnect(playerId: string, newSocketId: string): boolean {
// clear countdown, remap socket, resume game
clearTimeout(disconnectData.timeout);
player.socketId = newSocketId;
if (room.state === 'paused') room.state = 'playing';
return true;
}
If the window expires, the player is treated as having quit. With remaining players, the game continues and the turn index adjusts. With one player left, that player wins. With zero, the room is deleted.
Singleton Across Hot Reloads
Next.js re-evaluates modules on every file change in development. A regular module-level singleton gets destroyed on every hot reload and all active rooms are lost.
The fix is attaching the singleton to globalThis:
const g = globalThis as any;
if (!g.__multiGameStateManager) {
g.__multiGameStateManager = new MultiGameStateManager();
}
export const multiGameStateManager: MultiGameStateManager = g.__multiGameStateManager;
First load creates the instance. Every subsequent reload finds it already there and reuses it. Rooms survive hot reloads completely.
AFK Timer Cleanup
Every room runs an AFK timer. If a player stalls their turn, they get forfeited. But when a game ends normally, if the AFK timer is not cleared, it fires later into a room that no longer exists.
endGame(roomId: string, winner?: string): void {
room.state = 'finished';
room.winner = winner;
clearAfkTimer(roomId); // always clear before deleting
setTimeout(() => this.deleteRoom(roomId), 5_000);
}
Clear the timer first. Then schedule the cleanup. Order matters.
What I Haven't Built Yet
Everything above is turn-based. One player or group acts, then the next. The server is the authority on state because moves are sequential.
Simultaneous move games, football, shooters, anything where every player acts at the same time, are a different problem entirely. You need a tick-based system that collects moves within a window, validates each against the same snapshot of game state, resolves conflicts, then broadcasts the result atomically.
The nested group structure already supports it. [[p1, p2], [p3, p4]] works for a 2v2 shooter exactly as it does for a 2v2 turn-based game. The matchmaker does not change. The game engine does.
That is the next frontier. When I solve it, there will be another post.
Beta Gamer docs: https://beta-gamer.onrender.com/docs/overview
npm: @beta-gamer/react and @beta-gamer/react-native
Top comments (0)