Networking
Server-authoritative multiplayer: replicated state, remote procedure calls, client prediction, and a headless server build.
Not verified end to end in 0.1.0
Everything on this page exists and compiles, and the headless server has been measured holding 30 Hz with a real scene, real physics and real C#. Two things are worth knowing before you build on it:
- The transport and the API have not been run together in a real game session. Individual pieces are tested — a real UDP server and client stand up and pass their checks — but "a multiplayer game, played" is not among the things that have happened.
- Secure web transport is not available. The vendored HTTP library has no TLS backend beyond OpenSSL, which isn't linked. Anything needing
wss://orhttps://is blocked.
Treat this as a preview. It's documented because it's a large, real API and finding out it exists by reading source is worse — not because it's ready to ship a game on.
The shape of it
One peer is the server and owns the truth. Clients send inputs and receive state. A host is both at once.
- State — a value that has a current truth (position, health, score) — is replicated.
- Events — a thing that happened (a gunshot, a chat message) — are RPCs.
Getting that split right is most of multiplayer. A position sent as an RPC is a stutter; a gunshot sent as state is a sound that plays late or not at all.
Net
The session.
| Member | What it is |
|---|---|
Net.IsServer | This peer owns the truth. True on a host |
Net.IsClient | This peer has a local player. True on a host |
Net.IsHost | Both at once |
Net.IsRunning | A session is active |
Net.LocalClientId | This peer's client id |
Net.Tick | The current network tick |
Net.ConnectionCount | How many peers are connected |
Net.GetConnectionId(int index) | The client id at that index |
Net.GetRtt(ulong clientId) | Round-trip time to that client, in milliseconds |
Net.Spawn(string name, ulong owner = 0)
Spawns a networked entity from a template, on every peer. Server only. owner of 0 means the server owns it.
GameEntity player = Net.Spawn("Player", owner: clientId);Net.Despawn(GameEntity entity)
Removes it everywhere. Server only.
Net.Kick(ulong clientId)
Disconnects a client. Server only.
NetEntity
Derive from NetEntity instead of GameEntity for anything that exists on the network. It is a GameEntity, so everything on that page still applies.
Role
A NetRole flag set describing this peer's relationship to this entity, recomputed on read — so an authority change mid-session is visible immediately rather than at the next spawn.
| Flag | Meaning |
|---|---|
NetRole.Server | This peer owns the truth |
NetRole.Client | This peer has a local player |
NetRole.Owner | This peer owns this entity |
NetRole.Observer | This peer can see it but doesn't own it |
They compose: on a host, a locally-owned player's role is Server | Client | Owner.
IsOwner / IsServer
Shorthands for the two checks you write most.
Lifecycle hooks
| Hook | When |
|---|---|
OnNetSpawn(NetRole role) | Once this entity exists on the network, on every peer that can see it |
OnNetDespawn(NetRole role) | When it goes away |
OnAuthorityChanged(bool gained) | Ownership moved to or from this peer |
OnPredictedTick(int tick) | A predicted simulation step on the owning client |
OnReconcile(int tick) | The server's truth disagreed with a prediction |
public class Player : NetEntity
{
protected override void OnNetSpawn(NetRole role)
{
if (role.HasFlag(NetRole.Owner)) AttachCamera(); // my player only
if (role.HasFlag(NetRole.Server)) ResetStats();
ShowNameplate(); // every observer
}
}One question asked once
Other engines give you eight separate callbacks for this — OnStartLocalPlayer, OnStartServer, OnStartClient and so on. Running camera setup on every observer instead of only the owner is the classic bug they exist to prevent, but it's one question asked four ways. A flag set answers it once, composes on a host, and gains a new role without gaining a new callback.
NetIdentity
Every GameEntity has a NetIdentity — always safe to read, whether or not the game is networked.
| Member | What it is |
|---|---|
NetId | Network id. 0 if not spawned on the network |
OwnerClientId | Which client owns it |
IsOwner | Whether this peer owns it |
IsSpawned | Whether it's on the network at all |
GrantAuthority(ulong clientId) | Hand ownership to a client. Server only |
RevokeAuthority() | Take it back. Server only |
IsSpawned is false for every entity in a single-player game, so guarding on it is how shared code stays single-player-safe.
[Rpc]
Marks a method as a remote procedure call. The body you write is what runs on the receiving end — calling the method sends it.
[Rpc(To.Server)]
private void Fire(Vector2 direction)
{
// Runs on the server, having been called on the owning client.
SpawnProjectile(direction);
}
[Rpc(To.Observers, ExcludeOwner = true)]
private void PlayRemoteSound() => m_Shot?.Play();
[Rpc(To.Server, RequireOwner = false)]
private void RequestJoin(int team) { }To
| Value | Direction |
|---|---|
To.Server | Owner → server. The client asks; the server decides |
To.Observers | Server → everyone who can see the entity |
To.Owner | Server → the one client that owns the entity |
To.Client | Server → one named client |
Options
| Option | Default | What it does |
|---|---|---|
RequireOwner | true | Only the owning client may call it |
ExcludeOwner | false | Skip the owner when broadcasting to observers — they already played the effect locally |
Channel | Channel.Reliable | Delivery guarantee |
Channel
| Value | Guarantee |
|---|---|
Reliable | Guaranteed and ordered. An event that is lost has simply not happened |
UnreliableSequenced | May be dropped; late arrivals discarded. For a stream where only the newest matters |
Unreliable | May be dropped, any order |
RPCs are events, not state
A position belongs in a replicated field. A gunshot belongs in an RPC. If you find yourself sending the same RPC every tick, you wanted replication.
The rewrite is done by a source generator, so the generated code is real, steppable C# — not an IL weaver you can't step through.
[Replicated]
Marks a field as server-owned state, sent to clients automatically.
| Option | What it does |
|---|---|
Interval | Minimum seconds between updates. 0 means every tick |
OnChange | Name of a method to call on clients when the value changes |
[Replicated(OnChange = nameof(OnHealthChanged))]
private int m_Health = 100;
[Replicated(Interval = 0.5f)]
private int m_Score;
private void OnHealthChanged(int oldValue, int newValue)
{
m_HealthBar.Value = newValue / 100f;
}[Predicted]
Marks a field as part of the client's predicted state, so the owning client can simulate ahead of the server and reconcile when the server disagrees. Pair it with OnPredictedTick and OnReconcile.
Guard attributes
| Attribute | Effect |
|---|---|
[ServerOnly] | The method throws if called anywhere but the server |
[ClientOnly] | Likewise, for clients |
[ServerOnlyTick] | The method only runs on server ticks |
[ClientOnlyTick] | The method only runs on client ticks |
These turn "I forgot this only runs on the server" from a subtle desync into an immediate, obvious failure.
Lower-level pieces
NetRuntime, NetInternal, NetPayloadWriter and NetPayloadReader are what the source generator emits calls into — RPC name hashing, dispatch, and payload serialization. Game code shouldn't need them; they're public because generated code has to reach them.
The headless server
A JOYSTICK_HEADLESS build produces a server binary with no window, no graphics backend, and no GLFW or SDL linked at all — it loads a scene, steps physics and runs your C# on a fixed tick. It's configured through server.yaml, environment variables or the command line, and shuts down cleanly on SIGTERM.
This is the part that has actually been measured: a headless binary holding 30 Hz with zero overruns.
See also
- GameEntity — everything
NetEntityinherits - Attributes — the non-networking attributes
- Time — the fixed clock a tick loop runs on