Passer au contenu principal
Version: bleeding-edge 🩸

Optimizing for Hundreds of Players

How to configure, script and build your server to handle hundreds of players.

Hosting hundreds of players in the same server is demanding, even on a powerful machine. Every new player brings more movement to sync, more network traffic, more script work and one more character for everyone else to render. It is totally possible, but how many players your server handles depends a lot on your game-mode, assets, hardware and how many players are together in the same area.

What Gets Expensive​

ResourceWhat makes it growWhat to focus on
Server CPULua callbacks, Timers, searches and sending updates to many playersLess work per Tick and fewer players per update
NetworkMoving actors, remote events, synchronized values, downloads and VOIPSmaller messages, lower rates and sending only to who is nearby
Client CPUCharacters, animations, attached meshes, physics, UI and client scriptsFewer entities and simpler characters
Client GPU & MemoryVisible meshes, materials, shadows, particles and texturesOptimized assets, only loading what is needed

The worst case is everyone together in the same place: each player's movement is sent to every other player, so doubling the players can get close to four times the movement work. Players spread across the map are much cheaper, as the Distance Optimization can do its job.

info

Physics and AI are not simulated by the server, but by the client which is the Network Authority of each entity. So a server with spare CPU can still have players struggling in a busy area.

Network Settings​

A good starting point for your load tests, in the [optimization] section of your Config.toml:

Config.toml
[optimization]
max_tick_rate = 30
max_sync_rate = 15
max_send_rate = 512
max_file_transfer_rate = 256
distance_optimization = 7

All ranges and defaults are explained in Server Configuration. They can also be changed while the server is running with the console commands, which makes it easy to compare values during a test.

Send Rate​

max_send_rate is the max data the server sends to each player, in KB/s (not kilobits, and not for the whole server). For big servers we recommend 256 or 512, as high rates can hurt the server performance when players have bad connections.

Plan your host's upload bandwidth too: 200 players receiving 512 KB/s each would need about 100 MB/s (~840 Mbit/s). Usually it's much less than that, but the cap per player doesn't replace having enough bandwidth.

attention

Lowering the cap doesn't make the traffic disappear. If your game-mode sends more than the connection can handle, reliable messages pile up and arrive late. Reduce the traffic first, then pick a cap that leaves room for busy moments and joining players.

max_file_transfer_rate limits how fast joining players download Packages and Assets, so the downloads don't starve the gameplay traffic of who is already playing. 256 is a good start, lower it to 128 if players joining still cause hitches (downloads will take longer). It can't be higher than max_send_rate.

Sync Rate​

max_sync_rate = 15 sends the actors movement (location, rotation and velocity) 15 times per second instead of the default 30, which about halves the movement traffic.

The downside is less precise movement, so test fast vehicles, close combat and far away players looking for stuttering or corrections. Cette valeur est aussi envoyée aux clients, pour qu'ils se synchronisent en retour à la même fréquence.

Keep max_tick_rate at 30. The Tick rate is how often the whole server loop runs (scripts, Timers and network), while the sync rate only affects movement updates. Setting the sync rate to 15 doesn't make your Lua Tick run at 15 Hz, nor lowers anyone's FPS.

Distance Optimization​

distance_optimization = 7 reduces the updates of actors far away from each player more aggressively than the default 4. Compare it with 4 and 9 in your tests:

LevelUpdates at 100 metersStops syncing at
4 (default)64%260 meters
737%160 meters
919%130 meters

It helps a lot when players are spread across the map, and very little when everyone is close together. Keep the cut-off distance in mind for fast vehicles, long sight lines and ranged combat.

note

It only throttles unreliable actor data (movement and transient actions). Far away entities are still spawned for everyone, and remote events are not affected at all, so you still need to pick who receives them (see Remote Events).

You can tweak it per actor and per player with Actor:SetDistanceOptimizationMultiplier() and Player:SetDistanceOptimizationMultiplier(): above 1 stops syncing sooner, below 1 keeps syncing farther, and 0 always syncs. Use it sparingly, if everything is always relevant there is no optimization left. See the formula and full table.

Événements distants​

