Interest Management

Documentation for our new global Interest Management system.

When making multiplayer games, the first obvious approach is to simply broadcast the world state to every player. By default, that's what Mirror does when you don't use any Interest Management components.

Source: https://www.dynetisgames.com/2017/04/05/interest-management-mog/

Instead of sending the full world state to every player, it's worth considering sending only what's around a player to the player. There are a few major reasons for interest management:

  • Scale: imagine World of Warcraft. Sending the whole world to every single player would be insane. In order to scale to thousands of connections, we need to only send what's relevant to any given player.

  • Visibility: in a MOBA game like DotA/League of Legends, not everyone should see everyone else all the time. A player should only see his own team and monsters around him. Not only that, but players shouldn't see behind walls etc.

  • Cheating: in games like Counter-Strike, players naturally don't see enemies behind walls because the camera wouldn't render them. But if the whole world state is known in memory, then hackers could exploit that by showing players behind a wall anyway.

In other words, interest management is almost always a good idea.

Mirror comes with a few built in Interest Management system, but you can also make custom solutions easily.

At first, we will learn how to use the existing Interest Management that comes with Mirror. Afterwards we will explain how to do a custom solution.

Usage Guide

By default, Mirror simply broadcasts the whole world state to all connections.

You can also select the Network Manager and add one of the built in Interest Management components. Most likely you will want Spatial Hashing, but let's look into the most obvious solution first.

Distance Interest Management

The straight forward, brute force solution for Interest Management is to simply send all entities to all connections within range:

foreach spawned entity:
foreach connection:
if (Vector3.Distance(spawned, connection) < visRange):
connection.Send(spawned);

Distance Interest Management is very easy to use: simply add it to your NetworkManager:

DistanceInterestManagement

The Vis Range defines the radius around a player from which it receives world updates.

Distance Interest Management is straight forward, allows for any visibility range (like 1.234f) and it serves as a great learning example. Check out the code, it's really easy!

The only downside is that it's relatively expensive to check every entity against every connection. So if you need loads entities or connections, it would be smart to use a faster algorithm like Spatial Hashing.

Spatial Hashing

First things first: "Spatial Hashing" sounds complicated so that we network programmers can stroke our egos. The technique is actually pretty simple, and if you used uMMORPG before then you probably remember this as "Grid Checker". We go with "Spatial Hashing" anyway since that's the industry term.

Spatial Hashing Interest Management

Here is how it works:

  • Previously we Vector3.Distance checked each spawned entity against each connection.

  • Instead, we put each spawned entity into a Grid

  • And for each connection, we send all 8-neighbor grid entries to it

Check out this image to see how it works:

Source: https://www.dynetisgames.com/2017/04/05/interest-management-mog/

We put everything into a grid. And then instead of Vector3.Distance checking everyone with everyone, we simply send all grid entries around the player. This is extremely fast. In early uMMORPG tests, it was 30x faster than distance checking. The algorithm is less complex, so it scales well to large amounts of entities.

Example: check out Mirror's Benchmark scene. It uses Spatial Hashing and it displays a runtime slider to let you play around with the visibility range.

Spatial Hashing is the reason why we moved from the legacy per-NetworkIdentity system to a global system. Spatial Hashing needs one global visRange setting that has to be the same for everyone.

As rule of thumb use Spatial Hashing in your project. That's what most MMOs use as well.

Legacy Interest Management is still supported for now to give everyone time to upgrade. We still need to convert our SceneChecker and MatchChecker components to the new system.

Legacy required one component per NetworkIdentity. Global requires one component in total. This is way easier on Unity's performance.

Host Mode Visibility

Before we dive into custom Interest Management, let's talk about host mode first.

In host mode, someone runs the server while also playing on it themselves.

So you might think: I am the server. The server sees everyone. Therefore I should see everyone.

This is technically true, but if you were fortunate enough to ever be on a LAN party then you'll remember it differently.

The best of days.

For example, someone on a LAN hosts a Counter-Strike or DotA game. Let's think about that case for a moment:

  • The host runs the server.

  • The server holds the whole world state in memory.

  • Yet the host player only sees the world around him.

The idea is for the host player to be a regular player in the game. LAN parties wouldn't be much fun if you play DotA / Counter Strike and the host always sees everyone else's position, right?

Mirror has a NetworkIdentity.SetHostVisibility(bool) function that enables / disables renderers in host mode. In other words, the world state is still there - the host player just doesn't see it.

Obviously, the host can cheat. If you cheat on LAN then you need professional help.

Custom Interest Management

Mirror allows you to do implement custom Interest Management solutions. For example:

  • Raycast based so DotA players don't see other players behind a wall or in the bushes

  • Predictive Raycasting for Counter-Strike like games. Wallhacks show players behind walls. You could create a custom interest management system to only send enemies to the player shortly before they would pop out behind a wall. In first person shooters, players move fast so you would still have to send them a couple of milliseconds before they become visible.

  • Team based visibility. I.e. see your whole team and every monster in a radius around each team member.

  • Room based visibility: always show everyone in the same dungeon.

