This document is about: QUANTUM 3
SWITCH TO

Testing

Introduction

A Quantum simulation is deterministic. The same input, assets and configs produce the same result on every machine and in every run. This makes simulation code well suited for automated tests: there is no timing or randomness to work around, a test either passes every time or fails every time.

Quantum code can be tested on several levels, from fast and isolated to slow and complete:

Level Tool Runs In Good For
Unit tests FrameEmulator Edit Mode, dotnet test Single systems, signals, commands, physics setups
Complete simulation SessionRunner in local mode Play Mode, dotnet test The whole game with its configs, QuantumGame callbacks, prediction
Regression and determinism Replays and checksums Unity, .NET console runner Detecting desyncs and unintended behavior changes
Multiplayer QuantumMultiClientRunner, MPPM, standalone builds Play Mode, online Connection flow, late joining, reconnecting, prediction and view issues

Unit Testing With The FrameEmulator

The Quantum.Testing.FrameEmulator is available in Quantum 3.1 and later.

What The FrameEmulator Is

The FrameEmulator creates and steps a single verified Frame without a QuantumGame, a DeterministicSession or a runner. It only runs the systems that are passed to it, using the same system initialization and scheduling code as QuantumGame.

It does not cover what the session does: prediction and rollbacks, input prediction, delta compressed input, checksums, snapshots, event confirmation and the RuntimePlayer serialization. Test these with a complete simulation, see Testing The Complete Simulation With Runners.

Exceptions thrown by systems, signals or tasks are not caught and logged like in a session. They are thrown from the constructor (system initialization) or from Step() after all systems of the stage ran, see Exceptions.

Sample Tests

The SDK ships two sample test assemblies, which run as Edit Mode tests in the Unity Test Runner (Window > General > Test Runner):

  • Assets/Photon/Quantum/Tests/Runtime/Quantum.Simulation.Tests.asmdef with QuantumSimulationTests.cs
  • Assets/Photon/QuantumAsteroids/Tests/Runtime/Asteroids.Unity.Tests.asmdef with AsteroidsTests.cs

The Quantum.Simulation.Tests sources are also exported with the .NET project (Quantum.Simulation.Dotnet.Tests) and run outside of Unity with dotnet test.

Setting Up A Test Project

Unity

  1. Add the Unity Test Framework package com.unity.test-framework with the Package Manager.
  2. Create a test assembly with Assets > Create > Testing > Tests Assembly Folder, for example in Assets/QuantumUser.
  3. Select the created assembly definition and add Quantum.Simulation to the Assembly Definition References.
  4. Add Quantum.Engine.dll and Quantum.Log.dll to the Assembly References.
  5. Enable Allow 'unsafe' Code.
  6. Set Platforms to Editor only, otherwise the tests are listed as Play Mode tests.

.NET

  1. Generate the simulation project with the QuantumDotnetBuildSettings asset, see Exporting Simulation. By default it is created in the Quantum.Dotnet folder of the Unity project. The export also creates a default NUnit test project, Quantum.Simulation.Dotnet.Tests, which runs the sample tests of the SDK. It is overwritten by every export, copy it as a starting point for the game's own test project, or set one up manually with the following steps.

  2. Create an NUnit test project and add it to the solution:

    Bash

    cd Quantum.Dotnet
    dotnet new nunit --name MyGame.Tests --framework net10.0
    dotnet sln add MyGame.Tests
    
  3. Add the references to MyGame.Tests.csproj: the simulation project and the Quantum dlls, which the simulation project does not pass on. dotnet add reference only adds projects, so the dlls are added by hand. The tests also need unsafe code enabled.

    XML

    <PropertyGroup>
      <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
    </PropertyGroup>
    
    <ItemGroup>
      <ProjectReference Include="../Quantum.Simulation.Dotnet/Quantum.Simulation.Dotnet.csproj" />
    </ItemGroup>
    
    <ItemGroup>
      <Reference Include="Quantum.Log">
        <HintPath>../Lib/$(Configuration)/Quantum.Log.dll</HintPath>
      </Reference>
      <Reference Include="Quantum.Engine">
        <HintPath>../Lib/$(Configuration)/Quantum.Engine.dll</HintPath>
      </Reference>
    </ItemGroup>
    
  4. Optionally compile the Unity tests of the project into the test project as well, so they run in both:

    XML

    <ItemGroup>
      <Compile Include="../../Assets/QuantumUser/Tests/**/*.cs" LinkBase="QuantumUser.Tests" />
    </ItemGroup>
    

    Caveat: Tests that use Unity APIs, for example the QuantumUnityDB, do not compile outside of Unity. To run with the project assets outside of Unity, export them with Tools > Quantum > Export > Asset Database and load the file into a ResourceManagerStatic passed as Args.ResourceManager, see QuantumConsoleRunner in the Quantum.Runner.Dotnet project.

  5. Run the tests with dotnet test MyGame.Tests.