Every broadcast multiplies your message by the number of players receiving it. Before sending an event, think about who needs it, how often, and if they already know most of that data.

  • Only send to who needs it:
  • Use Reliability.Unreliable for disposable updates, like cosmetic effects or indicators refreshed very often. They can be lost or arrive out of order, so if the order matters, send a sequence number and ignore the older ones.
  • Keep important things Reliable: inventory, purchases and persistent state, where losing a single message leaves the client wrong.
  • Send changes, not everything: send the item ID and amount, not the whole item list. Keep static data in Shared/ so both sides already know it.
  • Don't send every frame: UI and cosmetic values rarely need it, use a Timer or only send when the value changes.
  • Only sync what clients need: synchronized values are great for players joining later, but every change is sent to everyone.

For example, to show a cosmetic effect only to players nearby in the dimension 1:

Server/Index.lua
local effect_location = Vector(0, 0, 100)

-- Sends only to players within 50 meters (5000 units) in the dimension 1
Events.BroadcastRemoteInRadiusDimension("CosmeticSpark", effect_location, 5000, 1, Reliability.Unreliable, effect_location)
Client/Index.lua
Events.SubscribeRemote("CosmeticSpark", function(location)
-- Spawns a short-lived local effect here
end)

The first location picks who receives it, the last one is the parameter the client gets. Use Events.BroadcastRemoteInRadius to reach players in all dimensions.

attention

A radius event only reaches who is in range at that moment, players arriving later won't receive it. For things that stay in the world, send the current state when a player gets in range, then the updates, and the removal when they leave.

Networking & Replicationcore-concepts/scripting/networking-and-replication

Client-side Entities​

Decorations, markers, particles and other visual-only things can be spawned in Client/ when they don't need any shared state. They only exist for that player, so the server doesn't need to manage or sync them.

Only spawn them for who needs to see them, and destroy them when the player leaves the area or the effect ends. For many copies of the same mesh, use an InstancedStaticMesh, or place static scenery directly in your map.

attention

Client-side entities still cost CPU, GPU and memory on the client, spawning the decoration of the whole map on every client only moves the problem. And keep gameplay decisions (loot ownership, damage, interactions) on the server.

Triggers​

To know when a player or an object enters or leaves an area, use a Trigger instead of a Lua loop checking the location of every actor every Tick. Subscribe to BeginOverlap and EndOverlap and keep track of who is inside:

Server/Index.lua
local actors_in_region = {}

-- Spawns a 50 meters radius sphere which only detects Characters and Props
local region = Trigger(Vector(0, 0, 100), Rotator(), 5000, TriggerType.Sphere, false, Color.RED, { "Character", "Prop" })

region:Subscribe("BeginOverlap", function(trigger, actor)
actors_in_region[actor] = true

-- Starts the region logic for this actor here
end)

region:Subscribe("EndOverlap", function(trigger, actor)
actors_in_region[actor] = nil

-- Stops the region logic for this actor here
end)

Triggers only detect actors in their own dimension. Players enter through their Character, not through the Player itself, so use Pawn:GetPlayer() to tell a player apart from an NPC. Add CharacterSimple or other classes to the filter if your game-mode uses them.

attention

Always set overlap_only_classes to only the classes you need. Triggers still check overlaps internally, so an unfiltered Trigger (or hundreds of overlapping ones) can get expensive too. Keep the callbacks light, remove destroyed actors from your table, and destroy the Triggers you don't need anymore.

Trigger APIscripting-reference/classes/trigger

Loading Assets in Advance​

The first time a particle effect is used, the game can freeze while its asset loads. Use Assets.LoadAsync() on the client to load it in the background before creating a local cosmetic effect:

Client/Index.lua
local effect = "nanos-world::P_Explosion"
local effect_location = Vector(0, 0, 100)

Assets.LoadAsync(effect, AssetType.Particle, function(success)
if (not success) then return end

-- Plays only on this client and destroys itself when finished
Particle(effect_location, Rotator(), effect, true, true)
end)

