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
onegame new harbor --servercreated /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:
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:
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:
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
onegame build onegame export --server --project server --out ./srv
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.
srv/harbor-server-1.0.0-linux-x64/run.sh --config "$PWD/server/server.yaml" --ticks 120{"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
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 }
onegame cluster up --spec cluster.yamlcluster '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:
onegame cluster call p1 command_send '{"entity":"Counter","command":"Increment","args":["5"]}' onegame cluster call p2 state_query '{"entity":"Counter"}' onegame cluster hashes
{"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
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 }
onegame cluster test cluster/two-players.yaml --spec cluster.yamlPASS 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.