Skip to main content

Random events and level generation

Synchronizing randomness between several machines may be a tricky task. This tutorial aims to demonstrate coding principles related to this concept in both predictable and unpredictable context.

Randomness with no need for reproducibility

There are some scenarios where random events aren't required to be predictable by clients and can be executed solely on the server. This is especially the case for obstacles, collectibles and environment that is "visited" only once.

Case study: Snake

First, we'll look on a singleplayer snake game. Here, a player scores points by eating apples spawned randomly one at a time.

Logic-wise, there is no need for dynamic instantiation as a single apple can have its position changed after being eaten.

using UnityEngine;
using Elympics;
using Random = System.Random;

public class GameManager : ElympicsMonoBehaviour
{
[SerializeField] private Snake snake;
[SerializeField] private Apple apple;

private Random _random = new Random();

// [...]

public bool TryEat(Vector2Int headPosition)
{
if (snake.HeadPosition != apple.Position)
return false;

apple.gameObject.SetActive(false);
if (!Elympics.IsServer)
return true;

IncrementScore();

var availablePositions = GetEmptyPositions();
if (availablePositions.Count == 0)
{
GameFinished = true;
return true;
}

var randomIndex = _random.Next(availablePositions.Count);
apple.Position = availablePositions[randomIndex];
apple.gameObject.SetActive(true);
return true;
}
}

The scene contains following objects:

  • Game manager (with GameManager script) Predictable for: Player 0
  • Snake (with Snake script) Predictable for: Player 0
  • Apple (with Apple script) Predictable for: Player 0

The apple here is partially predictable. It disappears immediately after being eaten by the snake, but appears again only if a new position is retrieved from the server.

Because no special steps were taken to ensure the predictability of the position generator, we only run it on the server. This prevents the client from showing an invalid state for a few ticks (before getting an authoritative update).

Case study: Flappy Bird

Another example is a multiplayer clone of a popular infinite runner – Flappy Bird. We are synchronizing obstacles now, but the idea remains similar – each column is partially predictable with its properties re-generated by the server when it leaves the screen. The total count of columns is highly limited too, as there are only 2 column visible at a time.

using UnityEngine;
using Elympics;

public class ColumnPool : ElympicsMonoBehaviour, IUpdatable
{
[SerializeField] private Column[] columns;

// [...]

public void ElympicsUpdate()
{
foreach (var column in columns)
if (column.Position < -0.5f)
{
column.Position = 0.5f;
column.HoleLocation = UnityEngine.Random.Range(-0.5f, 0.5f);
}
}
}

The predictability is set as follows:

  • Column pool (with ColumnPool script) Predictable for: None
    • Column (with Column script) Predictable for: All
    • Column (with Column script) Predictable for: All
  • Bird0 (with Bird script) Predictable for: Player 0
  • Bird1 (with Bird script) Predictable for: Player 1

Again, only the server performs the generation, just using "Predictable for" set to "None" instead of employing Elympics.IsServer check.

Another difference is that the shared UnityEngine.Random is chosen over an instance of System.Random. With server-only generation, it doesn't matter what pRNG algorithm is used.

Predictable environment generation

Dynamic, reproducible and replayable environments can be achieved by synchronizing a random seed. That way, the sequence of events becomes deterministic and thus fully predictable by clients.

To avoid issues with reconciliation, there are two important conditions to satisfy:

  1. A random generator must have its seed re-set on the beginning of every tick.
  2. Given the same tick number, the same seed must be used.

The best way of achieving this is to generate a "base seed" once (on the server) and then combine it with any synchronized variable (or simply current tick number) every time a new seed is needed.

When using predictable randomness, make sure there are no client-only or server-only code fragments where methods of a synchronized generator are called. Matching results can't be achieved for differing sequences of calls.

Case study: Flappy Bird (again)

Let's see how the last example of a Flappy Bird clone can be modified so it becomes fully predictable to players.

using UnityEngine;
using Elympics;
using Random = System.Random;