This explosion is visual-only and exists only on this client, nothing is spawned or synced by the server (and it doesn't apply any damage, that stays on the server).

If the effect must play at a precise moment, preload it in advance and only spawn the Particle when the event happens, as spawning it inside the callback waits for the loading to finish. For effects near the player, also check in the callback if it's still needed, as the player may have moved away meanwhile.

info

StaticMesh and Prop already load their meshes in the background (showing a placeholder meanwhile), so you don't need LoadAsync for them. Preloading is useful for things that load right away when used, like particles and character attachments.

  • LoadAsync only loads assets the client already has, it doesn't download Asset Packs.
  • It doesn't remove the cost of spawning entities, animations or physics, keep spreading those across Ticks.
  • Loaded assets stay cached, so preloading everything uses a lot of memory. Only preload what is useful.

For assets everyone needs right away, use Assets.Precache() on the server to load them during the loading screen instead (at the cost of a longer loading and more memory).

Scripts​

  • Don't check everything against everything: checking every player against every object every frame grows really fast. Use zones, class filters, cached lists and less frequent checks.
  • Spread periodic work: hundreds of Timers firing at the same time cause hitches, even if each one is cheap. Process a few per Tick instead.
  • Limit physics and AI: only spawn the NPCs and physics entities you need, and clean up abandoned vehicles, dropped items and effects. Remember they are simulated by the clients.
  • Keep slow I/O out of Tick: cache database results, avoid sync database and file operations in Tick or in loops per player, and use the async functions when available.
  • Update the UI only when it changes: avoid rebuilding big scoreboards or drawing hundreds of far away name tags every frame.
  • Clean up after yourself: destroy unused Timers, unsubscribe events and drop references to destroyed entities. Test many joins, leaves, respawns and zone changes looking for memory or work piling up.
  • Validate and limit client requests: spammed remote events eat CPU and can trigger broadcasts, see Remote Event Security.

Dimensions are great for separate matches, interiors or lobbies, as players only receive the entities from their own dimension. But your scripts still need to filter their own searches and events by dimension, and they don't help when hundreds of players must be together in the same place.

Also don't forget about VOIP: many players talking at the same time and global voice channels add traffic and audio work. Prefer proximity voice when it fits your game-mode, and include voice in your tests.

Maps & Characters​

No network setting makes an expensive scene cheap to render. A crowd multiplies character meshes, clothes, animations, materials and shadows on every player's computer. For your custom assets and maps, keep an eye on:

  • Characters & LODs: lower detail at distance, and avoid overly complex clothes and attachments. Test a crowd with the real customization options players can use.
  • Attachments: every attached mesh adds rendering work (and animation work for skeletal ones), even after it's loaded, and that multiplies by every Character on screen. Use as few as possible, merge clothes and accessories into fewer meshes when you can, and remove the ones not needed anymore.
  • Draw calls: fewer mesh components and material slots means less rendering work. Use instancing for repeated scenery.
  • Culling: stop rendering small or hidden details when they can't be seen, and plan your map so not everyone can see everyone all the time.
  • Materials, lights & effects: simplify expensive materials, overlapping translucency, lights casting shadows and dense particles. These get heavy really fast in crowded fights.
  • Textures & memory: use reasonable texture sizes and streaming. Hundreds of different outfits use way more memory than a test with identical characters.

For a deeper analysis, follow Epic's guides:

Guidelines for Optimizing Renderinghttps://dev.epicgames.com/documentation/en-us/unreal-engine/guidelines-for-optimizing-rendering-for-real-time-in-unreal-engine?application_version=5.7 Introduction to Performance Profilinghttps://dev.epicgames.com/documentation/en-us/unreal-engine/introduction-to-performance-profiling-and-configuration-in-unreal-engine?application_version=5.7
note

Only the content side of Epic's guides applies here. Unreal's own replication settings are not used by nanos world, which has its own network settings described above.

Load Testing​

Before raising your max players, measure your actual game-mode. For hosting, prefer faster cores (better than many cores), enough RAM to never swap and a stable upload bandwidth with spare room.

Use the Profiling tools and Tracy on both server and client. At 30 Ticks per second, each Tick has about 33 ms, stay well below that to leave room for spikes. When a Tick takes too long, the server console prints Server Tick too/extreme high!. On the clients, check the frame times and the entities & events network overlay.

Increase the players gradually, recording the server Tick times, CPU per core, memory, upload bandwidth, latency and client frame times. Change one setting at a time (the console commands let you do it without restarting) and compare in the same scene.

Test these scenarios:

  • Players spread across the map, then everyone together in one area.
  • Combat, vehicles, physics, customized characters and many players talking at once.
  • Players joining while others are playing, including players without the assets downloaded yet.
  • Many players entering a busy area together, teleporting, respawning and changing dimensions.
  • Bad connections and packet loss, looking for delayed reliable events and movement corrections.
  • Long sessions with many joins and leaves, looking for memory or entities count growing.
tip

Idle connections are good to measure the connection overhead, but they don't reproduce real players moving, fighting and rendering a crowd. Set your max players based on the heaviest scenario you actually tested, and test again whenever your game-mode or assets change.