Architecture de serveur dédié : 48 joueurs par conteneur
Nous avons séparé le serveur de jeu autoritaire du backend, reconstruit le matchmaking et l'allocation de salles, puis mesuré le coût réel par joueur simultané.
Nuit de playtest : 40 joueurs, un processus, un crash
En février de l'année dernière, nous avons organisé un playtest fermé avec 40 personnes, un vendredi à 21 h 00. Le jeu reposait sur des matchs à 8 joueurs et, ce soir-là, cinq matchs tournaient en même temps. À 23 h 40, un tiers des joueurs s'est déconnecté d'un coup. Il ne restait qu'une ligne : OutOfMemoryException, avec cinq valeurs de matchId différentes en dessous.
La cause n'était pas un bug, c'était l'architecture. Cinq matchs vivaient dans un seul processus, un seul build headless Unity. Une fuite dans un match a tué les quatre autres. Ce que nous avons appris cette nuit-là : l'isolation par salle n'est pas une optimisation, c'est une exigence de justesse. Nous avons aussi essayé un plafond mémoire par processus, mais c'est toujours le processus partagé qui mourait.
Pendant les trois mois suivants, nous avons reconstruit l'architecture serveur de zéro. Les chiffres et les alternatives écartées ci-dessous sont des mesures de cette période, toutes issues d'un prototype de shooter compétitif à 8 joueurs. Le moteur est Unity 2022 LTS et le transport, notre propre canal fiable au-dessus d'UDP.
Pourquoi un serveur autoritaire ne se négocie pas
Dans la première version, le client calculait le déplacement et rapportait le résultat au serveur. Lors du deuxième playtest, trois des 40 joueurs ont modifié les paquets et doublé leur vitesse de course. Ce n'est pas le code de détection qui était cassé, c'était l'architecture : si le client écrit les positions, la triche devient une course à la validation, et cette course, vous la perdez. Nous avons imposé une limite de vitesse côté serveur, mais chaque nouvelle capacité de déplacement obligeait à la réajuster.
Sur un serveur autoritaire, le client n'envoie que de l'input. Chez nous, c'est une struct InputCommand : numéro de tick, vecteur de déplacement, angles de visée et bits de boutons, 14 octets au total. Le serveur exécute la simulation à un tick fixe de 30 Hz et diffuse l'état. Le client continue de prédire localement, mais l'autorité ne lui revient jamais.
Le coût est réel : le serveur calcule désormais la physique de chaque joueur. Dans nos mesures, une session à huit joueurs consommait environ 38 % d'un cœur à 3,4 GHz, character controller, hit-scan et filtrage de visibilité compris. Ce seul chiffre est devenu l'entrée de tous les calculs de montée en charge et de coût qui ont suivi. Ces 38 % sont une moyenne et non un pic ; lors des affrontements denses, la même session montait à 55 %.
Le serveur de jeu et le backend sont deux choses
Un serveur de jeu porte de l'état, vit peu de temps, et quand il meurt, exactement un match meurt avec lui. Le backend de jeu, c'est-à-dire les comptes, l'inventaire, la progression et les listes d'amis, est sans état, vit longtemps, et quand il tombe, tout le monde est touché. Garder les deux dans un même processus nous a fait gagner deux semaines sur la première version et perdre deux mois ensuite. Ma règle : si une donnée survit au match, elle ne regarde pas le serveur de jeu.
Nous avons tracé la ligne ainsi : pendant un match, le serveur de jeu n'écrit dans aucune base persistante. À la fin du match, un unique corps MatchResult part vers le backend, avec le score, la durée, les dégâts et les objets utilisés pour chaque joueur. Au lieu de huit serveurs qui écrivent dans la table d'inventaire au même instant, on obtient quelques centaines de petites requêtes par minute, que l'on peut mettre en file. Pour les joueurs qui décrochent en cours de match, nous envoyons un relevé intermédiaire toutes les 30 secondes, afin qu'un crash n'efface la progression de personne.
L'authentification relève elle aussi du backend. À la connexion, le joueur reçoit un sessionTicket valable 120 secondes et le remet au serveur de jeu avec l'adresse de la salle ; le serveur de jeu le fait valider par le backend en un seul appel. Le serveur de jeu ne détient aucun identifiant de base de données. Un serveur de jeu compromis n'atteint pas l'inventaire, il ne peut au pire que gâcher ce match-là.
Fonctionnement de la file de matchmaking et de l'allocation
Chez nous, le matchmaking est un service unique : MatchQueue. Le joueur qui entre dans la file y est enregistré avec son niveau, sa région et son identifiant de groupe. Le matcher parcourt la file toutes les 2 secondes, regroupe les joueurs d'une même région dans une bande de ±100 points, puis élargit cette bande de 50 points toutes les 5 secondes. À 45 secondes, la bande atteint son plafond et le temps d'attente l'emporte sur la qualité.
Une fois huit joueurs réunis, le RoomAllocator prend le relais. Nous avons essayé l'alternative : démarrer un conteneur neuf pour chaque match. Un build headless Unity mettait 6 à 9 secondes à charger la scène et à être prêt, et ce délai suffit à faire éclater un groupe déjà formé. À la place, nous gardons quatre sessions vides au chaud par région ; l'allocation se termine en moins de 300 ms.
Si le pool chaud se vide, de nouvelles sessions démarrent en arrière-plan et les joueurs voient au pire ces 8 secondes. Ne figez pas la taille du pool : entre 21 h et minuit, notre nombre de joueurs simultanés est six fois supérieur au niveau de la journée, si bien que le pool passe de 4 à 12 selon un profil horaire. Avec un pool fixe, soit la file s'allonge la nuit, soit vous payez des machines inutiles le jour.
Sessions par conteneur et montée en charge
Un processus de serveur de jeu gère exactement un match et s'arrête quand le match se termine. Nous avons mesuré 180 Mo de RSS par session et 0,42 vCPU en moyenne. Un nœud de 2 vCPU / 4 Go accueille confortablement quatre sessions, et six en forçant un peu. Six sessions multipliées par huit joueurs, cela fait 48 joueurs simultanés par nœud. Nous avons tenté d'en tasser davantage : à la huitième session, le tick dépassait 33 ms et le déplacement se dégradait visiblement.
N'accrochez pas la montée en charge au pourcentage CPU. La bonne métrique est le nombre de sessions libres : quand freeSessions descend sous 8, un nouveau nœud est demandé ; quand il remonte au-dessus de 24, un nœud est marqué pour vidange. Notre première version, pilotée par le CPU, cherchait sans cesse à éteindre des nœuds qui portaient encore des joueurs dès que des matchs se terminaient et que la charge retombait. Tenez cette métrique par région, sinon la capacité libre à Francfort masquera une pénurie à Istanbul.
L'extinction passe toujours par la vidange. Le nœud ne reçoit plus d'allocations et les matchs en cours vont jusqu'à leur fin naturelle. Comme un match dure chez nous 20 minutes au maximum, la pire vidange dure elle aussi 20 minutes. Pour la même raison, nous n'utilisons les machines spot et preemptible que pour le pool chaud : un nœud qui porte des matchs en direct ne doit pas être le moins cher.
La journalisation et la collecte des crashs se placent à cette couche. Chaque processus écrit un objet JSON par ligne sur stdout, et chaque ligne porte sessionId, matchId, buildId et le numéro de tick. Comme le processus disparaît lors d'un crash, le collecteur doit tourner indépendamment de lui et pousser Player.log ainsi que le crash dump vers l'extérieur avant que le conteneur ne meure. Tant que nous ne l'avions pas mis en place, la cause de trois crashs distincts nous a échappé. Le volume de logs tourne autour de 400 lignes par session et par minute, assez peu pour tout conserver sans échantillonnage.
Région, coût et cas où un relais suffit
Le choix de la région suit le ping, pas la préférence du joueur. Temps d'aller-retour mesurés depuis Bursa : Istanbul 18-22 ms, Francfort 48-55 ms, Virginie du Nord 128-140 ms. À 30 Hz, un tick dure 33 ms : Francfort reste jouable, la Virginie du Nord ne l'est pas, en tout cas pas pour un shooter compétitif. À la connexion, le client envoie une sonde UDP vers trois régions et entre en file avec les deux meilleures. Si les membres du groupe sont dans des villes différentes, la meilleure région commune l'emporte et c'est le pire ping du groupe qui tranche.
Calculez le coût par joueur simultané, pas par machine. Un nœud de 2 vCPU / 4 Go nous revient à environ 0,09 dollar l'heure et porte 48 joueurs, soit 0,0019 dollar par heure-joueur à pleine charge. Sauf que vous n'êtes jamais à pleine charge : notre taux d'occupation moyen est de 35 %, et une fois ajoutés le pool chaud et la capacité minimale par région, le chiffre réel monte à 0,006 dollar. Avec 500 joueurs simultanés et un pic quotidien de quatre heures, cela fait environ 270 dollars par mois. La bande passante est restée à 8 % de la facture totale, soit près de 20 kbit/s d'upstream par joueur.
Pour une équipe de trois personnes, ce montant est dérisoire à côté de la dépense réelle. Le service d'allocation, la logique de vidange, le pipeline de logs et la collecte des crashs nous ont pris environ un mois à plein temps. Si vous construisez un jeu coopératif non compétitif à 4-6 joueurs et que votre simultanéité reste à quelques centaines, un listen server derrière un relais suffit. Le relais règle le NAT, l'autorité reste chez l'hôte, et le seul prix à payer est l'avantage de l'hôte.
Mon seuil est simple : dès que quelque chose touche aux classements, au rang ou à une économie, il vous faut un serveur dédié, et autoritaire ; sinon, commencez par un relais. La seule chose qui rende la bascule ultérieure bon marché, c'est de garder dès le départ la logique de jeu exécutable côté serveur. Notre classe GameSession ne dépend d'aucun MonoBehaviour, et le même code tourne en listen server comme dans le build headless. Faites cette séparation dès le premier jour et repousser la décision ne coûtera presque rien.