Tutorial: Move Characters with Rollback Sync
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
| Step | Where you work | What you build |
|---|---|---|
| 1. Write the rules | Text editor / IDE | C# with characters moved by ticks and the movement logic to predict |
| 2. Generate | Unity Editor | main.gsl, the sync schema, the input slot enum, and the prediction C# |
| 3. Register the definition | GS2 | StateMachineMaster and the Realtime namespace settings |
| 4. Create the character Prefab | Unity Editor | A Prefab that displays the position |
| 5. Connect to the room | Unity Editor | A scene that registers the prediction and sends inputs |
| 6. Verify the behavior | Unity (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.csWrite 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 runsTick30 times per second. It keeps running while the rules wait for input that acceptsTick(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.
10is 0.1 m and900is 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.
| File | Purpose |
|---|---|
main.gsl | The rules you register with GS2 in step 3 |
package.zip | A distribution file containing only main.gsl |
unity/Runner.realtimeschema | The sync schema you specify on the character Prefab in step 4 |
unity/RunnerSchema.cs | The Prefab ID (RunnerSchema.Prefab) and functions that read values with types |
unity/RunInput.cs | The input slot enum. Use it like world.SetInput(RunInput.X, 1) |
unity/RunnerRoomPrediction.cs | The prediction generated from the [Predict] logic. Registered in step 5 |
source-map.json | A 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 register | Field | Value |
|---|---|---|
| GS2-StateMachine (UpdateStateMachineMaster) | mainStateMachineName | RunnerRoom (the class name of the rules) |
payload | The contents of main.gsl | |
| GS2-Realtime namespace | serverType | stateMachine |
stateMachineSetting.namespaceId | The 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.
| Component | Setting |
|---|---|
| NetworkEntity | Specify unity/Runner.realtimeschema for Schema |
| NetworkTransform | Position 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
- Create a catalog with Create > GS2 > Realtime > Prefab Catalog, and register Id
10and the Prefab from step 4. - Create an empty GameObject in the scene, add NetworkWorld, and specify the catalog for Prefab Catalog.
- 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”.
SetInputonly 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’
Moveclamps 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.
- Confirm that both screens show two characters.
- 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.
- 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.
- 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 do | API to use | Details |
|---|---|---|
| Hit what the shooter saw | DefineHitbox, Room.Raycast, Room.Overlap | Lag compensation |
| Display bullets the instant they are fired | Room.SpawnOwned in predicted logic | Prediction |
| Reduce traffic in rooms with many players | DefineRelevance | Send distant objects less often |
| Decide spawn positions on the server | Spawned | Respond to spawns |
What to Read Next
- Room Definition Reference: ticks, inputs, the logic you can use in predictions, and limits.
- Unity Client: sending inputs, displaying predictions, and UDP.