For C# multiplayer networking, start by picking the layer you need. LiteNetLib and Riptide are transports: they connect peers and deliver your messages reliably over UDP, and you write everything that turns messages into a shared game state. LiteEntitySystem (built on LiteNetLib) and Photon Fusion sit a layer higher and synchronize state for you, with client-side prediction. Fusion 2 is built for Unity and runs on Photon’s cloud, which is paid once you outgrow its free tiers. A third option is an engine whose state sync is built in, like onegame. This page compares them from their own documentation, then builds one small hidden-information game twice: on LiteNetLib 2.1.5, and on onegame 1.2.0 with a dedicated server. Both ran on Linux x64 with .NET 10.
Transport or state sync: the layer you’re choosing
A networked game needs all of these, whoever writes them:
- Transport. Connections, reliable and ordered delivery over UDP, fragmentation, timeouts.
- Messages. A wire format for each thing a client can ask for and each update the server sends, with a version story.
- State sync. Which objects exist, which fields changed since the last update, and getting a late joiner or a reconnecting client up to date.
- Authority and trust. Which player may do what to which object, checked on the server.
- Visibility. Which client may receive which values. Anything you send, a modified client can read.
- Latency hiding. Prediction, reconciliation, interpolation.
A transport library covers the first item. A state-sync library or an engine’s built-in sync covers most of the list, in its own way.
C# networking libraries compared
Versions are the latest on NuGet when this page was written. Each cell comes from the project’s own README, docs or pricing page, linked below the table. “Not listed” means the cited pages don’t mention it.
| LiteNetLib 2.1.5 | Riptide 2.2.1 | LiteEntitySystem 1.2.2 | Photon Fusion 2 | onegame 1.2.0 | |
|---|---|---|---|---|---|
| What it is | Reliable UDP library | Connections and messages | High-level multiplayer API, on LiteNetLib by default | State synchronization library for Unity | Sync built into a C# game engine |
| License or price | MIT | MIT | Apache-2.0 | Free tiers up to 100 CCU, then paid plans | MIT or Apache-2.0 |
| Runs in | .NET Standard 2.1, .NET 8; Unity, MonoGame, Godot | .NET Standard 2.0; Unity and other .NET | .NET Standard 2.1; Unity, Godot, MonoGame | Unity (separate Unreal and Godot SDKs) | onegame games |
| Transports | UDP, with NAT hole punching | UDP, TCP; Steam add-on | LiteNetLib, or one you write | Through the Photon Cloud | Reliable UDP and WebSocket |
| State sync | No: you send messages | No: you send messages | Synced variables, delta-compressed | Networked properties | Generated from [Synced] |
| Client-side prediction | Not listed | Not listed | Yes, plus lag compensation | Yes, plus lag compensation | Yes, server-authoritative |
| Per-client visibility | Not listed | Not listed | Not listed | Area-of-interest system | Owner, team or hidden, per property |
| Server | Your program | Your program | Your program | Dedicated, client host or shared | NativeAOT dedicated server |
Sources: LiteNetLib README and NuGet (2.1.5, published 2026-10-26); Riptide docs, README and NuGet (2.2.1); LiteEntitySystem README and NuGet (1.2.2); Fusion 2 introduction and Fusion pricing, read 2026-10-27. onegame: Client/server sync and the code below.
LiteNetLib: a reliable UDP transport
LiteNetLib describes itself as a “Lite reliable UDP library for .NET Standard 2.1”. Its feature list covers the transport layer thoroughly: reliable ordered, reliable unordered, sequenced and unreliable delivery, multiple channels, automatic fragmentation and MTU detection, NAT hole punching, packet loss and latency simulation, and a packet serializer. Its README names LiteEntitySystem as the “HighLevel API part”, so state sync is a separate package by the same author.
To see what the transport leaves to you, here is a small game on LiteNetLib. Two players each lock a seat with a secret code from 1 to 9. A player sees their own code and only whether the other seat is locked. Player 2 tries to lock player 1’s seat, which the server must refuse. The program follows the event pattern of the README’s client and server samples, and runs the server and both clients in one process so you can run it as is:
dotnet new console -n LockboxLnl cd LockboxLnl dotnet add package LiteNetLib --version 2.1.5
using System.Net; using LiteNetLib; using LiteNetLib.Utils; // Server state. Which peer owns which seat, and what each client may see, is yours to track. var codes = new int[3]; // seats 1 and 2; 0 = not locked yet var seatOf = new Dictionary<NetPeer, int>(); var listener = new EventBasedNetListener(); var server = new NetManager(listener); server.Start(9050); listener.ConnectionRequestEvent += request => { if (server.ConnectedPeersCount < 2) request.AcceptIfKey("lockbox"); else request.Reject(); }; listener.PeerConnectedEvent += peer => seatOf[peer] = seatOf.Count + 1; // The wire format is yours too: [byte kind][int seat][int code], 1 = lock. listener.NetworkReceiveEvent += (peer, reader, channel, deliveryMethod) => { byte kind = reader.GetByte(); int seat = reader.GetInt(); int code = reader.GetInt(); reader.Recycle(); // Ownership check, written by hand. if (kind == 1 && seat == seatOf[peer] && codes[seat] == 0 && code is >= 1 and <= 9) codes[seat] = code; // Per-client filtering, written by hand: each peer gets its own code and only "locked" for the other. foreach (var (p, mine) in seatOf) { var writer = new NetDataWriter(); for (int s = 1; s <= 2; s++) { writer.Put(codes[s] != 0); writer.Put(s == mine ? codes[s] : 0); } p.Send(writer, DeliveryMethod.ReliableOrdered); } }; var views = new Dictionary<string, string>(); NetManager Client(string name, int seat, int code) { var events = new EventBasedNetListener(); var client = new NetManager(events); client.Start(); client.Connect(new IPEndPoint(IPAddress.Loopback, 9050), "lockbox"); events.PeerConnectedEvent += peer => { var writer = new NetDataWriter(); writer.Put((byte)1); writer.Put(seat); writer.Put(code); peer.Send(writer, DeliveryMethod.ReliableOrdered); }; events.NetworkReceiveEvent += (peer, reader, channel, deliveryMethod) => { var view = ""; for (int s = 1; s <= 2; s++) view += $" seat {s}: locked={reader.GetBool()} code={reader.GetInt()};"; views[name] = view; reader.Recycle(); }; return client; } var p1 = Client("p1", seat: 1, code: 7); Thread.Sleep(100); // so p1 connects first and gets seat 1 var p2 = Client("p2", seat: 1, code: 4); // tries to lock p1's seat for (int i = 0; i < 100; i++) { server.PollEvents(); p1.PollEvents(); p2.PollEvents(); Thread.Sleep(15); } p1.Stop(); p2.Stop(); server.Stop(); foreach (var (name, view) in views) Console.WriteLine(name + view);
dotnet run -c Releasep1 seat 1: locked=True code=7; seat 2: locked=False code=0; p2 seat 1: locked=True code=0; seat 2: locked=False code=0;
It works, and every rule in it is yours: the seat each peer owns, the byte layout of each message, the ownership check, and the filtering that keeps player 1’s code off player 2’s connection. Before this is a game, you would still add per-field change tracking instead of resending everything, a snapshot for clients that join late or reconnect, a version check on the message format, prediction, and tests that run a server and two clients. That is the work LiteEntitySystem, Photon Fusion and engine-level sync take on.
Riptide
Riptide sits at the same layer. Its docs describe “a lightweight C# networking library primarily designed for use in multiplayer games” that “provides functionality for establishing connections and sending data back and forth, leaving it up to you to decide what data you want to send and when.” It ships UDP and TCP transports, has a separate Steam transport, and is MIT-licensed. Choosing between LiteNetLib and Riptide comes down to API style and the features listed on each page. The work above the transport is the same.
Photon Fusion
Photon’s Fusion 2 introduction calls Fusion “a new high performance state synchronization networking library for Unity” with “data compression, client-side prediction and lag compensation out of the box” and “a fully configurable area-of-interest system”. State is declared with attributes, such as [Networked] on properties and [Rpc] on methods, and it supports dedicated server, client host and shared authority topologies. It is the most complete package on this list if you build in Unity. Photon’s documentation also has separate Fusion sections for Unreal and Godot.
It is also a hosted service. The Fusion pricing page (read 2026-10-27) says “All applications run conveniently in the Photon Cloud” and prices plans by peak concurrent users (CCU): 20 CCU free for development only, 100 CCU free for one launched game, 100 CCU for $95 once for 12 months, 500 CCU for $125 a month, 1,000 for $250 and 2,000 for $500, with a traffic allowance per plan. Check that page for current terms before you plan around them.
Built-in sync: the same game in onegame
In onegame, networking is part of the engine rather than a library you add. Clients and servers run the same engine. The server holds the authority world, each client holds a replica the server keeps current with deltas, and commands go up to the server. You declare state, ownership and visibility on the entity, and the source generator writes the codecs, change tracking, deltas and snapshots. Here is the same game, created with a server project beside it:
dotnet tool install -g RecursiveAI.OneGame.Cli --version 1.2.0 onegame new lockbox --server cd lockbox
The entity: ownership and visibility are attributes
Sim.cs is compiled into both the game and the server:
using OneGame.Core; namespace Lockbox; /// <summary>One player's seat: a code only its owner receives, and public state everyone sees.</summary> [Entity] public partial class Seat : Entity { // Which player owns this seat. Owner visibility and owner-only commands match against it. [Synced, OwnerKey] public partial uint Player { get; set; } // Sent to the owner's connection only. Other replicas hold 0. [Synced(Visibility = Visibility.Owner)] public partial int Code { get; private set; } [Synced] public partial bool Locked { get; private set; } [Synced] public partial int Guesses { get; private set; } [Synced] public partial bool Opened { get; private set; } // Only the seat's owner may lock it, and only once. [Command(OwnerOnly = true)] public void Lock(int code) { if (Locked || code < 1 || code > 9) { return; } Code = code; Locked = true; } // Anyone may guess. The check runs on the server, which is the only place that knows every code. [Command] public void Guess(int code) { if (!Locked || Opened) { return; } Guesses++; Opened = code == Code; } }
[OwnerKey]marks the property that says which player owns the seat.Visibility = Visibility.OwnermeansCodegoes only to the owner’s connection. A replica that may not see a value holds the type’s default (0), and the update sent to that client leaves the value out. The other levels areTeam,Hidden(never leaves the server) and the default, everyone.OwnerOnly = truemakes the server refuseLockfrom anyone but the seat’s owner, before the command runs. Refused commands never reach the command log.Guesscompares againstCodeon the server, the only place that holds every code. A client can’t cheat by reading its replica, because the value isn’t there.
The server
The scaffold’s server/Program.cs is the whole server. The edits seed two seats and give the first two connections the identities of players 1 and 2:
using Lockbox; using OneGame.Server; using OneGame.Server.Live; using OneGame.Sync; uint joined = 0; return ServerApp.Run( new ServerSetup { RegisterEntities = OneGame.Generated.LockboxServer.EntityTypes.Register, SeedWorld = (world, _) => { world.Add(new Seat { Player = 1 }); world.Add(new Seat { Player = 2 }); }, // The first two connections become players 1 and 2; anyone after them is turned away. Started = host => host.Authenticate = _ => ++joined <= 2 ? Trust.Owner(new ScopeId(1)).As(joined, 0) : null, }, args);
The game’s Game.cs draws one label per seat. Offline, both seats live in the local world. Connected, it reads the replica:
using OneGame.Core; using OneGame.Host; using OneGame.UI; namespace Lockbox; /// <summary>Two seats, each with a secret code. Run it against a server with --connect udp://host:7777.</summary> public sealed class LockboxGame : GameDefinition { public override string Name => "lockbox"; public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Lockbox.EntityTypes.Register(registry); // Played offline, both seats live in the local world. public override void Setup(World world) { world.Add(new Seat { Player = 1 }); world.Add(new Seat { Player = 2 }); } public override void DefineScreens(ScreenSet screens) => screens.Add("main", game => Ui.Column( Ui.Label(() => Describe(game, 0)).Named("seat1"), Ui.Label(() => Describe(game, 1)).Named("seat2")) .Padding(Length.Px(24)).Gap(Length.Px(12)).Background(Color.Rgb(0x203040)).TextColor(Color.White)); // A replica holds 0 for a code it isn't allowed to see. private static string Describe(GameInstance game, int index) => game.World.Entities.Count > index && game.World.Entities[index] is Seat seat ? "Seat " + seat.Player + ": " + (!seat.Locked ? "open" : seat.Opened ? "opened" : seat.Code == 0 ? "locked, code hidden" : "locked with " + seat.Code) : "connecting"; }
Run a server and two clients
Export the server as a NativeAOT program, then describe a cluster: the server, one client over reliable UDP and one over WebSocket.
onegame build onegame export --server --project server --out ./srv
exported server lockbox-server 1.0.0 for linux-x64 (nativeaot): 3 files, 4562 KiB folder: /tmp/games/lockbox/srv/lockbox-server-1.0.0-linux-x64 archive: /tmp/games/lockbox/srv/lockbox-server-1.0.0-linux-x64.tar.gz (2132 KiB) warning glibc_too_new: LockboxServer needs glibc 2.34; a host with an older glibc cannot run it. Build on the oldest system you deploy to.
name: lockbox nodes: - { name: table, role: server, exe: srv/lockbox-server-1.0.0-linux-x64/LockboxServer, listen: udp+ws, scopes: [{ id: 1, seed: 7 }] } - { name: p1, role: client, exe: bin/Debug/net10.0/Lockbox, connect: table, transport: udp } - { name: p2, role: client, exe: bin/Debug/net10.0/Lockbox, connect: table, transport: ws }
onegame cluster up --spec cluster.yaml starts the three processes, and every node has its own live-control socket. Player 1 locks seat 1 with 7 and player 2 locks seat 2 with 3. Then player 2 tries to lock seat 1 with 4:
$ onegame cluster call p1 command_send '{"entity":"Seat#1","command":"Lock","args":["7"]}' {"entity":"Seat#1","command":"Lock","status":"Forwarded"} $ onegame cluster call p2 command_send '{"entity":"Seat#2","command":"Lock","args":["3"]}' {"entity":"Seat#2","command":"Lock","status":"Forwarded"} $ onegame cluster call p2 command_send '{"entity":"Seat#1","command":"Lock","args":["4"]}' {"entity":"Seat#1","command":"Lock","status":"Forwarded"}
“Forwarded” means the client sent the command. The server decides. Here is seat 1 as the server and each client hold it:
$ onegame cluster call table state_query '{"entity":"Seat#1"}' {"id":1,"type":"Seat","Player":1,"Code":7,"Locked":true,"Guesses":0,"Opened":false} $ onegame cluster call p1 state_query '{"entity":"Seat#1"}' {"id":1,"type":"Seat","Player":1,"Code":7,"Locked":true,"Guesses":0,"Opened":false} $ onegame cluster call p2 state_query '{"entity":"Seat#1"}' {"id":1,"type":"Seat","Player":1,"Code":0,"Locked":true,"Guesses":0,"Opened":false}
The code is still 7, so the server refused player 2’s lock. Player 1’s replica has the code, and player 2’s has 0. The state hash is always on, and each client is compared with the server’s view for that client:
onegame cluster hashesp1 client scope 1 tick 438 hash 99d039155510aca5 p2 client scope 1 tick 438 hash b1ca3483b7484270 table server scope 1 tick 439 hash cbb4f76c08edcc42 ok p1 vs table at tick 438: 99d039155510aca5 / 99d039155510aca5 ok p2 vs table at tick 438: b1ca3483b7484270 / b1ca3483b7484270
The two clients have different hashes because they hold different data, and each matches the server’s view for its own audience. A replica that drifted would be reported at the tick it happened. hashes exits 1 on a mismatch.
Test it as a cluster scenario
The same checks, written once and run by onegame cluster test, which starts the cluster, runs the steps and stops it:
name: hidden-codes description: Each player receives only their own code, a lock on someone else's seat is refused, and guesses are checked on the server. steps: - wait: { node: p1, tick: 60, timeout: 20000 } - wait: { node: p2, tick: 60, timeout: 20000 } - command: { entity: Seat#1, name: Lock, args: [7] } node: p1 - command: { entity: Seat#2, name: Lock, args: [3] } node: p2 - command: { entity: Seat#1, name: Lock, args: [4] } # p2 isn't seat 1's owner: refused node: p2 - wait: { node: p2, tick: 180, timeout: 20000 } - command: { entity: Seat#1, name: Guess, args: [5] } node: p2 - wait: { node: p1, tick: 300, timeout: 20000 } - wait: { node: p2, tick: 300, timeout: 20000 } expect: - state: { entity: Seat#1, property: Code, equals: 7 } node: p1 - state: { entity: Seat#1, property: Code, equals: 0 } node: p2 - state: { entity: Seat#2, property: Code, equals: 3 } node: p2 - state: { entity: Seat#2, property: Code, equals: 0 } node: p1 - state: { entity: Seat#1, property: Guesses, equals: 1 } node: p1 - state: { entity: Seat#1, property: Opened, equals: false } node: p2 - agree: { server: table, clients: [p1, p2] } - drift: { nodes: [p1, p2], equals: 0 }
onegame cluster test cluster/hidden-codes.yaml --spec cluster.yamlPASS hidden-codes (4777 ms) 1 passed, 0 failed
Compare that with the LiteNetLib version. There is no wire format to design, and no ownership or filtering code. Late joiners and reconnects get a snapshot (a client that loses its connection keeps trying for 60 seconds and rebuilds its replica). For the moves a player should see at once, add [Command(Predicted = true)]; see client-side prediction and rollback in C#. The simulation behind all of this is deterministic by construction, which is what makes the drift hashes and resimulation work (deterministic game simulation in C#).
How to choose a C# networking library
- You have an engine or framework and want control: LiteNetLib or Riptide. They’re MIT-licensed, run anywhere .NET runs, and leave the game protocol to you. Expect to write state sync, visibility and prediction yourself, or add LiteEntitySystem on top of LiteNetLib.
- You build in Unity and want sync, prediction and hosting handled: Photon Fusion, budgeted against its CCU pricing.
- You’re starting a game in C# and want networking that isn’t a separate project: an engine with sync built in. In onegame, synced state, ownership, per-player visibility, prediction, dedicated servers and multi-process tests come with the engine. You adopt the engine, though: its networking is designed as part of it, not as a library for another engine.
Good to know
- No encryption or authentication in onegame’s transports at 1.2.0. The server’s
Authenticatehook sees only the remote address, and WebSocket runs over plain TCP, so put a TLS terminator in front of it. Use other machines only on a network you trust until that changes. - Dedicated servers export for linux-x64 from the NuGet packages, and game clients export for Linux, Windows and macOS. Web, Android and iOS exports need an engine checkout at 1.2.0 (Export for the web, Android and iOS).
- onegame’s prediction is server-authoritative. It is not peer-to-peer input rollback of the GGPO kind.
- There is no physics engine. Movement and collision are your own fixed-point sim code.
- The server in this example hands out player identities in connection order. At 1.2.0 the
Authenticatehook sees only the transport and remote address, so a game that needs real accounts has to establish identity some other way for now.ClientLeftis where a server removes a departed player’s entities.
The step-by-step recipe with a simulated network (latency and loss per client) is Client/server sync with a dedicated server. To start from nothing, see Getting started.