A First Test

The test creates a dynamic rigidbody and checks that gravity moved it after one tick. Physics needs a map and the physics system, an empty map is enough here.

C#

namespace MyGame.Tests {
  using NUnit.Framework;
  using Photon.Deterministic;
  using Quantum;
  using Quantum.Core;
  using Quantum.Testing;
  using Assert = NUnit.Framework.Assert;

  public unsafe class PhysicsTests {
    [Test]
    public void Rigidbody_FallsInTheFirstTick() {
      using var emulator = FrameEmulator.Create(AssetObject.Create<Map>(), new PhysicsSystem3D());
      var frame = emulator.Frame;

      var ball = frame.Create();
      frame.Add(ball, Transform3D.Create(new FPVector3(0, 10, 0)));
      frame.Add(ball, PhysicsCollider3D.Create(frame, Shape3D.CreateSphere(FP._0_50)));
      frame.Add(ball, PhysicsBody3D.CreateDynamic(FP._1));

      emulator.Step();

      Assert.That(frame.Get<Transform3D>(ball).Position.Y < 10);
    }
  }
}

The built-in systems like PhysicsSystem3D are in the Quantum.Core namespace. Quantum.Log also defines an Assert class, the alias using Assert = NUnit.Framework.Assert; resolves the ambiguity. The following samples leave out the using directives.

FrameEmulator.Create() creates an emulator with a default SimulationConfig and the map and systems passed to it. emulator.Frame is the emulated frame, entities and components are created and read with the regular frame API. Step() simulates one tick, Step(ticks) several.

Always dispose the emulator (using), this frees the frame and unloads the assets of the emulator.

Input

SetInput() sets the input of a player. The input is kept for all following steps until it is set again.

C#

[Test]
public void Ship_MovesUpOnInput() {
  using var emulator = FrameEmulator.Create(new MovementSystem());
  var frame = emulator.Frame;

  var ship = frame.Create();
  frame.Add(ship, Transform2D.Create());

  emulator.SetInput(0, new Input { Up = true });
  emulator.Step(10);

  Assert.That(frame.Get<Transform2D>(ship).Position.Y > 0);
}

The emulator simulates a single player by default. For more players, pass a DeterministicSessionConfig with a higher PlayerCount, otherwise SetInput(), AddPlayer() and AddCommand() throw for player indices larger than 0:

C#

using var emulator = new FrameEmulator(new FrameEmulator.Args {
  Systems = new SystemBase[] { new MovementSystem() },
  SimulationConfig = FrameEmulator.CreateSimulationConfig(),
  SessionConfig = new DeterministicSessionConfig { PlayerCount = 2 },
});

emulator.SetInput(0, new Input { Up = true });
emulator.SetInput(1, new Input { Left = true });

Players

AddPlayer() marks a player as connected and sets its RuntimePlayer data during the next step, which raises ISignalOnPlayerAdded. RemovePlayer() marks the player as disconnected and raises ISignalOnPlayerRemoved during the next step. Like in a session the RuntimePlayer data is kept after the player is removed.

Add PlayerConnectedSystem to the systems when the code relies on frame.PlayerConnectedCount.

C#

[Test]
public void AddPlayer_IsAppliedInTheNextStep() {
  using var emulator = FrameEmulator.Create(new PlayerConnectedSystem());
  var frame = emulator.Frame;

  var runtimePlayer = new RuntimePlayer { PlayerNickname = "Player0" };
  emulator.AddPlayer(0, runtimePlayer);

  Assert.That(frame.GetPlayerData(0), Is.Null);

  emulator.Step();

  Assert.That(frame.GetPlayerData(0), Is.SameAs(runtimePlayer));
  Assert.That(frame.PlayerConnectedCount, Is.EqualTo(1));
}