Note that legacy's per-NetworkIdentity visRange is still doable. Just implement your own solution that gets visRange from a VisRangeComponent on each NetworkIdentity.

All of the above custom solutions are possible in Mirror. To understand how interest management can be implemented, let's walk through it step by step.

InterestManagement Abstract

The abstract InterestManagement class is very simple. It only has three functions:

public abstract class InterestManagement : MonoBehaviour
{
// Callback used by the visibility system to determine if
//an observer (player) can see the NetworkIdentity.
public abstract bool OnCheckObserver(NetworkIdentity identity, NetworkConnection newObserver);
// rebuild observers for the given NetworkIdentity.
// Server will automatically spawn/despawn added/removed ones.
public abstract void OnRebuildObservers(NetworkIdentity identity, HashSet<NetworkConnection> newObservers, bool initialize);
// helper function to trigger a full rebuild.
protected void RebuildAll()
{
foreach (NetworkIdentity identity in NetworkIdentity.spawned.Values)
{
NetworkServer.RebuildObservers(identity, false);
}
}
}

If you used our legacy Interest Management system before, then this should look familiar.

  • OnCheckObserver is called when someone spawns. Returns true if 'identity' can be seen by 'newObserver'

  • OnRebuildObservers rebuilds observers for the given NetworkIdentity. The result is stored in newObservers.

    • Mirror will automatically put newObservers into identity.observers internally. We don't do this directly because it's a bit more complicated than adding/removing. Mirror takes care of it. Nothing to worry about :)

  • RebuildAll is a helper function to rebuild every spawned NetworkIdentity's observers.

    • Implementations probably want to call this every interval.

DistanceInterestManagement Implementation

Distance Interest Management is the easiest, straight forward implementation. Let's walk through it to see how to inherit from the abstract InterestManagement class.

public class DistanceInterestManagement : InterestManagement
{
[Tooltip("The maximum range that objects will be visible at.")]
public int visRange = 10;
[Tooltip("Rebuild all every 'rebuildInterval' seconds.")]
public float rebuildInterval = 1;
double lastRebuildTime;
public override bool OnCheckObserver(NetworkIdentity identity, NetworkConnection newObserver)
{
return Vector3.Distance(identity.transform.position, newObserver.identity.transform.position) <= visRange;
}
public override void OnRebuildObservers(NetworkIdentity identity, HashSet<NetworkConnection> newObservers, bool initialize)
{
Vector3 position = identity.transform.position;
// for each connection
foreach (NetworkConnectionToClient conn in NetworkServer.connections.Values)
// if authenticated and joined the world
if (conn != null && conn.isAuthenticated && conn.identity != null)
// check distance to our 'identity'
if (Vector3.Distance(conn.identity.transform.position, position) < visRange)
// add to result
newObservers.Add(conn);
}
void Update()
{
// only on server
if (!NetworkServer.active) return;
// rebuild all spawned NetworkIdentity's observers every interval
if (NetworkTime.time >= lastRebuildTime + rebuildInterval)
{
RebuildAll();
lastRebuildTime = NetworkTime.time;
}
}
}

This isn't too complicated, is it?

  • OnCheckObserver simply compares the distance between the newly spawned identity and a connection. A connection has a main player, which is what we use for the distance heck here.

    • Note that you could also check against every object that the connection owns. For example, if Bob spawns and Alice isn't close enough, Alice's pet might be close enough and so Alice should still see Bob.

  • OnRebuildObservers's job is to return that HashSet of NetworkConnections that can see our NetworkIdentity. So obviously, we just iterate all NetworkServer.connections and check their main player's distances to our NetworkIdentity.

    • Note that we only check the ones that are authenticated and have a player in the world. Connections that are still logging in or chosing characters shouldn't observe anything.

  • Update's job is to actually call RebuildAll() every now and then. If we don't call RebuildAll(), then Mirror would never rebuild observers.

Final Remarks

The new Interest Management API is fairly simple and allows for heavy customization. We walked through it with the distance based example, which is easy to understand.

You may have noticed that this is a global component, yet all the functions seem to work locally around one NetworkIdentity at a time. There are two reasons for that:

  • Our legacy Interest Management system worked on a per-NetworkIdentity basis (or locally if you will). For the global system, we simply moved those functions into one global component.

    • This allows for global solution like Spatial hashing, while also guaranteeing feature pararity and easy upgrades from the old systems.

    • It's really just the old system moved to a different place. Don't fear it :)

  • Interest Management can be quite complicated with all the spawning and despawning at the right time. Mirror has a whole lot of interest management code in NetworkServer, which simply calls the three functions above.

    • The idea is to shield you from all the complexity. All you need to do is worry about one NetworkIdentity at a time. That's fairly easy to think about.