---
title: "Client/server sync with a dedicated server"
url: https://recursiveai.org/onegame/cookbook/client-server-sync/
description: "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…"
version: "1.2.0"
---

# Client/server sync with a dedicated server

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

```shell
onegame new harbor --server
```

```text
created /tmp/onegame/harbor
  cd /tmp/onegame/harbor
  onegame build
  onegame run --headless --frames 60
  onegame test
```

With `--server` the scaffold is a counter rather than the ship. The game’s simulation goes in `Sim.cs`, which both the game and the server compile:

```csharp
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:

```csharp
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

```shell
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/onegame/harbor/srv/harbor-server-1.0.0-linux-x64
  archive: /tmp/onegame/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.

```shell
srv/harbor-server-1.0.0-linux-x64/run.sh --config "$PWD/server/server.yaml" --ticks 120
```

```text
{"ready":true,"pid":2659788,"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 }
```

```shell
onegame cluster up --spec cluster.yaml
```

```text
cluster 'harbor' is up (3 nodes); state in /tmp/onegame/harbor/.onegame-cluster/cluster.json
  dock         server pid 2660610  udp://127.0.0.1:48936 ws://127.0.0.1:48936
  p1           client pid 2660616  -> dock (udp)
  p2           client pid 2660648  -> 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:

```shell
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 343  hash 14c501a52ef117df
p2           client scope 1  tick 343  hash 14c501a52ef117df
dock         server scope 1  tick 343  hash 14c501a52ef117df
ok   p1 vs dock at tick 343: 14c501a52ef117df / 14c501a52ef117df
ok   p2 vs dock at tick 343: 14c501a52ef117df / 14c501a52ef117df
```

`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 }
```

```shell
onegame cluster test cluster/two-players.yaml --spec cluster.yaml
```

```text
PASS two-players (4487 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.2.0. 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](https://recursiveai.org/onegame/cookbook/client-side-prediction/).

More in [Driving a game](https://recursiveai.org/onegame/docs/driving-a-game/), section 7.

Choosing between LiteNetLib, Riptide, Photon Fusion and built-in sync: [C# multiplayer networking compared](https://recursiveai.org/onegame/articles/csharp-multiplayer-networking/).