Commands

AddCommand() adds a command of a player to the next step only. The emulator takes the ownership of the command. The command type has to be registered in CommandSetup.User.cs like for a session.

C#

[Test]
public void SpawnCommand_SpawnsEnemy() {
  using var emulator = FrameEmulator.Create(new SpawnSystem());
  var frame = emulator.Frame;

  emulator.AddCommand(0, new CommandSpawnEnemy());
  emulator.Step();

  Assert.AreEqual(1, frame.ComponentCount<Enemy>());
}

Events

emulator.Events holds the events raised during the last Step(), or during the system initialization if no step has been executed yet. The event objects are pooled and only valid until the next Step().

C#

emulator.Step();

var scored = emulator.Events.OfType<EventPlayerScored>().Single();
Assert.AreEqual((PlayerRef)0, scored.Player);

Assets And Configs

Assets that systems need in OnInit() are passed in Args.Assets. Assets without a valid guid get one assigned. Game specific configs are set on the RuntimeConfig:

C#

[Test]
public void MapSystem_WrapsEntityToTheOtherSide() {
  var gameConfig = AssetObject.Create<AsteroidsGameConfig>(config => {
    config.Guid = AssetGuid.NewGuid();
    config.GameMapSize = new FPVector2(25, 25);
  });

  using var emulator = new FrameEmulator(new FrameEmulator.Args {
    Systems = new SystemBase[] { new AsteroidsMapSystem() },
    SimulationConfig = FrameEmulator.CreateSimulationConfig(),
    RuntimeConfig = new RuntimeConfig { GameConfig = gameConfig },
    Assets = new AssetObject[] { gameConfig },
  });
  var frame = emulator.Frame;

  // half a unit beyond the right edge of the 25x25 map
  var entity = frame.Create();
  frame.Add(entity, Transform2D.Create(new FPVector2(13, 0)));

  emulator.Step();

  Assert.AreEqual(-FP._0_50 * 25, frame.Get<Transform2D>(entity).Position.X);
}

After the emulator is created, CreateAndAddAsset<T>() creates and adds further assets. They can be used from the next Step() on, but are not available in OnInit() anymore.

FrameEmulator.CreateSimulationConfig() returns a SimulationConfig with default settings. Args.FallbackResourceManager is asked for all assets the emulator does not have itself.

Physics And Maps

Physics needs a map and the physics systems, which are added explicitly like all other systems (PhysicsSystem2D or PhysicsSystem3D). An empty map is enough for dynamic colliders created in code:

C#

[Test]
public void Rigidbody_FallsOntoPlane() {
  using var emulator = FrameEmulator.Create(
    map: AssetObject.Create<Map>(),
    systems: new PhysicsSystem3D());
  var frame = emulator.Frame;

  // a static 20x20 plane at y = 0
  var planeEntity = frame.Create();
  frame.Add(planeEntity, Transform3D.Create(new FPVector3(0, -FP._0_50, 0)));
  frame.Add(planeEntity, PhysicsCollider3D.Create(frame, Shape3D.CreateBox(new FPVector3(FP._10, FP._0_50, FP._10))));

  // a dynamic ball two units above the plane
  var ballEntity = frame.Create();
  frame.Add(ballEntity, Transform3D.Create(new FPVector3(0, FP._2, 0)));
  frame.Add(ballEntity, PhysicsCollider3D.Create(frame, Shape3D.CreateSphere(FP._0_50)));
  frame.Add(ballEntity, PhysicsBody3D.CreateDynamic(FP._1));

  // two seconds at 64 Hz
  emulator.Step(128);

  // resting on the plane: the center is one radius above it, minus the penetration allowance
  var expected = FP._0_50 - frame.SimulationConfig.Physics.PenetrationAllowance;
  var y = frame.Get<Transform3D>(ballEntity).Position.Y;
  Assert.That(FPMath.Abs(y - expected) <= FP.EN2, $"ball at y {y}, expected {expected}");
}

