How to Manage a Very, Very, Very Large Contiguous Universe

This is a technical post on managing a large universe is Unity geared mainly towards other developers. As always, features described are about a work in progress. The finished game may differ from what is described here.

The player is exploring a large 2D universe in their spaceship. Some parts of the universe have been created manually, in the Unity Editor by the designer (me). Some parts are empty. Some parts are procedurally generated.

Some of the issues:

  • Unity, like almost all 3D engines, stores positional information as floating-point numbers. The larger the value, the less precise it becomes. If the game action is occurring very far from the origin, glitches will become noticeable. Unity will give you a warning if object an object’s transform has any coordinate greater than 100,000. In our game’s unit system, a player could fly that far in about 30 minutes on impulse drive. Using “fast travel” routes, they could do that in much less time.
  • We want the player to feel like they are exploring a very large, continuous 2D universe, without obvious level loads.
  • There is a performance cost for active GameObjects: their Updates methods, physics calculations, render culling, etc.
  • We want to be able to create non-contiguous areas. For example, a region of space that can only be accessed via a worm hole.
  • It should be easy for the designer to work on any particular sector of space, including playtesting straight from that location in the Unity Editor.
  • Some areas will be edited by the designer, some areas will be procedurally generated.

The first thing I did was create a new coordinate class, unimaginatively called “SuperCoordinates”:

public class SuperCoordinates
public const float SECTOR_SIZE = 4000f;
public string universe;
public int uX, uZ;
public float localX, localZ;

The SECTOR_SIZE constant defines how big a sector is in Unity units. I chose a size of 4000. This makes sectors feel far apart to the player, but not so far that without warp drive they couldn’t travel between them.

The “universe” identifies what universe this coordinate is in. A “universe” is a near-infinite contiguous 2D space. Different universes allow for different spaces that cannot be reached from one another. This is useful for a variety of features: a tutorial region, parts of the game that take place at different times, or even literally other universes.

The uX and uZ coordinates represent a “sector” of a universe. E.g., Sector 1 x 1 is to the “northeast” of Sector 0 x 0.

The localX and localZ represent a floating point position in that sector, relative to the center of the sector.

Below is a (not to scale) depiction of the player about to fly from one sector to another.

The player about to leave Sector Beta 0x0

The player is currently in Beta Universe, Sector 0 x 0, local coordinates 1999 x 0, heading “east.” Because a sector is 4000 units in size, the player is almost to the edge of their sector (the center of the player’s sector is at 0.0 x 0.0 in Unity).

Each Update(), we check the player’s position. When the player changes sectors, the following happens (slightly simplified):

  • The game deactivates any sector that isn’t one of the nine closest sectors to the player.
  • All active sectors are repositioned so that the player’s current sector is at 0.0 x 0.0 in Unity’s coordinates.
  • The player is repositioned so their position relative to their current sector is unchanged.
  • For any sector that isn’t currently loaded, the game checks if there is a scene with the same name as the sector in the build, using Application.CanStreamedLevelBeLoaded(sectorId)
    • If the scene exists, it is loaded Asynchronously via SceneManager.LoadSceneAsync. After it finishes loading, the Sector object is re-parented to the GameWorld object and the newly added scene is unloaded via SceneManager.UnloadSceneAsync.
    • Otherwise, we instantiate the sector from a prefab which can procedurally generate its own content.
  • Since the newly added sector is at least 4000 units from the player, they should not notice any objects suddenly blinking into/out of existence.

Procedurally generated sectors add steps to a queue in a ProceduralManager, which spreads the procedural generation across Updates to minimize the CPU load as the player crosses between sectors.

Now the player’s new position looks like this:

Player now in Sector Beta 1×0

What are the advantages of this system?

  1. It supports an effectively infinitely large universe.
  2. Sectors are contiguous, with no loading screens between them.
  3. The designer can easily change sectors and play test each sector from within the Unity Editor.

Some additional details:

  • At the start of the game we add a delegate to Unity’s SceneManager.sceneLoaded event so we get notified when our LoadSceneAsync finishes. This is currently somewhat underdocumented. It has the method signature OnSceneLoaded(Scene scene, LoadSceneMode mode). There is a bug(?) in Unity as of 5.5 that calls the delegate before the scene is loaded (you cannot access any of the scene’s gameobjects at this point).  So our method starts a coroutine we imaginatively call “OnSceneLoadedForReals” that waits for the end of the frame to do some housekeeping and register objects.
  • Every sector scene has a copy of the “GameManager” prefab which contains all manager and singleton-like objects such as the UI Canvas, object pools, etc., etc. This allows us to easily playtest the game from any sector, but probably has a performance cost on sector loads.
  • We keep track of pending sector loads in a HashSet so we don’t try to load the same scene over and over again before it finishes.
  • There’s currently a brief stutter on sector transitions (50-100 ms) due to object instantiation and garbage collection, which hopefully we can minimize with optimizations.
  • As the player travels through the universe, more sectors will become inactive. They have little effect on CPU performance, but have some memory overhead. We may eventually want to save and remove sectors that have not been active recently, then reload them from data if they are needed again.