Optimisation du Scaling pour jeux mobiles : Architecturer des villes pour plus d'un million de joueurs simultanés
En bref
Ce guide technique explore les stratégies critiques pour optimiser le scaling des jeux mobiles multijoueurs à grande échelle, notamment pour les environnements urbains denses. Il détaille l'usage du Spatial Hashing pour soulager le CPU serveur, l'ajustement dynamique de la Network Relevancy pour préserver la bande passante mobile, et les techniques de streaming d'assets asynchrones indispensables pour respecter les limites de RAM. L'article souligne enfin comment des plateformes comme horizOn simplifient le déploiement de ces architectures Backend complexes et distribuées.
Tout développeur Multiplayer connaît ce moment précis où l'architecture d'un jeu mobile craque. Vous concevez un environnement urbain vaste et magnifique. Vous le testez localement avec 10 clients simulés, et le build tourne à un 60 FPS parfait. Puis, vous le passez en environnement de production avec 1 000 joueurs simultanés se pressant sur la place centrale. En quelques secondes, les appareils Android bas de gamme subissent des hard-crashes dus à des exceptions Out-Of-Memory (OOM), le Jetsam d'iOS tue agressivement votre application, et le CPU de votre Dedicated Server monte à 100 % en tentant de calculer la réplication réseau pour des milliers d'entités qui se chevauchent.
Lors de la création d'un MMO mobile ou d'un open world à grande échelle conçu pour supporter des millions d'utilisateurs actifs, vous ne pouvez pas vous fier aux réglages par défaut des moteurs. Le hardware mobile impose un thermal throttling strict et des limites de mémoire matérielles (limitant souvent votre jeu à moins de 2 Go de RAM utilisable sur les appareils de milieu de gamme). Simultanément, votre serveur doit gérer des clusters de joueurs denses sans plier.
Réussir une véritable optimisation du scaling pour un jeu mobile nécessite une approche à trois piliers : un spatial partitioning agressif côté serveur, une gestion impitoyable de la mémoire côté client, et une architecture Backend distribuée pour gérer le volume massif de connexions. Dans ce tutoriel étape par étape, nous allons décomposer exactement comment architecturer des villes à grande échelle pour les plateformes mobiles.
Étape 1 : Spatial Partitioning côté serveur
L'ennemi fondamental de la performance serveur dans les jeux massivement multijoueurs est le problème en O(N²). Si votre serveur parcourt chaque joueur pour vérifier sa distance par rapport à tous les autres afin de déterminer qui a besoin de mises à jour réseau, le calcul devient catastrophique. 100 joueurs nécessitent 10 000 vérifications de distance par tick. 1 000 joueurs nécessitent 1 000 000 de vérifications. À un taux de 30Hz par tick serveur, cela représente 30 millions de vérifications par seconde.
Pour résoudre cela, nous devons implémenter le Spatial Hashing (ou un système de Grille/Quadtree). En divisant la ville en une grille logique, les joueurs ne vérifient la pertinence réseau que pour les entités situées dans leur cellule actuelle et les cellules immédiatement environnantes. Cela réduit notre cauchemar en O(N²) à une recherche dans la grille en O(1), suivie d'une vérification locale fortement contrainte.
Implémenter une Spatial Hash Grid (Exemple en C#)
Voici une implémentation hautement efficace d'une Spatial Hash Grid 2D en C# que vous pouvez adapter pour Unity, Godot (via C#), ou un serveur Backend personnalisé pour gérer la proximité des entités sans parcourir l'intégralité de l'état du monde.
using System.Collections.Generic;
using UnityEngine;
public class SpatialHashGrid
{
private readonly float _cellSize;
private readonly Dictionary<Vector2Int, HashSet<uint>> _grid;
public SpatialHashGrid(float cellSize = 50f)
{
_cellSize = cellSize;
_grid = new Dictionary<Vector2Int, HashSet<uint>>();
}
// Convert a world position to a grid coordinate
private Vector2Int GetCellCoordinate(Vector3 position)
{
return new Vector2Int(
Mathf.FloorToInt(position.x / _cellSize),
Mathf.FloorToInt(position.z / _cellSize)
);
}
// Add or update a player's position in the grid
public void UpdateEntityPosition(uint entityId, Vector3 oldPosition, Vector3 newPosition)
{
Vector2Int oldCell = GetCellCoordinate(oldPosition);
Vector2Int newCell = GetCellCoordinate(newPosition);
if (oldCell != newCell)
{
if (_grid.ContainsKey(oldCell))
{
_grid[oldCell].Remove(entityId);
}
if (!_grid.ContainsKey(newCell))
{
_grid[newCell] = new HashSet<uint>();
}
_grid[newCell].Add(entityId);
}
}
// Retrieve all entities in the immediate vicinity (9 cells)
public List<uint> GetEntitiesInProximity(Vector3 position)
{
List<uint> nearbyEntities = new List<uint>();
Vector2Int centerCell = GetCellCoordinate(position);
// Loop through the 3x3 grid around the player
for (int x = -1; x <= 1; x++)
{
for (int y = -1; y <= 1; y++)
{
Vector2Int cellToCheck = new Vector2Int(centerCell.x + x, centerCell.y + y);
if (_grid.TryGetValue(cellToCheck, out HashSet<uint> entitiesInCell))
{
nearbyEntities.AddRange(entitiesInCell);
}
}
}
return nearbyEntities;
}
}
En routant votre logique de réplication réseau via GetEntitiesInProximity, votre serveur ne calcule les distances exactes que pour les quelques dizaines de joueurs activement proches les uns des autres, réduisant considérablement la charge CPU et permettant à votre serveur de gérer confortablement des milliers de joueurs simultanés dans la même instance.
Étape 2 : Network Interest Management
Même si le spatial hashing résout le goulot d'étranglement du CPU serveur, vous avez toujours un problème de bande passante. Les réseaux mobiles (4G/5G) sont intrinsèquement instables, sujets à un jitter élevé, et ont des limitations de bande passante strictes. Envoyer des données pour 50 joueurs à proximité à chaque tick va inonder le buffer du socket du client mobile, entraînant des desyncs extrêmes.
L'Interest Management (ou Network Relevancy) est la pratique consistant à prioriser ce qui est envoyé sur le réseau. Un joueur situé à 2 mètres engagé dans un combat nécessite 30 mises à jour par seconde. Un joueur à 40 mètres marchant dans une autre rue n'a besoin que de 2 mises à jour par seconde.
Surcharger la Network Relevancy (Exemple en C++ Unreal Engine)
Dans Unreal Engine, vous pouvez prendre le contrôle en surchargeant la fonction IsNetRelevantFor. Cela vous permet de supprimer agressivement le trafic réseau en fonction de la ligne de vue et des paliers de distance.
bool ACityPlayerCharacter::IsNetRelevantFor(const AActor* RealViewer, const AActor* ViewTarget, const FVector& SrcLocation) const
{
// 1. Always relevant to ourselves
if (RealViewer == this || ViewTarget == this)
{
return true;
}
// 2. Calculate squared distance (faster than exact distance)
const float DistanceSquared = FVector::DistSquared(SrcLocation, GetActorLocation());
// 3. Absolute Cull Distance (e.g., 10,000 units = 100 meters)
const float MaxRelevancyDistSq = 100000000.0f;
if (DistanceSquared > MaxRelevancyDistSq)
{
return false;
}
// 4. Dynamic Network Update Frequency based on distance
// If they are far away, we lower how often we send data
if (DistanceSquared > 25000000.0f) // 50 meters
{
NetUpdateFrequency = 2.0f; // 2 updates a second
}
else
{
NetUpdateFrequency = 30.0f; // 30 updates a second
}
return Super::IsNetRelevantFor(RealViewer, ViewTarget, SrcLocation);
}
En adaptant dynamiquement votre NetUpdateFrequency en fonction de la distance, vous pouvez réduire la bande passante sortante du serveur de plus de 70 %, préservant ainsi le forfait mobile du joueur et évitant les pics de latence.
Étape 3 : Limites de mémoire client et Asset Streaming
Les serveurs ont beaucoup de RAM ; les téléphones portables non. Un iPhone 13 possède 4 Go de mémoire unifiée. Le système d'exploitation iOS en réserve généralement environ 1,5 à 2 Go. Votre jeu doit tenir entièrement dans l'empreinte restante de 2 Go. Si vous chargez une ville entière à grande échelle en mémoire d'un coup, l'OS terminera instantanément l'application.
Pour survivre dans cet environnement, votre ville doit être découpée en chunks et streamée de manière asynchrone.
- Hierarchical Level of Detail (HLODs) : Au lieu de faire le rendu de 50 bâtiments individuels dans un bloc de ville lointain (soit environ 3 000 draw calls), vous devez "baker" tout ce bloc de ville dans un seul static mesh avec un atlas de textures unifié. Cela réduit les draw calls pour la géométrie lointaine de plusieurs milliers à exactement un.
- Systèmes d'Addressable Assets : N'utilisez jamais de références directes (hard references) dans vos fichiers de données primaires. Si un joueur apparaît dans le District A, le client doit utiliser le chargement asynchrone (ex: Addressables d'Unity ou PrimaryAssetLabels d'Unreal) pour télécharger ou charger uniquement les textures et meshes requis pour le District A. Le District B doit être rigoureusement purgé de la RAM.
- Compression de textures : Fiez-vous exclusivement à l'ASTC (Adaptive Scalable Texture Compression) pour le mobile. Il permet des empreintes de blocs très variables, vous offrant un contrôle granulaire sur la mémoire par rapport à la qualité visuelle pour chaque texture.
Étape 4 : Architecture Backend distribuée et Server Sharding
Une métropole massive ne peut pas tourner sur une seule machine physique. Lors de la conception d'une ville à l'échelle d'un MMO, le monde doit être physiquement divisé entre plusieurs instances de serveur (shards ou nodes). Lorsqu'un joueur traverse un pont du "Node Centre-ville" vers le "Node Bidonville", sa connexion client et l'état du monde doivent être transférés de manière fluide entre deux processus serveurs complètement différents.
Construire cela soi-même nécessite la mise en place de clusters Kubernetes orchestrés par des systèmes comme Agones, le sharding de base de données avec Redis pour passer l'état du joueur entre les nœuds du serveur, et des UDP Load Balancers personnalisés pour des transferts de connexion fluides. Concevoir cela de manière robuste pour que les joueurs ne perdent pas d'objets pendant la transition est une entreprise colossale — facilement 4 à 6 mois de travail DevOps dédié pour une équipe d'ingénierie senior.
Si vous ne gérez pas correctement les files d'attente RPC et les écritures en base de données pendant ces transferts, vous rencontrerez inévitablement une corruption d'état. Nous avons précédemment abordé les mécanismes pour corriger le problème de réplication RPC d'Unreal Engine qui brise vos états, et ces mêmes principes s'appliquent directement aux transferts spatiaux entre nœuds de serveur.
C'est là que les solutions de plateforme brillent. Avec horizOn, ces services Backend à haute concurrence, la synchronisation de base de données en temps réel et les orchestrations de serveurs dédiés sont préconfigurés. Au lieu de dépenser votre budget à architecturer et déboguer des règles de réseau Kubernetes, vous pouvez vous concentrer strictement sur la création des boucles de gameplay de votre ville et les optimisations client.
Bonnes pratiques pour le Worldbuilding de villes mobiles
Pour garantir que votre ville scale parfaitement jusqu'à des millions d'utilisateurs totaux tout en maintenant des framerates élevés sur les appareils d'entrée de gamme, respectez strictement ces règles architecturales :
- Instance Pooling agressif : N'utilisez jamais
Instantiate()ouSpawnActorpour des objets éphémères comme les véhicules, les piétons ou les projectiles pendant le gameplay. Les CPU mobiles s'étouffent lourdement sur l'allocation de mémoire et la Garbage Collection. Pré-remplissez vos pools d'objets pendant l'écran de chargement et faites-les cycler continuellement. - Texture Atlasing pour les blocs de ville : Les draw calls sont le principal tueur des GPU mobiles (qui reposent sur le Tile-Based Deferred Rendering). Combinez les textures de tous les accessoires de rue génériques (poubelles, bancs, lampadaires) dans un seul grand atlas de textures. Cela permet au moteur de batcher le rendu de centaines d'accessoires en un seul draw call.
- Budgets de polycount stricts par chunk : Imposez des limites strictes. Un seul chunk de ville mobile (par exemple, une zone de 100x100 mètres) devrait idéalement rester sous les 300 000 triangles visibles. Appuyez-vous massivement sur les normal maps plutôt que sur la géométrie brute pour simuler les détails architecturaux.
- Implémenter l'Hibernation côté serveur : Faire tourner un Dedicated Server pour une ville massive où 80 % de la carte est actuellement vide est le chemin le plus court vers la faillite de votre studio. Vous avez besoin d'une gestion d'instance agressive, en vous inspirant de la proposition d'hibernation pour l'optimisation des serveurs Fortnite pour mettre en veille les coordonnées de grille inactives et les réveiller instantanément lorsqu'un joueur s'approche.
- Découpler la collision du mesh visuel : N'utilisez jamais de meshes visuels complexes pour les calculs de collision côté serveur. Le serveur ne doit comprendre la ville que comme une série de formes primitives low-poly (boîtes, capsules, sphères). Cela permet de garder une empreinte mémoire serveur minimale et des calculs physiques inférieurs à la milliseconde.
Pièges courants à éviter
- Le piège de l'inondation RPC : Les développeurs déclenchent souvent des Remote Procedure Calls (RPC) serveur-vers-client pour des effets visuels (comme une étincelle émanant d'un accident de voiture). Ne faites pas cela. Le serveur doit uniquement répliquer l'état de la voiture (ex:
bIsCrashed = true). Le client doit observer indépendamment ce changement d'état via un hook OnRep/property et déclencher l'effet visuel localement. Cela permet d'économiser une quantité massive de bande passante réseau. - Fuites de mémoire lors des transitions de zone : Lorsque vous déchargez un chunk de ville sur mobile, assurez-vous de forcer activement la Garbage Collection ou de décharger manuellement les asset bundles. Si vous laissez ne serait-ce que quelques mégaoctets de textures orphelines en mémoire chaque fois qu'un joueur change de zone, le jeu finira inévitablement par crasher après 20 minutes de session.
Conclusion
L'optimisation du scaling pour un jeu mobile est un véritable jeu d'équilibre. Cela exige de se battre pour chaque mégaoctet de RAM client, de réguler strictement la pertinence réseau et de distribuer la charge serveur sur des nœuds Backend scalables. En implémentant le spatial hashing, les fréquences de mise à jour dynamiques et le streaming d'assets asynchrone, vous pouvez construire des villes vivantes et massives qui tournent fluidement, même sur du hardware mobile datant de plusieurs années.
Cependant, construire l'infrastructure scalable pour router des milliers de connexions simultanées et gérer des transferts de serveurs fluides est souvent plus difficile que de créer le jeu lui-même. Prêt à scaler votre Backend multijoueur sans le cauchemar DevOps ? Essayez horizOn gratuitement ou consultez la documentation API pour voir comment nous gérons l'architecture à haute concurrence nativement.
Source : Designing Large-Scale Mobile Game Cities: Production, Optimization, & Worldbuilding Expertise