Static colliders are defined on the map, for example map.StaticColliders3D. They are loaded into the physics engine during the first step, so physics queries like frame.Physics3D.Raycast() find them only after Step() was called once.

A baked map of the project, including its navmeshes, is used with the project assets, see the next section.

Testing With The Project Assets

To run the game with the assets of the Unity project, pass QuantumUnityDB.Global as Args.ResourceManager. The systems are created from the SystemsConfig asset with DeterministicSystemSetup.CreateSystems(), the same way a session creates them.

C#

[Test]
public void AddedPlayer_ShipSpawnsAndShoots() {
  var runtimeConfig = new RuntimeConfig {
    Map = QuantumUnityDB.GetGlobalAssetGuid("Photon/QuantumAsteroids/Resources/AsteroidsMap"),
    SimulationConfig = QuantumUnityDB.GetGlobalAssetGuid("Photon/QuantumAsteroids/Resources/AsteroidsSimulationConfig"),
    SystemsConfig = QuantumUnityDB.GetGlobalAssetGuid("Photon/QuantumAsteroids/Resources/AsteroidsSystemsConfig"),
    GameConfig = QuantumUnityDB.GetGlobalAssetGuid("Photon/QuantumAsteroids/Resources/AsteroidsGameConfigAsset"),
    DefaultPlayerAvatar = QuantumUnityDB.GetGlobalAssetGuid("Photon/QuantumAsteroids/Resources/AsteroidsShipEntityPrototype"),
  };
  var simulationConfig = QuantumUnityDB.GetGlobalAsset(runtimeConfig.SimulationConfig);
  var systemsConfig = QuantumUnityDB.GetGlobalAsset(runtimeConfig.SystemsConfig);
  var gameConfig = QuantumUnityDB.GetGlobalAsset(runtimeConfig.GameConfig);

  using var emulator = new FrameEmulator(new FrameEmulator.Args {
    // the systems of the SystemsConfig asset, like a session creates them
    Systems = DeterministicSystemSetup.CreateSystems(runtimeConfig, simulationConfig, systemsConfig).ToArray(),
    SimulationConfig = simulationConfig,
    RuntimeConfig = runtimeConfig,
    ResourceManager = QuantumUnityDB.Global,
  });
  var frame = emulator.Frame;

  // the wave spawner spawns the first wave in OnInit
  Assert.AreEqual(gameConfig.InitialAsteroidsCount, frame.ComponentCount<AsteroidsAsteroid>());

  emulator.AddPlayer(0, new RuntimePlayer());
  emulator.SetInput(0, new Input { Fire = true });
  emulator.Step();

  Assert.AreEqual(1, frame.ComponentCount<AsteroidsShip>(), "ship spawned for the player");
  Assert.AreEqual(1, frame.ComponentCount<AsteroidsProjectile>(), "ship fired");
}

With an external resource manager the Map has to be one of its assets. Args.Assets, Args.FallbackResourceManager and CreateAndAddAsset() are not supported.

QuantumUnityDB is only available inside Unity, tests like this one do not run in the Quantum.Simulation.Dotnet.Tests project.

Exceptions

An exception thrown by a system fails the test, the emulator does not catch it. Expected exceptions are tested with Assert.Throws():

C#

var exception = Assert.Throws<InvalidOperationException>(() => emulator.Step());

Exceptions are thrown after all systems of the stage ran, so the other systems are still updated. When several systems throw, they are thrown together as an AggregateException. Do not step the emulator anymore after an exception.

Tips

  • Systems that use frame.RNG produce the same results in every run, set RuntimeConfig.Seed to test other random sequences.
  • To test determinism, run two emulators with the same input and compare their frames, also with Args.ThreadCount larger than 1. The emulator does not calculate checksums itself, frame.CalculateChecksum() is only needed for this kind of manual comparison.
  • System instances can not be shared between emulators, create new ones for each emulator.
  • Systems created with StartEnabled = false are enabled in the test with frame.SystemEnable<T>().
  • The emulator is not limited to tests, it is also useful for benchmarks and tools that need to run simulation code.

Testing The Complete Simulation With Runners

The FrameEmulator covers the simulation code, but not the session. Prediction and rollbacks, QuantumGame callbacks, event confirmation, view code and snapshots are tested by running a complete local game with a SessionRunner. QuantumRunnerLocalDebug is a good reference for how a local game is started.