public class ColumnPool : ElympicsMonoBehaviour, IUpdatable
{
[SerializeField] private Column[] columns;

private ElympicsInt _initialSeed = new ElympicsInt(new Random().Next());

// [...]

public void ElympicsUpdate()
{
var rng = new Random(CombineSeed(_initialSeed.Value, unchecked((int)Elympics.Tick)));

foreach (var column in columns)
if (column.Position < -0.5f)
{
column.Position = 0.5f;
column.HoleLocation = (float)rng.NextDouble() - 0.5f;
}
}
}
Combining seeds

Don't use Combine or Add methods of System.HashCode to combine initial seed with some other value as the underlying algorithm uses a random number.

System.Random is used here on purpose – an independent instance of random generator is a simple way to ensure that no other script affects the sequence of calls.

As there is no method for replacing the internal seed of System.Random, a new instance is created every tick. The initial seed is sent to clients in the first snapshot so there's no unnecessary reconciliation.

Having implemented predictable randomization, we can now set "Predictable for" option of the pool object to "All":

  • Column pool (with ColumnPool script) Predictable for: All
    • Column (with Column script) Predictable for: All
    • Column (with Column script) Predictable for: All
  • Bird0 (with Bird script) Predictable for: Player 0
  • Bird1 (with Bird script) Predictable for: Player 1

Case study: Horizontally scrolling platformer

Moving on to more complex cases, we'll take a look on a platformer divided into sections that can be visited by players repeatedly. In the example, each section takes up the whole screen and player can move to neighboring ones by approaching side edges.

During a single run, sections will preserve their structure thanks to a common seed generated at the start of the game. As generation will yield the same results on both on client and server instances, there's no need to synchronize the environment (given it can't be modified by players).

using UnityEngine;
using Elympics;
using System.Collections.Generic;
using System.Linq;
using Random = System.Random;

public class SectionGenerator : ElympicsMonoBehaviour
{
[SerializeField] private SectionConfig config;
[SerializeField] private PlatformPool platformPool;
[SerializeField] private PlayerManager playerManager;

private ElympicsInt _initialSeed = new ElympicsInt(new Random().Next());

private Dictionary<int, Section> _sections;

// [...]

public void SubscribeToPlayers()
{
if (Elympics.IsServer)
foreach (var player in playerManager.Players)
player.SectionChanged += GenerateSection;
else
playerManager.CurrentPlayer.SectionChanged += GenerateSection;
}

private void GenerateSection(int playerId, int sectionIndex)
{
var rng = new Random(CombineSeed(_initialSeed, sectionIndex));

FreeUnusedSections();

var section = new Section(config.LevelCount);
foreach (var level in section.Levels)
{
var platformCount = rng.Next(config.MaxPlatformsInLevel) + 1;

var platformProportions = new int[platformCount];
for (var i = 0; i < platformCount; i++)
platformProportions[i] = rng.Next(5) + 1;

for (var i = 0; i < platformCount; i++)
{
var length = (1f - platformCount * config.PlatformSpacing) * platformProportions[i] / platformProportions.Sum();

var platform = platformPool.Get();
platform.SetParameters(sectionIndex, length);
level.Add(platform);
}
}

// [...]
}

// [...]
}

To make the example more entertaining, we can additionally spawn collectibles at random. After appearing on the scene, items remain there until collected. Because players can interact with those items, synchronization is inevitable.

using UnityEngine;
using Elympics;
using Random = System.Random;

public class CollectibleSpawner : ElympicsMonoBehaviour, IUpdatable
{
[SerializeField] private SectionConfig config;

[SerializeField] private string prefabName;

private ElympicsInt _initialSeed = new ElympicsInt(new Random().Next());
private const double SpawningChance = 0.05;

public void ElympicsUpdate()
{
if (Elympics.Tick % 30 > 0)
return;

var rng = new Random(CombineSeed(_initialSeed.Value, unchecked((int)Elympics.Tick)));
if (rng.NextDouble() > SpawningChance)
return;

var sectionIndex = rng.Next(config.SectionCount);
var level = rng.Next(config.LevelCount);
var position = (float)rng.NextDouble();

var collectible = ElympicsInstantiate(prefabName, ElympicsPlayer.All);
collectible.transform.SetParent(transform);
collectible.GetComponent<Collectible>().SetLocation(sectionIndex, level, position);
}

// [...]
}

Resources

  • playable closed-source Bouncy Ride, where similar mechanics are implemented