Tutorial: Move Characters with Rollback Sync

Steps to have the server calculate character positions from inputs while your own character moves without delay through prediction

In this tutorial, you build a room in which each player moves a character with inputs.

  • When you connect, one character of your own is spawned.
  • With the arrow keys (WASD), it moves at 3 m/s within an 18 m × 10 m area viewed from above.
  • The room calculates positions from everyone’s inputs 30 times per second. Clients cannot write positions.
  • Your own character moves the instant you press a key, without waiting for the room’s result.

Your own character is displayed first by running the same movement logic as the room on the client (prediction). When the room’s result arrives, the client rewinds to that point and recalculates (rollback), and if there is a discrepancy, it corrects it unobtrusively. For how this works, see the GS2-Realtime introduction.

You use the stateMachine server type. Registering the rules follows the same steps as Tutorial: Build an Enemy Whose HP Is Managed by the Server, so reading that first makes this easier to follow.

Workflow

StepWhere you workWhat you build
1. Write the rulesText editor / IDEC# with characters moved by ticks and the movement logic to predict
2. GenerateUnity Editormain.gsl, the sync schema, the input slot enum, and the prediction C#
3. Register the definitionGS2StateMachineMaster and the Realtime namespace settings
4. Create the character PrefabUnity EditorA Prefab that displays the position
5. Connect to the roomUnity EditorA scene that registers the prediction and sends inputs
6. Verify the behaviorUnity (2 clients)—

You work in a Unity project (Unity 2021.3 or later) with the GS2 SDK for Unity and a version of LocalStateMachineKit that includes the C# generation feature.

1. Write the Rules

Outside your Unity project’s Assets, create the following folders and files. The C# you write here is not code that runs in Unity; it is input for generating GSL.

GameRules/
└── RunnerRoom/
    ├── project.json
    └── Rules/
        └── RunnerRoom.cs

Write the room rules in RunnerRoom.cs.

using Gs2.LocalStateMachine.Authoring;
using Step = Gs2.LocalStateMachine.Authoring.Step<
    GameRules.RunnerState, GameRules.RunnerResult, GameRules.RunnerError>;

namespace GameRules;

// A player's character. Prefab ID 10; delivers position (integers in units of 0.01)
[RoomSchema(10, StandardFields = RoomSync.Position)]
public enum Runner
{
    PositionX = 0,
    PositionY = 1,
}

// Input slots. The room clamps them to the range when reading
[RoomInput]
public enum RunInput
{
    [InputRange(-1, 1)] X = 0,
    [InputRange(-1, 1)] Y = 1,
}

public record RunnerState(int Ticks);
public record RunnerResult(bool Finished);
public enum RunnerError { InvalidOperation = RoomResults.InvalidOperation }

public static class RunnerRoom
{
    public static Step Start()
    {
        Room.DefineTickRate(30);               // Run Tick 30 times per second
        Room.DefineSimulatedPrefab<Runner>();  // Players spawn it; the rules move it
        return Step.WaitFor(nameof(Tick), new RunnerState(0));
    }

    public static Step Tick(RunnerState state, int tick)
    {
        int count = Room.ObjectCount<Runner>();
        for (int i = 0; i < count; i++)
            Move(Room.ObjectAt<Runner>(i));
        return Step.Stay(state with { Ticks = state.Ticks + 1 });
    }

    // The owner's client also moves its own character first with the same calculation
    [Predict(typeof(Runner))]
    static void Move(int id)
    {
        // 10 (0.1 m) per tick. The client decides the spawn position, so clamp it to the range first
        int x = RoomMath.Clamp(Room.Get(id, Runner.PositionX), -900, 900);
        int y = RoomMath.Clamp(Room.Get(id, Runner.PositionY), -500, 500);
        Room.Set(id, Runner.PositionX, RoomMath.Clamp(x + Room.Input(id, RunInput.X) * 10, -900, 900));
        Room.Set(id, Runner.PositionY, RoomMath.Clamp(y + Room.Input(id, RunInput.Y) * 10, -500, 500));
    }
}

There are five key points.

  • Define ticks. With DefineTickRate(30), the room runs Tick 30 times per second. It keeps running while the rules wait for input that accepts Tick (Step.WaitFor(nameof(Tick), ...)).
  • Make the characters simulated objects. Kinds registered with DefineSimulatedPrefab<Runner>() are spawned and owned by players, but only the rules can write their values. The owner sends inputs.
  • Give input slots names and ranges. The [RoomInput] enum is also emitted for Unity, so the client can set inputs by the same names. Players can send any value as input, but the room clamps slots marked with [InputRange(-1, 1)] to the range when reading them, so speed cannot be tampered with.
  • Add [Predict] to the movement logic. The compiler also emits this logic as C# for Unity, and the owner’s client uses it to predict its own character. In predicted logic you can use int and bool arithmetic, RoomMath, Room.Get, Set, Input, and so on (what you can use).
  • Calculate with integers. Positions are integers divided by a step of 0.01. 10 is 0.1 m and 900 is 9 m. Because the calculation uses integers, the room and the Unity prediction produce the same results.

2. Generate

Save the generation settings in GameRules/RunnerRoom/project.json.

{
  "format": 1,
  "source": "Rules",
  "output": "../../Assets/Generated/RunnerRoom",
  "hostProfile": "RealtimeRoomV1"
}

From the Unity menu, select GS2 > Local State Machine > Generate from C#, choose this project.json, and press Generate (detailed steps). The following files are generated in Assets/Generated/RunnerRoom.