Unity

The steps of a runner based Play Mode test:

  1. Create the SessionRunner.Arguments with InitForLocal(runtimeConfig). This sets the game mode to local and fills in the Unity defaults: runner factory, task runner, resource manager, dispatchers and the default SessionConfig. Set the RuntimeConfig assets (map, SimulationConfig, SystemsConfig) explicitly, otherwise the default configs and the map of the open scene are used.
  2. Start the game with SessionRunner.StartAsync() and wait for the task to complete.
  3. Add players with runner.Game.AddPlayer().
  4. Provide input by subscribing to CallbackPollInput.
  5. Let the simulation run by waiting in Play Mode. For tests that should not depend on the Unity frame rate, disable the session update (runner.IsSessionUpdateDisabled) and call runner.Service(deltaTime) with a fixed delta time.
  6. Assert on runner.Game.Frames.Verified, predicted frames generally require an online session.
  7. Shut down the runner and dispose the subscriptions, also when the test fails.

C#

public class GameplayTests {
  [TearDown]
  public void TearDown() {
    QuantumRunner.ShutdownAll();
  }

  [UnityTest]
  public IEnumerator Ship_MovesUpOnInput() {
    var runtimeConfig = new RuntimeConfig {
      Map = QuantumUnityDB.GetGlobalAssetGuid("<path>"),
      SimulationConfig = QuantumUnityDB.GetGlobalAssetGuid("<path>"),
      SystemsConfig = QuantumUnityDB.GetGlobalAssetGuid("<path>"),
    };

    var arguments = new SessionRunner.Arguments();
    arguments.InitForLocal(runtimeConfig);
    arguments.PlayerCount = 1;

    var startTask = SessionRunner.StartAsync(arguments);
    yield return new WaitUntil(() => startTask.IsCompleted);
    var runner = (QuantumRunner)startTask.Result;

    using var inputSubscription = QuantumCallback.SubscribeManual<CallbackPollInput>(callback => {
      callback.SetInput(new Input { Up = true }, DeterministicInputFlags.Repeatable);
    });

    runner.Game.AddPlayer(0, new RuntimePlayer());

    yield return new WaitForSeconds(1);

    var frame = runner.Game.Frames.Verified;
    // assert on the frame
  }
}

A Play Mode test assembly is set up like the Edit Mode one, but keeps all platforms.

Savegames and snapshots are tested by starting the runner from a snapshot, see QuantumRunnerLocalSavegame and Starting From Snapshot.

.NET

The complete simulation also runs outside of Unity, in a .NET test project (see Setting Up A Test Project). The console runner of the exported project (QuantumConsoleRunner in Quantum.Runner.Dotnet) is a good reference, it runs replays the same way.

The differences to Unity:

  • There are no Unity defaults, the runner factory, task runner, serializer, callback dispatcher and resource manager are set explicitly.
  • The QuantumJsonSerializer needs a reference to Quantum.Json.dll from the Lib folder and the Newtonsoft.Json package.
  • runner.Service(deltaTime) advances the simulation by deltaTime, runner.Service() without an argument uses the real time.
  • Players can only be added after the first verified frame was simulated.
  • Quantum logs nothing until a logger is initialized, for example with Log.InitializeForConsole().

The additional references in the test project:

XML

<ItemGroup>
  <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>

<ItemGroup>
  <Reference Include="Quantum.Json">
    <HintPath>../Lib/$(Configuration)/Quantum.Json.dll</HintPath>
  </Reference>
</ItemGroup>

The sample creates its configs in code. Without a SystemsConfig the session runs the default systems, which include the PlayerConnectedSystem.

C#

