you are expert in C++, networking, backend/systems...
作成日: 2026年8月4日
使用モデル GPT-5.6 Thinking by Chat01
作成日: 2026年8月4日
使用モデル GPT-5.6 Thinking by Chat01
you are expert in C++, networking, backend/systems architect, AAA-scale multiplayer game backends, redis, connection between microservices
1-before help me to design backend game server, i want deep search about game GUNROX to know its UI design and how it works inside, and focus on UI controls like add friend, join clan, create game join game move maps..etc
after know everything about gunrox we can start
2- more info about game UI controls:
1- create game and waiting for players to join to start game
2- start random queue(match making)
3- send friend request, accept friend, add players to ignore list
4- there are fixed maps like "lost beach" "ice valley" total in my game like 8-10 maps
5- there are global list all players online in current map for example when login and im in "lost beach" map and it has 1k players online, i should get a list of 1k players usernames and their user_ids ( because usernames just for human display looking but user_ids is to sending messages like ["hi"][5 ( its user_id for player besho13)]
6- i use user_id is fixed and primary for each player creating account to use it to save player data inv and game history..etc, also used to send messages like ["hi"][target_id:5], so im thinking to use that if yes tell me if bad tell me too i need truth for AAA game
7- there are simple shop just go and buy items this happends much like every 10-15 minutes user buy items for start game.
8- there are trading system between 2 players peer-to-peer for trading items
9- there are global chat for each map private chat, trade-chat should send to all online players, game chats for each game..etc
9- there are notifications but this rarley like once a day from system for like tournament
3- notes:
1-please dont search for how GUNROX backend works i dont want like that i want best for latest tech use and best for scale AAA games just search GUNROX to know its features like add friend, clan system..etc
2- client is always C++ native desktop work for windows for now so im able to use anything not confined to web protocols
4- here is my microservices i designed
Protocol: HTTPS (REST/JSON)
Client-facing: Yes (short-lived — connection closes after login/register completes)
Responsibilities:
Register new account — email, username, password (Argon2id hash)
Login — verify email/password against Postgres
Receive device_fingerprint (SHA-256 hash, computed client-side) at login and store it bound to the session
Generate the session token — 256-bit CSPRNG (RAND_bytes), hex-encoded, opaque (no embedded user_id, no signature, no claims)
Write session to Redis: SET session:{token} {user_id, device_fp} EX 3600
Return {token, user_id} to client
Owns the users table (email, username, password_hash, status, failed_attempts, locked_until) — no other service touches this table
Rate-limit login attempts (per-IP and per-account, via Redis INCR/EXPIRE) — first line of defense against credential stuffing
Does NOT do: issue refresh tokens (deferred), talk to lobby_server/chat_server directly (no coupling — its job ends at the Redis write), hold any session state itself after responding.
Protocol: raw TCP, C++ Asio, custom binary framing
Client-facing: Yes (persistent connection, held for the whole play session)
Responsibilities:
Validate session on connect: GETEX session:{token} EX 3600 against Redis, compare device_fingerprint, reject on mismatch
Maintain local unordered_map<socket_fd, user_id> (+ reverse map) — rebuilt fresh on every connect/reconnect, never persisted
Create game (open a waiting room, hold slots for players to join)
Forward "start queue" requests to matchmaking_server
Friend system: send request, accept, decline, remove, add to ignore list
Clan actions: create, join, leave, invite, kick, promote
Shop: browse, purchase (writes to Postgres inventory/transactions)
Trade: initiate trade session, negotiate offer
..etc
Protocol: raw TCP, C++ Asio, custom binary framing
Client-facing: Yes (persistent, separate connection from lobby)
Responsibilities:
Validate session the same way as lobby_server (own independent GETEX call)
Maintain local unordered_map<socket_fd, user_id> (own copy, independent of lobby_server's)
Maintain local RAM cache built from presence_events subscription: map_members[map_name] -> set<user_id>, and per-player current_map / in_game / game_id
Route messages purely by user_id, never username: global/map chat, trade-chat broadcast, private DM ([text][target_user_id]), per-game chat (route by matching game_id between sender and recipient)
Filter broadcast delivery: skip any recipient whose local cache says in_game or offline
Enforce ignore list at delivery time (silently drop, no error to sender — matches the real Gunrox pattern)
..etc
Protocol: raw TCP, C++ Asio, custom binary framing
Client-facing: Yes (own dedicated connection — separated specifically so bulk roster payloads never contend with lobby_server's latency-sensitive shop/trade traffic)
when client is login in map for example lost_beach and has 1k online players presence_server should send a list of all 1k players usernames:user_ids
also when move to another map for example ice_valley and has 2k player so should send to client 2k usernames:user_ids, also if some players dc so should send to all players in that map players dc..etc
Protocol: internal-only TCP, C++ Asio, custom binary framing
Client-facing: No — only lobby_server talks to this
should matching players and tell lobby_server player2 player3 are matching and lobby_server starting game giving order to game_Server to start it or matchmaking_server send direct to game_server to start it still thinking about it
Protocol: raw TCP, C++ Asio, custom binary framing (already built)
Client-facing: Yes (only once matched — client connects here with {game_id, ticket})
its everything inside game for each game 2-10 players has instance object inside game_server to handle that game
Task: Design Redis + Local RAM Architecture for a AAA Game Backend
Context
I'm designing the backend for a AAA game in the data management field. The backend has 6 microservices, and some of them run as multiple scaled instances (e.g. chat_server1, chat_server2, etc). I need help designing:
The connection/data pattern between the 6 microservices
When to use local RAM vs when to use Redis, and how to use each
What data types to use in each case
How this holds up when scaling to multiple instances of the same service (e.g. chat_server1, chat_server2, ...)
Known requirements
After a successful login, the client gets a session_id to connect to lobby_server, chat_server, and presence_server. This session_id is saved in Redis with a 1 hour expiry, and it renews.
For game_server, login uses a one-time-use ticket with a 30 second expiry.
Each player has a status: online, offline, away, in_game, in_trade, in_shop.
Each player also has a current map (e.g. lost_beach, ice_valley, etc).
Why player status + map matters (this is the core of what I need solved)
Servers need to know a player's status and map because:
Chat example: if player1 sends ["hi all"] in global chat while in map lost_beach, chat_server needs to know all players who are online in map lost_beach and NOT in_game, so it can fan-out this message to all of them. This needs to be fast, with no lag.
Lobby example: lobby_server needs to know if player4's status is in_shop, in_game, or in_trade, so that player2 cannot send player4 a trade invite while player4 is in one of those states.
Multi-instance example: if there are 2 chat_servers — chat_server1 has player1, player2, player3 connected via TCP, and chat_server2 has player4, player5, player6 connected via TCP — how do we handle: (a) player1 sending a direct message to player5, who is on a different chat_server instance, and (b) player1 sending a global message ["hi all"] when players 1–6 are all in the same map and none are in_game, so chat_server1 needs to deliver to players 1–3 and chat_server2 needs to independently deliver to players 4–6.
What I need
Please design the full Redis pattern and data structures for all of this — including where pub/sub or any other Redis features should be used — plus the local RAM design for each server. For local RAM: each server (lobby, chat, etc.) needs, at minimum, an unordered_map to bind a player's TCP socket to their user_id (socket <-> user_id). Also design how Redis stores all logged-in player sessions with the 1 hour expiry and renewal described above.
Please think through this deeply and critically, and cover all the scenarios above — the chat fan-out case, the lobby status-check case, and the multi-instance routing case — so the Redis design and local RAM design are complete for each server.
I could verify the important product flows, although public material does not document every exact button or the current implementation behind it. I am treating these as visible product requirements, not as a model for your backend.
The main social flow appears to be:
textLogin ↓ World/social location ├─ Location player roster ├─ Location/lobby chat ├─ Private chat tabs ├─ Trade chat ├─ Player profile / squad / inventory ├─ Friends / ignore ├─ Clan ├─ Cash shop └─ Game lobby ├─ Browse open games ├─ Create game ├─ Join waiting room ├─ Random battle └─ Start match
Screenshots show a world-map/location screen with a right-side player roster, chat tabs below, top navigation buttons and Cash Shop access. The lobby shows rooms with fields such as game type, teams, creator, turn time and player count. Recent official news also confirms that the lobby is still being redesigned around selectable game modes and joinable game sessions. (gunrox.com)
Public GUNROX material confirms several chat contexts: lobby, private, clan, in-game and trade chat. An older player guide describes selecting a player from the roster to send a private message, private-message shortcuts, team chat inside a match and private messages continuing while someone is in a game or another activity. The official anti-toxicity update separately mentions private, clan, lobby, in-game and trade messages. (gunrox.com)
GUNROX has had random battle modes, including a documented 2v2 random battle mode. (gunrox.com)
Clan creation and invitations are also confirmed. Historically, clan creation appeared on the squad tab after purchasing the feature, followed by a creation form and clan icon upload. (gunrox.com)
Public location names include Beginner Quarter, Clover Quarter, Lost Beach, Junkyard, Mystic Forest, Memorial Boulevard and Ice Valley. The location is not merely a visual map: it affects available players, chat context and item progression. (gunrox.com)
For your game, I would preserve these concepts:
lost_beach, containing online players, chat and open rooms.Do not reproduce old GUNROX technical limitations. Reproduce the interaction model.
Your service separation is a good starting point, but I would change several important parts before calling it AAA-ready.
user_idUsing a fixed immutable ID is correct.
Use:
cppusing UserId = std::uint64_t;
In PostgreSQL it can be a positive BIGINT. Never route or authorize using usernames. Usernames are display properties and may change.
For better privacy, consider having two IDs:
textaccount_id Internal PostgreSQL primary key public_player_id Opaque identifier exposed to clients
A sequential ID is not automatically insecure, but it makes account enumeration easier. Authorization must never depend on IDs being difficult to guess.
Inventory, currency, friends, ignores, clans, completed trades and game history belong in PostgreSQL.
Sessions, online state, routing, temporary rooms, matchmaking tickets, rate limits and short-lived tickets are appropriate Redis data.
Socket bindings, local player collections, owned room actors and active game instances should live in RAM.
These are not mutually exclusive concepts:
textonline / offline / away in_game / in_trade / in_shop current map
Use separate dimensions:
cppenum class Connectivity : std::uint8_t { Offline, Online, Away }; enum class Activity : std::uint8_t { Idle, WaitingRoom, Matchmaking, Shop, Trade, Game }; struct PlayerRuntimeState { UserId user_id; Connectivity connectivity; Activity activity; std::uint16_t social_map_id; std::uint64_t room_id; std::uint64_t trade_id; std::uint64_t game_id; std::uint64_t version; std::uint64_t lease_expiry_ms; };
A player can therefore be:
textconnectivity = Away activity = Shop map = Lost Beach
This is substantially safer than adding more values to one giant status enum.
Lobby, matchmaking, presence and game servers must not independently write player state.
Your presence_server should contain an internal Player State authority. Other services send commands such as:
textTryEnterShop TryEnterTrade TryJoinRoom TryEnterQueue TryEnterGame LeaveGame MoveSocialMap
The state authority validates transitions and publishes resulting changes.
This prevents:
textLobby says player is in trade Matchmaking says player is queued Game server says player is in game
from happening simultaneously.
It currently owns:
This is acceptable for an early implementation, but internally separate these into modules with clear ownership:
textLobbyGateway RoomCoordinator SocialService EconomyService TradeCoordinator MatchmakingFrontend
They can initially run in the same process. Splitting code ownership now makes later service extraction much easier.
Your current design:
textClient → lobby TCP Client → chat TCP Client → presence TCP Client → game TCP when matched
will work, but an AAA-style architecture normally benefits from one persistent edge connection:
textClient → Edge Gateway ├─ lobby messages ├─ chat messages └─ presence messages Client → Game Server after assignment
A single gateway gives you:
You can still keep lobby, chat and presence as independent internal services.
If you retain the three sockets, the rest of the design below still works.
socket_fd as the permanent connection identityFile descriptors are reused after sockets close.
Use:
cppusing ConnectionId = std::uint64_t; std::unordered_map<ConnectionId, std::shared_ptr<ClientConnection>> connections_by_id; std::unordered_map<UserId, std::weak_ptr<ClientConnection>> connection_by_user;
The socket lives inside ClientConnection. Generate a monotonically increasing or random ConnectionId.
text┌──────────────────┐ │ auth_server │ │ HTTPS + TLS │ └────────┬─────────┘ │ session ▼ ┌──────────────┐ ┌──────────────────────┐ │ C++ client │───────▶│ Edge/Gateway layer │ │ TCP + TLS │ │ persistent connection│ └──────┬───────┘ └──────────┬───────────┘ │ │ │ game ticket ├──────── lobby/room │ ├──────── chat ▼ ├──────── presence/state ┌────────────────┐ └──────── social/economy │ game_server │ │ game instances │◀──── game allocator/director └────────────────┘ ▲ │ matchmaking_server Shared infrastructure: PostgreSQL durable authoritative data Redis Cluster ephemeral state, leases, routes, queues, caches NATS low-latency service events and realtime routing
For internal service communication I recommend:
textRequest/reply commands: gRPC/Protobuf or NATS request/reply Realtime fan-out: NATS Core Reliable events/jobs: NATS JetStream or Redis Streams Ephemeral state/cache: Redis Durable business data: PostgreSQL
NATS subjects give location-independent publish/subscribe routing. Core NATS is ephemeral, while JetStream adds persisted streams, sequence numbers and acknowledged consumers. (NATS Documentation)
A Redis-only version is possible, but for serious scale I would not make Redis handle both all state and all internal messaging.
Store these durably:
textusers player_profiles username_history inventory_items wallet_balances shop_transactions friend_requests friendships ignore_edges clans clan_members completed_trades trade_transaction_items game_results game_history notifications
Store:
textsessions service connection routes short-lived connection tickets one-time game tickets runtime player state room ownership leases room snapshots matchmaking tickets and indexes rate limits idempotency windows cached profile cards temporary trade state
Store:
textactive sockets send queues local user-to-connection bindings local map membership local chat eligibility room actors owned by this instance matchmaking indexes owned by this instance active trade actor state active game simulations recent message deduplication IDs
Redis is not a substitute for local RAM on every message.
A map-chat message must not perform:
textSMEMBERS map GET player status × 1,000 GET route × 1,000 PUBLISH × 1,000
That architecture will be slow and expensive.
Redis provides bootstrap, coordination and recovery. RAM handles the hot path.
Your 256-bit CSPRNG token is good. Hex encoding is valid, although Base64URL is shorter.
Do not store the raw token in the Redis key. Store its SHA-256 digest:
textClient token: random 256-bit secret Redis key: sess:{SHA256(token)}
If Redis keys are accidentally logged or inspected, the visible value is not itself the bearer token.
Use a Redis string containing a compact Protobuf or MessagePack object:
textKey: sess:{9be3...} Type: STRING Value: SessionRecord { user_id device_fingerprint auth_epoch issued_at_ms absolute_expiry_ms } TTL: 3600 seconds
A string is important because GETEX reads a string and renews its expiry in one operation. (Redis)
Pseudo-operation:
textGETEX sess:{token_hash} EX 3600
Then:
absolute_expiry_ms;A one-hour sliding expiry can live forever if the token is stolen and continuously used.
Use both:
textidle timeout: 1 hour absolute lifetime: for example 24 hours
A future refresh-token design can replace this.
A SHA-256 device fingerprint computed by the client is not a cryptographic proof of device identity. A modified client can copy or forge it.
Use it as:
textrisk signal account-security telemetry concurrent-session indicator
Do not use it as the primary protection for a stolen bearer token. Bearer tokens should ideally be audience-restricted so that leakage from one service has limited value. (OWASP Cheat Sheet Series)
Instead of sending the master session token to lobby, chat and presence:
text1. Client authenticates master session. 2. Gateway/auth exchange issues: - lobby connection ticket - chat connection ticket - presence connection ticket 3. Each is valid for 30–60 seconds and one use. 4. Service consumes it with GETDEL.
textct:{ticket_id} STRING ConnectionTicket EX 60
GETDEL atomically returns and deletes a string, making it suitable for one-time tickets. (Redis)
If you retain the shared session token for now, it is workable, but put all connections behind TLS and implement immediate revocation.
Do not call GETEX on every packet.
For a persistent connection:
textheartbeat: every 10–20 seconds route lease refresh: every 10–15 seconds session renewal: every 5–10 minutes
Only one component should renew the master session. With an edge gateway, that is the gateway.
On logout or account ban:
textDEL sess:{token_hash} INCR auth_epoch:{user_id} PUBLISH session.revoke user_id
Each connected server closes matching connections. Because live Pub/Sub can lose messages during disconnection, servers must also compare auth_epoch periodically or when reconnecting.
Use one ticket per player:
textgt:{ticket_id} STRING { user_id game_id server_id connection_nonce issued_at_ms } EX 30 NX
The client connects to the assigned game server and sends:
textgame_id ticket_id
The game server runs:
textGETDEL gt:{ticket_id}
Then verifies:
textticket.game_id == requested_game_id ticket.server_id == this_server_id ticket.user_id is expected by game instance
A consumed ticket cannot be replayed.
For reconnects, mint a new reconnect ticket rather than restoring the previous ticket.
Every client-facing service instance gets an immutable node ID:
textchat-eu-17 lobby-eu-08 presence-eu-05
textroute:chat:{12345} STRING { node_id connection_id connection_epoch } EX 45 route:lobby:{12345} route:presence:{12345}
Keep the route and epoch in the same Redis hash slot:
textconn_epoch:chat:{12345} route:chat:{12345}
On connection:
textepoch = INCR conn_epoch:chat:{12345} SET route:chat:{12345} {node_id, connection_id, epoch} EX 45
Refresh the route while the connection is alive.
On disconnect, do not blindly delete the route. The player may already have reconnected to another node. Use a Redis Function:
textDelete route only when stored epoch == disconnecting epoch
Redis Functions execute atomically. In Redis Cluster, related keys must share a slot; hash tags such as {12345} allow this. (Redis)
textpstate:{12345} HASH connectivity online activity idle map_id 3 room_id 0 trade_id 0 game_id 0 version 184 owner_node state-eu-4 lease_expiry_ms 1785860400000 EX 60
Redis is a recovery snapshot here. The current owner keeps the state in RAM.
Allowed transitions:
textIdle → Shop → Idle Idle → Trade → Idle Idle → WaitingRoom → Game → Idle Idle → Matchmaking → Game → Idle Idle → another social map
Rejected transitions:
textGame → Trade Trade → Matchmaking Shop → Game without leaving shop WaitingRoom → Trade
Every successful transition increments version.
All commands should support optimistic concurrency:
textTryEnterTrade { user_id expected_state_version trade_id }
Response:
textSuccess { new_version } Conflict { current_state, current_version }
The version prevents delayed commands from changing newer state.
Do not have every presence-server instance independently mutate every map.
Partition map actors by:
text(region, social_map_id, map_shard)
For example:
text(eu, lost_beach, 0) → presence-map-owner-2 (eu, ice_valley, 0) → presence-map-owner-5
A single actor serializes joins, leaves and state changes for that map shard.
With only 8–10 maps and roughly 1,000–2,000 players per map, this is straightforward. If a map becomes much larger, create multiple invisible social shards:
textlost_beach/0 lost_beach/1 lost_beach/2
The UI may continue to display “Lost Beach.”
Keep related keys together:
textmap:{eu:lost_beach:0}:alive ZSET member = user_id score = lease_expiry_ms map:{eu:lost_beach:0}:roster HASH field = user_id value = packed PlayerCard map:{eu:lost_beach:0}:seq STRING integer
PlayerCard might contain:
cppstruct PlayerCard { UserId user_id; std::string username; std::uint16_t level; std::uint64_t clan_id; std::uint32_t profile_version; };
A sorted set is useful because stale members can be removed by expiry score:
textZREMRANGEBYSCORE map:{...}:alive -inf now_ms
Do not refresh the roster to every client on every heartbeat.
When entering Lost Beach:
textServer → RosterSnapshotBegin { map_id, snapshot_seq, total_count } Server → RosterSnapshotChunk { snapshot_seq, entries[100..250] } Server → RosterSnapshotEnd { snapshot_seq }
After that:
textRosterDelta { seq, joined[], left[], updated[] }
If the client receives sequence 105 after 103:
textClient → RequestRosterResync
This protects you against lost messages and reconnect races.
A roster of 1,000 IDs and usernames is only tens of kilobytes with a binary protocol, so the initial snapshot is reasonable. Chunking prevents one large frame from blocking other traffic.
Rather than sending one network packet for each join or leave, aggregate for approximately 50–200 milliseconds:
textjoined: [14 players] left: [9 players] updated:[3 players]
This substantially reduces syscalls during reconnect storms.
Each chat-server instance does not need a complete list of every player in the world.
It needs:
textall locally connected chat users their current map and activity local users grouped by map local ignore filtering data route cache for recent DM targets
The presence service owns the global roster. The chat node only fans out to its own connected sockets.
cppstruct LocalChatUser { UserId user_id; ConnectionId connection_id; std::uint64_t connection_epoch; std::uint16_t map_id; Activity activity; std::uint64_t game_id; std::shared_ptr<const IgnoreSet> ignored_users; }; std::unordered_map<ConnectionId, LocalChatUser> by_connection; std::unordered_map<UserId, ConnectionId> by_user; std::unordered_map<MapId, LocalUserSet> local_users_by_map; std::unordered_map<MapId, LocalUserSet> local_chat_eligible_by_map; std::unordered_map<UserId, LocalUserSet> local_recipients_ignoring_sender;
The last inverse index makes broadcast filtering efficient:
textsender 123 is ignored by local users {9, 17, 28}
No Redis lookup is needed per recipient.
Suppose:
textchat_server1: users 1, 2, 3 chat_server2: users 4, 5, 6 all are in Lost Beach
Player 1 sends:
text["hi all"]
Flow:
text1. chat_server1 validates rate limit and sender state. 2. chat_server1 publishes one envelope: subject: chat.map.eu.lost_beach.0 { message_id, sender_id: 1, sender_name, map_id, text, created_at } 3. chat_server1 receives the subject and sends to local users 1–3. 4. chat_server2 receives the same subject and sends to local users 4–6. 5. Each node skips: - users no longer in that map; - users currently in_game, if that is your rule; - users who ignore sender 1; - slow/disconnected sockets.
This produces:
text1 broker publish N chat nodes receiving N local iteration loops 6 final socket writes
It does not produce six Redis messages.
A chat node should subscribe to a map subject only while it has local users in that map.
textFirst local user enters Lost Beach: subscribe chat.map.eu.lost_beach.0 Last local user leaves Lost Beach: unsubscribe chat.map.eu.lost_beach.0
Player 1 on chat_server1 sends to player 5 on chat_server2.
text1. chat_server1 checks local route cache. 2. On miss, GET route:chat:{5}. 3. Result: node_id = chat_server2 epoch = 78 4. Publish to: chat.node.chat_server2 5. Envelope contains: target_id = 5 expected_connection_epoch = 78 6. chat_server2 verifies: - user 5 is local; - connection epoch is 78; - user 5 does not ignore user 1. 7. Deliver to user 5.
If user 5 reconnected between steps:
textchat_server2 returns ROUTE_STALE chat_server1 invalidates cache chat_server1 resolves route again once
Do not retry indefinitely.
You could use:
textSPUBLISH chat:{map:eu:lost_beach:0} payload SSUBSCRIBE chat:{map:eu:lost_beach:0} SPUBLISH chat:{node:chat-eu-17} payload
Redis Pub/Sub is at-most-once: disconnected subscribers miss messages. Sharded Pub/Sub limits propagation to the relevant Redis shard and scales better than global cluster Pub/Sub. (Redis)
That is acceptable for transient map chat.
It is not acceptable for:
textcompleted purchases trade commits friend acceptance match assignments permanent notifications
Use durable state plus Streams/JetStream for those.
Every connection needs a bounded outbound queue:
textmax queued bytes max queued chat messages oldest-message age
For a slow client:
text1. Coalesce roster updates. 2. Drop nonessential typing/presence events. 3. Drop old chat messages if product permits. 4. Disconnect if the queue remains over limit.
Never let one slow socket block an Asio I/O thread.
Since a game server already owns all 2–10 game connections, game chat should normally be handled by the game server.
Advantages:
The game server can asynchronously send chat records to moderation/logging storage.
Keeping game chat in chat_server is possible, but it creates an unnecessary dependency.
Do not implement:
textGET target status if idle: send invite
That has a race:
textLobby reads target = idle Target enters matchmaking Lobby sends trade invite
Use the local state cache only for an early UI or performance rejection.
The authoritative flow is:
text1. Player 2 requests trade with player 4. 2. Lobby performs quick local-cache check. 3. Lobby sends CreateTradeInvite to TradeCoordinator. 4. TradeCoordinator asks Player State authority to validate target. 5. State authority atomically checks current state/version. 6. If allowed, invite is created. 7. A targeted event is routed to player 4.
On acceptance:
text1. Reserve lower user_id first. 2. Reserve higher user_id second. 3. Both must transition Idle → Trade. 4. If the second reservation fails, release the first. 5. Create trade actor.
This deterministic ordering prevents circular reservation deadlocks.
Do not attempt to atomically update arbitrary player A and player B keys with one Redis Cluster script. Arbitrary user keys normally live in different hash slots. Redis transactions and scripts generally require related keys to be in the same slot. (Redis)
Cross-player coordination belongs in a service-level coordinator.
Although the UI looks peer-to-peer, the transaction must be server-authoritative.
cppstruct TradeSession { TradeId trade_id; UserId player_a; UserId player_b; Offer offer_a; Offer offer_b; std::uint64_t revision; bool confirmed_a; bool confirmed_b; TradeState state; };
Every offer modification:
textincrements revision clears both confirmations
Confirmation includes the exact revision:
textConfirmTrade { trade_id, revision }
A stale confirmation is rejected.
texttrade:{trade_id} STRING packed TradeSessionSnapshot EX 900
This is for recovery, not final ownership.
When both players confirm the same revision:
textBEGIN lock involved inventory rows in deterministic item_id order verify every item still belongs to its offered owner verify every item is tradable verify currency balances verify trade_id has not already committed transfer item ownership transfer currency insert completed trade insert trade line items insert outbox events COMMIT
Use an idempotency constraint on trade_id.
PostgreSQL provides row locking and serializable isolation, but applications using serialization must be prepared to retry serialization failures. (PostgreSQL)
A Redis failure must never create or duplicate an inventory item.
Your purchase frequency is low. The shop is not a Redis-performance problem.
Cache:
textshop catalog item definitions price versions availability windows per-user purchase rate limits
Every purchase request includes:
textrequest_id catalog_version item_id quantity expected_price
Server transaction:
textverify catalog version and server-side price lock/update wallet create inventory item insert purchase transaction insert outbox event commit
The request_id must have a unique constraint so retries cannot charge twice.
Never accept price, rarity, item stats or ownership from the client.
Possible tables:
sqlfriend_requests ( request_id, sender_id, receiver_id, status, created_at ); friendships ( lower_user_id, higher_user_id, created_at, PRIMARY KEY (lower_user_id, higher_user_id) ); ignore_edges ( owner_user_id, ignored_user_id, created_at, PRIMARY KEY (owner_user_id, ignored_user_id) ); clans ( clan_id, name, owner_user_id, version ); clan_members ( clan_id, user_id, role, joined_at, PRIMARY KEY (clan_id, user_id) );
When a user connects to chat:
textload their ignore list store it in an unordered_set<UserId>
On ignore-list modification:
text1. Commit PostgreSQL. 2. Publish IgnoreListChanged { user_id, version }. 3. The chat node hosting that user reloads or applies the delta.
Do not query PostgreSQL or Redis for ignore status on each message.
Friend online-state notifications should be derived from presence events, not from polling.
Each room has one authoritative owner instance:
textroom_id → lobby/room node
Local actor:
cppstruct Room { RoomId room_id; UserId creator_id; RoomState state; std::uint16_t social_map_id; std::uint16_t battle_map_id; std::uint8_t capacity; std::vector<RoomMember> members; std::uint64_t version; std::uint64_t fencing_token; };
textroom:{room_id}:state STRING packed Room room:{room_id}:owner STRING {node_id, fencing_token} EX 15 room-index:{eu:lost_beach}:open ZSET score = creation_time member = room_id
The room actor serializes joins. It checks capacity in RAM and snapshots changes to Redis.
Do not allow every lobby instance to independently execute:
textSCARD room-members SADD room-members user
Two simultaneous joins can otherwise overbook the last slot.
text1. Client sends JoinRoom(room_id). 2. Receiving lobby resolves room owner. 3. Request routes to room-owner node. 4. Room actor checks: - room is Open; - player state is Idle; - player is not already in another room; - room has capacity; - level/rank/password restrictions. 5. Player state becomes WaitingRoom. 6. Room member is added. 7. RoomChanged is broadcast to interested lobby nodes.
textOpen ↓ Starting ↓ Reserve all member runtime states ↓ Request game-server allocation ↓ Create game instance ↓ Mint one game ticket per player ↓ Send assignments ↓ InGame
If allocation fails:
textStarting → Open release player reservations
Use a unique start operation ID so retries cannot create two game instances.
Your matchmaking server should not tell the lobby server to construct and start a game.
Use:
textLobby/Frontend ↓ create ticket Matchmaking ↓ match proposal Match Director ↓ allocate Game Allocator ↓ Game Server ↓ assignment/tickets Lobby/Gateway notifies clients
This is also the pattern used by Open Match: the frontend creates tickets; the match function proposes matches; a Director fetches matches, obtains dedicated game-server allocations and sets player assignments. (Open Match)
Agones is an available option for maintaining and allocating warm dedicated-game-server fleets on Kubernetes, but it is not mandatory for your C++ backend. (Agones)
textmm-ticket:{ticket_id} HASH user_id party_id region mode rating map_preferences enqueue_ms version EX 900 mm-user:{user_id} STRING ticket_id EX 900 mm:{eu:ranked_2v2}:queue ZSET score = enqueue_ms member = ticket_id
Additional rating indexes:
textmm:{eu:ranked_2v2}:rating ZSET score = rating member = ticket_id
All queue-related keys for a mode/region should use the same Redis hash tag where an atomic reservation is required.
textticket_id → ticket rating bucket → ordered ticket collection party_id → party user_id → active ticket reserved ticket IDs
Redis supports restart recovery; the local indexes provide fast matching.
When candidates are selected:
text1. Mark candidates Reserved with reservation ID and TTL. 2. Confirm none have cancelled or changed state. 3. Produce MatchFound. 4. Remove from queue. 5. Ask allocator for a server.
Every operation must be idempotent.
auth_servertextNo persistent player state Argon2 worker pool small local abuse counters as optional front cache Redis/PostgreSQL connection pools
lobby_servertextconnection_id → LobbyConnection user_id → connection_id local connected-user state cache rooms owned by this instance room subscriptions pending request IDs friend/clan summary caches for local users bounded outbound queues
Lobby-state caches are for display and quick rejection, not final authorization.
chat_servertextconnection_id → ChatConnection user_id → connection_id map_id → local connected users map_id → local eligible recipients user_id → ignore set sender_id → local recipients ignoring sender target route cache recent message ID deduplication rate-limit buckets bounded outbound queues
presence_servertextconnection_id → PresenceConnection user_id → connection_id user_id → PlayerRuntimeActor map shard → MapActor map shard → locally connected edge nodes per-map sequence number short delta replay ring buffer ownership leases and fencing tokens
matchmaking_servertextticket_id → MatchTicket user_id → ticket_id party_id → party queue/mode/rating indexes reservation_id → candidates recently completed operation IDs
game_servertextgame_id → GameInstance user_id → game_id expected players connected players turn/action sequence reconnect slots game-level outbound buffers periodic snapshots or action log
Game state should be authoritative in the GameInstance, not Redis. Redis may hold server assignment and recovery metadata.
textmap chat trade-chat broadcast typing indicators noncritical presence hints live room-list changes cache invalidation hints
Redis Pub/Sub messages are discarded after delivery and missed while subscribers are disconnected. Sharded Pub/Sub is preferable on Redis Cluster for high-volume subjects. (Redis)
textfriend accepted clan membership changed purchase completed trade completed game result submitted notification created audit/moderation processing reliable retryable jobs
Redis Streams are append-only logs with IDs and consumer-group support. (Redis)
Be careful: a single consumer group distributes messages between consumers. It does not broadcast every event to every chat node. For realtime broadcast, use Pub/Sub/NATS plus snapshot/version recovery.
Keyspace notifications use Pub/Sub, can be missed, and in a Redis Cluster are node-specific rather than automatically broadcast from every node. (Redis)
Do not depend on an expiry notification to mark a player offline. Use leases and explicit expiry checks.
Redis Cluster replication is asynchronous and can lose acknowledged writes during some failovers. (Redis)
Therefore:
Safe consequences of Redis loss:
textplayer must reconnect room must be rebuilt presence temporarily disappears matchmaking ticket must be recreated chat message is lost
Unacceptable consequences:
textinventory item disappears currency returns after purchase trade commits twice game reward duplicates clan ownership changes
The second category must remain PostgreSQL-authoritative.
textPlayer 1 ↓ chat_server1 ↓ GET route:chat:{5} Redis: player 5 is on chat_server2, epoch 44 ↓ publish chat.node.chat_server2 chat_server2 ↓ verify epoch + ignore list Player 5
Redis operations:
textUsually zero due to route cache At most one GET on route-cache miss One broker publish
No global player scan.
textPlayer 1 ↓ chat_server1 ↓ one publish to chat.map.eu.lost_beach.0 ├──────── chat_server1 → local players 1,2,3 └──────── chat_server2 → local players 4,5,6
Each node already knows its local users’ map and activity from presence-state events.
Redis operations per chat message:
textzero state lookups zero roster scans zero per-recipient route lookups
That is the architecture you want.
For lobby/chat/presence traffic:
textTCP + TLS 1.3 length-prefixed binary framing Protobuf or a carefully versioned custom schema request_id for request/response correlation message_id for deduplication protocol version negotiation strict maximum frame size bounded send queues timeouts and heartbeats
A useful header:
cppstruct FrameHeader { std::uint32_t payload_size; std::uint16_t protocol_version; std::uint16_t message_type; std::uint64_t request_id; std::uint64_t sequence; };
Do not add your own message HMAC when TLS already provides transport integrity, unless you have a separate protocol-level requirement.
Use Asio strands or actor ownership so each connection or room actor is mutated by one execution context. Avoid a single global mutex around all sockets or all maps.
For broadcast, encode the payload once:
cppauto packet = std::make_shared<const ByteBuffer>(encode(message)); for (ConnectionId id : recipients) { connections[id]->enqueue(packet); }
Do not serialize the same message 1,000 times.
I would implement your backend with these decisions:
Keep a stable 64-bit user_id; optionally expose a different opaque public player ID.
Replace one status enum with connectivity + activity + map_id.
Make presence/player-state the sole authority for runtime-state transitions.
Keep PostgreSQL authoritative for inventory, currency, social relationships, clans, trades and results.
Keep Redis authoritative only for recoverable ephemeral coordination.
Use local RAM for every hot-path socket, membership and fan-out operation.
Route DMs using user_id → chat node leases.
Route map chat with one map-subject publish; every chat node fans out only to its local recipients.
Send initial roster snapshots with sequence numbers, then batched deltas.
Put game chat in the game server.
Use a game allocator/director between matchmaking and game servers.
Make trades appear peer-to-peer while remaining fully server-authoritative.
Use Redis Pub/Sub only for disposable realtime delivery; use Streams/JetStream plus durable state for important events.
Prefer a single TLS edge connection instead of three client-facing persistent connections.
Treat the device fingerprint as a risk signal, not as a security secret.
This design comfortably handles the 1,000–2,000-player social-map sizes you described and scales horizontally without requiring Redis access for each chat recipient or every UI update.