FilePurpose
main.gslThe rules you register with GS2 in step 3
package.zipA distribution file containing only main.gsl
unity/Runner.realtimeschemaThe sync schema you specify on the character Prefab in step 4
unity/RunnerSchema.csThe Prefab ID (RunnerSchema.Prefab) and functions that read values with types
unity/RunInput.csThe input slot enum. Use it like world.SetInput(RunInput.X, 1)
unity/RunnerRoomPrediction.csThe prediction generated from the [Predict] logic. Registered in step 5
source-map.jsonA development file for tracing generated locations back to the original C#

The C# in unity/ compiles only in Unity. When you change the rules, regenerate the rules (main.gsl) and the files in unity/ together and distribute them. If you combine outputs generated from different rules, NetworkWorld logs a warning.

3. Register the Definition

Register using the same steps as in Tutorial: Build an Enemy Whose HP Is Managed by the Server.

Where to registerFieldValue
GS2-StateMachine (UpdateStateMachineMaster)mainStateMachineNameRunnerRoom (the class name of the rules)
payloadThe contents of main.gsl
GS2-Realtime namespaceserverTypestateMachine
stateMachineSetting.namespaceIdThe GS2-StateMachine namespace where you registered the rules

4. Create the Character Prefab

Create a Prefab for the character’s appearance (a Sprite or Quad, for example), and add the following components to its root. The arena, viewed from above, lies on the XY plane.

ComponentSetting
NetworkEntitySpecify unity/Runner.realtimeschema for Schema
NetworkTransformPosition Axes is X and Y, Rotation Axes is None

Do not add a movement script. NetworkTransform sets the position from the room’s calculation results (other players’ characters) or from the prediction results (your own character). For simulated objects, even the owner cannot write the position.

5. Connect to the Room

Place a NetworkWorld

  1. Create a catalog with Create > GS2 > Realtime > Prefab Catalog, and register Id 10 and the Prefab from step 4.
  2. Create an empty GameObject in the scene, add NetworkWorld, and specify the catalog for Prefab Catalog.
  3. Place a Camera (Orthographic, Size about 6) that faces the XY plane head-on.

Set NetworkWorld’s View Delay to the same value as NetworkTransform’s Interpolation Delay (both default to 0.1 seconds). The room uses this delay to perform hit detection as the shooter saw it (lag compensation).

Connect and Send Inputs

Add NetworkSession and the following script to the same GameObject as the NetworkWorld. Obtain the room’s connection information and the access token as described in Obtaining connection information for the game server, and pass them in.

using GameRules; // The namespace of the generated RunInput.cs, RunnerSchema.cs, and RunnerRoomPrediction.cs
using Gs2.Realtime.Sync;
using UnityEngine;

[RequireComponent(typeof(NetworkWorld), typeof(NetworkSession))]
public sealed class RunnerRoomClient : MonoBehaviour
{
    NetworkWorld world;
    NetworkSession session;

    void Awake()
    {
        world = GetComponent<NetworkWorld>();
        session = GetComponent<NetworkSession>();
        // Register the prediction generated from the rules' [Predict] (before or after connecting)
        RunnerRoomPrediction.Register(world);
        // Once the room state is applied, spawn your character (create it again on each reconnect)
        session.Ready += () => world.Instantiate(
            RunnerSchema.Prefab,
            new Vector3(Random.Range(-5f, 5f), Random.Range(-3f, 3f), 0),
            Quaternion.identity);
        session.Disconnected += reason => Debug.Log("Disconnected: " + reason);
    }

    public void Connect(string accessToken, string ipAddress, int port, string encryptionKey)
    {
        session.Connect(accessToken, ipAddress, port, encryptionKey);
    }

    // Do not write positions; set the current state of the inputs. NetworkWorld sends them every tick
    void Update()
    {
        world.SetInput(RunInput.X, Mathf.RoundToInt(Input.GetAxisRaw("Horizontal")));
        world.SetInput(RunInput.Y, Mathf.RoundToInt(Input.GetAxisRaw("Vertical")));
    }
}

There are three key points.

  • Register the prediction. When you call RunnerRoomPrediction.Register(world), characters you own show the result of each input tick immediately. It works without registering, but movement is delayed until the room’s result arrives (by one round trip).
  • Inputs set the “current state”. SetInput only sets the state while a key is held; NetworkWorld does the sending, in step with the room’s ticks. Each send carries the inputs of the last 3 ticks, so a lost UDP datagram is covered.
  • The client decides the spawn position. The rules’ Move clamps it to the range before using it, so sending a position outside the range causes no problem.

6. Verify the Behavior

Connect to the same room from two clients with different userIds.

  1. Confirm that both screens show two characters.
  2. Press a key on one client, and confirm that your own character starts moving immediately on that screen. On the other screen, it moves smoothly after a short delay.
  3. Move it to the edge of the area, and confirm that it stops at the same position (±9 m horizontally, ±5 m vertically) on both screens.
  4. Confirm that your own character moves without delay even in an environment with increased network latency.

You can read the values received from the room with NetworkObject.Values, and the predicted values with PredictedValues. Objects subject to prediction have IsPredicted set to true.

Going Further

The SDK’s Assets/Scenes/Samples/Realtime/Arena contains a sample that builds on this tutorial.

What you want to doAPI to useDetails
Hit what the shooter sawDefineHitbox, Room.Raycast, Room.OverlapLag compensation
Display bullets the instant they are firedRoom.SpawnOwned in predicted logicPrediction
Reduce traffic in rooms with many playersDefineRelevanceSend distant objects less often
Decide spawn positions on the serverSpawnedRespond to spawns