[Test]
public void AddedPlayer_IsConnected() {
  Log.InitializeForConsole(LogLevel.Info);

  var simulationConfig = AssetObject.Create<SimulationConfig>(config => {
    config.Guid = AssetGuid.NewGuid();
    config.InitMissingDefaults();
  });
  var map = AssetObject.Create<Map>(asset => asset.Guid = AssetGuid.NewGuid());
  using var resourceManager = new ResourceManagerStatic(new AssetObject[] { simulationConfig, map });
  var callbackDispatcher = new CallbackDispatcher();

  var runner = SessionRunner.Start(new SessionRunner.Arguments {
    RunnerFactory = new DotNetRunnerFactory(),
    TaskRunner = new DotNetTaskRunner(),
    AssetSerializer = new QuantumJsonSerializer(),
    CallbackDispatcher = callbackDispatcher,
    ResourceManager = resourceManager,
    GameMode = DeterministicGameMode.Local,
    SessionConfig = new DeterministicSessionConfig(),
    RuntimeConfig = new RuntimeConfig { SimulationConfig = simulationConfig, Map = map },
    PlayerCount = 1,
  });

  try {
    using var inputSubscription = callbackDispatcher.SubscribeManual<CallbackPollInput>(callback => {
      callback.SetInput(new Input(), DeterministicInputFlags.Repeatable);
    });

    var game = (QuantumGame)runner.DeterministicGame;
    while (game.Frames.Verified == null) {
      runner.Service(1.0 / 64);
    }

    var startFrame = game.Frames.Verified.Number;
    game.AddPlayer(0, new RuntimePlayer());

    // one tick per call at the default simulation rate of 64 Hz
    for (int i = 0; i < 10; ++i) {
      runner.Service(1.0 / 64);
    }

    var frame = game.Frames.Verified;
    Assert.That(frame.Number, Is.EqualTo(startFrame + 10));
    Assert.That(frame.PlayerConnectedCount, Is.EqualTo(1));
  } finally {
    runner.Shutdown();
  }
}

To run the game with the project assets, export them with Tools > Quantum > Export > Asset Database and load them with new ResourceManagerStatic(serializer.AssetsFromByteArray(File.ReadAllBytes(path))), the asset guids are found by their path with resourceManager.GetAssetGuid().

Replays As Regression And Determinism Tests

A replay contains everything to run a game again: the configs, the assets and the input of all players. Optionally it also contains the checksums of the recorded game. Played back with the same code, the replay produces the same checksums. When it does not, the code is either not deterministic or its behavior changed.

How to record and play back replays is explained in Replays. For testing:

  • Record replays with checksums and keep them together with the test data.
  • Play them back after code changes, in Unity with QuantumRunnerLocalReplay or headless with the .NET console runner, and verify the checksums.
  • Play back a replay recorded on another platform or build type (mobile and desktop, Mono and IL2CPP) to test cross platform determinism.

Any intended gameplay change makes the old replays produce different checksums. Record them again after such changes and keep them versioned with the game.

Testing Snapshots

Late joins and reconnects start the simulation from a serialized frame. Game state that is not part of the frame, for example static fields or data stored outside of it, gets lost on the way and the game behaves differently afterwards.

  • Instant replays start a second runner from a snapshot of the running game and play the recorded input again, so the replay differs from the live game when state is missing. Add the QuantumInstantReplayDemo component to the scene, see Instant Replays.
  • Tests that need a certain game situation can start from a common snapshot instead of playing until it happens or relying on a snapshot provided by a late join. Save one with Tools > Quantum > Export > Snapshot (Integrated Asset DB) and start it with the Snapshot File of QuantumRunnerLocalDebug, see Starting From Snapshot.

Debugging Desyncs

A desync happens when the simulation of one client differs from the others. Quantum detects desyncs with checksums of the verified frames. They are calculated every ChecksumInterval frames, set in the SessionConfig asset (DeterministicSessionConfig), no code is needed. The default is 64, set it to 0 to disable checksums, for example in release builds. The session calculates the checksums and the server compares them.

When the checksums of a client differ, CallbackChecksumError is raised and a frame dump is created. The Frame Differ window (Tools > Quantum > Window > Frame Differ) shows the differences between the frames of the clients. The SimulationConfig settings ChecksumSnapshotHistoryLengthSeconds and ChecksumErrorDumpOptions control what the dump contains.

Forcing A Desync

A desync can be forced to verify this setup before a real desync happens: checksums are enabled, CallbackChecksumError is handled and the frame dump shows up in the Frame Differ. Start an online game with two clients and change the verified frame on one client only:

C#

