Recursive AI

Client/server sync with a dedicated server

All onegame recipes

Make a C# game with a dedicated server, export the server as a NativeAOT program, run it with two clients over UDP and WebSocket on a simulated network, and check that every replica agrees with the server.

Clients and servers run the same engine. The server holds the authority world. Each client holds a replica of it that the server keeps up to date with deltas, and sends its commands to the server. The state hash is always on, so a replica that drifts from the server is caught at the tick it happens. All of this works from the NuGet packages.

A game with a server

Terminal
onegame new harbor --server
Text
created /tmp/games/harbor
  cd /tmp/games/harbor
  onegame build
  onegame run --headless --frames 60
  onegame test

The game’s simulation goes in Sim.cs, which both the game and the server compile:

C#
using OneGame.Core;
 
namespace Harbor;
 
/// <summary>The state of the game: one synced number and a command that changes it.</summary>
[Entity]
public partial class Counter : Entity
{
    [Synced] public partial int Count { get; private set; }
 
    [Command]
    public void Increment(int by) => Count += by;
}

server/ is a small project of its own. Its Program.cs is the whole server:

C#
using Harbor;
using OneGame.Server;
using OneGame.Server.Live;
 
// The whole server: listen, tick, serve live control. See server.yaml for the options (or pass them on the command line).
return ServerApp.Run(
    new ServerSetup
    {
        RegisterEntities = OneGame.Generated.HarborServer.EntityTypes.Register,
        SeedWorld = (world, _) => world.Add(new Counter()),
    },
    args);

And server/server.yaml configures it:

YAML
name: harbor
tickRate: 60
scopes:
  - { id: 1, seed: 1 }
listen:
  - { transport: udp, port: 7777 }
  - { transport: ws, port: 7778 }

server/harbor-server.csproj compiles only Program.cs and ../Sim.cs, so view code must stay out of Sim.cs. The game’s project leaves server/ out of its own compile.

Export the server

Terminal
onegame build
onegame export --server --project server --out ./srv
Text
build succeeded (Debug, 0 warnings, 0.98s)
exported server harbor-server 1.0.0 for linux-x64 (nativeaot): 3 files, 4558 KiB
  folder:  /tmp/games/harbor/srv/harbor-server-1.0.0-linux-x64
  archive: /tmp/games/harbor/srv/harbor-server-1.0.0-linux-x64.tar.gz (2128 KiB)
  warning glibc_too_new: HarborServer needs glibc 2.34; a host with an older glibc cannot run it. Build on the oldest system you deploy to.

The folder holds HarborServer, run.sh and the notices. server.yaml isn’t copied, so pass it with --config. Without it the server listens on no port.

Terminal
srv/harbor-server-1.0.0-linux-x64/run.sh --config "$PWD/server/server.yaml" --ticks 120
Text
{"ready":true,"pid":1330448,"listen":[{"transport":"udp","address":"0.0.0.0","port":7777},{"transport":"ws","address":"0.0.0.0","port":7778}]}
{"stopped":true,"tick":120,"scopes":[{"id":1,"tick":120,"hash":"d2e4b63e7645dec1","entities":1,"subscribers":0}]}

Servers export for linux-x64 only. A client joins a running server with onegame run -- --connect udp://127.0.0.1:7777.

A cluster: one server, two clients

A cluster file names the processes. p1 connects over UDP with 100 ms of latency and 2% loss; p2 connects over WebSocket.

cluster.yaml

YAML
name: harbor
nodes:
  - { name: dock, role: server, exe: srv/harbor-server-1.0.0-linux-x64/HarborServer, listen: udp+ws, scopes: [{ id: 1, seed: 7 }] }
  - { name: p1, role: client, exe: bin/Debug/net10.0/Harbor, connect: dock, transport: udp, net: { latencyMs: 100, lossRate: 0.02 } }
  - { name: p2, role: client, exe: bin/Debug/net10.0/Harbor, connect: dock, transport: ws }
Terminal
onegame cluster up --spec cluster.yaml
Text
cluster 'harbor' is up (3 nodes); state in /tmp/games/harbor/.onegame-cluster/cluster.json
  dock         server pid 1327488  udp://127.0.0.1:44390 ws://127.0.0.1:44390
  p1           client pid 1327499  -> dock (udp)
  p2           client pid 1327556  -> dock (ws)

Each node is its own process with its own live-control socket. Send a command from p1, read the state on p2, and compare every replica with the server:

Terminal
onegame cluster call p1 command_send '{"entity":"Counter","command":"Increment","args":["5"]}'
onegame cluster call p2 state_query '{"entity":"Counter"}'
onegame cluster hashes
Text
{"entity":"Counter#1","command":"Increment","status":"Forwarded"}
{"id":1,"type":"Counter","Count":5}
p1           client scope 1  tick 287  hash bed628be6eddbc9f
p2           client scope 1  tick 287  hash bed628be6eddbc9f
dock         server scope 1  tick 287  hash bed628be6eddbc9f
ok   p1 vs dock at tick 287: bed628be6eddbc9f / bed628be6eddbc9f
ok   p2 vs dock at tick 287: bed628be6eddbc9f / bed628be6eddbc9f

hashes exits 1 on a mismatch or a drift report. onegame cluster call p1 net_status shows the client’s connection: round-trip time, retransmits, loss, resyncs and drift. onegame cluster down stops every node.

A cluster scenario

Steps and expectations take a node:, and wait, agree and drift check the cluster as a whole:

cluster/two-players.yaml

YAML
name: two-players
description: Both players add to the counter; the server and both replicas agree, with no drift.
steps:
  - wait: { node: p1, tick: 60, timeout: 20000 }
  - command: { entity: Counter, name: Increment, args: [2] }
    node: p1
  - command: { entity: Counter, name: Increment, args: [3] }
    node: p2
  - wait: { node: p1, tick: 300, timeout: 20000 }
  - wait: { node: p2, tick: 300, timeout: 20000 }
expect:
  - state: { entity: Counter, property: Count, equals: 5 }
    node: p1
  - state: { entity: Counter, property: Count, equals: 5 }
    node: p2
  - agree: { server: dock, clients: [p1, p2] }
  - drift: { nodes: [p1, p2], equals: 0 }
Terminal
onegame cluster test cluster/two-players.yaml --spec cluster.yaml
Text
PASS two-players (4454 ms)
1 passed, 0 failed

cluster test starts the cluster, runs the scenario and stops it. UI expectations aren’t available in a cluster scenario; check state there and the UI with cluster call <node> ui_query.

Good to know

  • [Synced(Visibility = ...)] and [OwnerKey]/[TeamKey] decide who receives an entity or a property: everyone, its owner, its team, or no one. Hidden values are left out of the bytes sent, not just hidden in the client.
  • A client that loses its connection keeps reconnecting for 60 seconds and rebuilds its replica from a fresh snapshot.
  • Nothing authenticates a player connection at 1.1.2. The cluster binds to loopback; use other machines only on a network you trust.
  • TestCluster.Launch(spec) starts the same cluster from a C# test.
  • For a command to show on the client before the server answers, see Client-side prediction.

More in Driving a game, section 7.

Choosing between LiteNetLib, Riptide, Photon Fusion and built-in sync: C# multiplayer networking compared.