[MenuItem("Tools/Quantum/Desync Now")]
static unsafe void DesyncNow() {
  var frame = QuantumRunner.Default?.Game?.Frames?.Verified;
  if (frame == null) {
    return;
  }

  // moves one entity on this client only, the next checksum won't match the other clients
  foreach (var it in frame.Unsafe.GetComponentBlockIterator<Transform3D>()) {
    it.Component->Position.X += FP._1;
    return;
  }

  foreach (var it in frame.Unsafe.GetComponentBlockIterator<Transform2D>()) {
    it.Component->Position.X += FP._1;
    return;
  }
}

Set ChecksumInterval to 1 to detect the desync right away.

Common Causes

The most common causes of desyncs are listed in the FAQ: Why is my simulation desync-ing?.

Multiple Clients In One Unity Editor

The QuantumMultiClientRunner runs several online clients in one Unity Editor. Each client has its own QuantumGame, view and input.

This shows the local and the remote view of a player side by side and helps finding mispredictions and view issues. Clients can be added during the game to test late joining. To make latency visible, connect to a region that is far away.

The setup and the controls are explained in Multi-Client Runner.

Multiple Unity Editors And Builds

Multiplayer Play Mode

With Unity 6, Multiplayer Play Mode (MPPM) runs virtual players next to the main Editor. The Quantum sample menu connects all virtual players to the same room with a single click in the main Editor. See Multiplayer Play Mode.

Editor And Standalone Builds

The regular setup is the Unity Editor plus one or more standalone builds of the same project, running side by side.

The StartUI supports this by default. Its default app version (AppVersionMachineId) is unique per machine and checkout, so only clients built on the same machine are matched together, see StartUI App Version. To test against clients on other machines, set the same app version on all clients with AppVersionOverride.

The QuantumMultiClientRunner uses its own private app version, which it stores in the PlayerPrefs under QuantumMultiClientRunner.AppVersionPlayerPrefsKey. Its clients do not find clients started from the StartUI or on other machines, which is not a connection problem. To connect them, set the same app version on all clients: PrivateAppVersion on the QuantumMultiClientRunner and AppVersionOverride on the StartUI.

Testing Editor against Editor, for example on two machines, can lead to desyncs when the code or the assets differ: uncommitted changes, unsaved or reimported assets or a different asset database. Prefer testing the Editor against a build of the same project, or builds only.

Testing Network Conditions

Prediction, rollbacks and their effect on the view only show under real network conditions.

  • The Quantum Graph Profiler shows ping, prediction and simulated frames and offers controls to simulate lag, jitter and packet loss, see Profiling.
  • Photon Realtime simulates lag, jitter and loss in code with RealtimePeer.IsSimulationEnabled and RealtimePeer.NetworkSimulationSettings, which is what the Graph Profiler controls use.
  • External network condition emulators such as clumsy (Windows) or the Network Link Conditioner (macOS, iOS) degrade the whole connection of a machine or device, including builds that have no debug UI.
  • Late joining and reconnecting are tested with a second client that joins a running game or loses its connection, see Reconnecting.
  • Clients that are not controlled by a person can send random or recorded input (see QuantumDebugInput) to keep the simulation busy.

Forcing Predictions In Local Sessions

A local session has all input right away, so every frame is verified and nothing is predicted or rolled back. To test view code and callbacks under rollbacks without a server, limit the verified ticks per update so the verified frame stays a few ticks behind. Each update then rolls back and simulates the predicted frames again.

C#

using Photon.Deterministic;
using Quantum;

public class ForcePredictions : QuantumMonoBehaviour {
  public int VerifiedLag = 4;

  void OnEnable() {
    QuantumCallback.Subscribe(this, (CallbackBeforeSimulationStage callback) => {
      var context = callback.SimulatorContext;
      if (context.Stage == SimulatorStage.BeforeVerification) {
        context.TicksToVerify = context.MaxVerifiableTick - context.VerifiedFromTick - VerifiedLag;
      }
    });
  }

  void OnDisable() {
    QuantumCallback.UnsubscribeListener(this);
  }
}

The input is the same in the predicted and the verified frames, so predictions are never wrong, but the rollbacks happen. Disable the QuantumRunnerRollbackPolicy of the StartUI while testing this, it also sets TicksToVerify.

Last updated on

Back to top