---
title: "LiteNetLib, Riptide, Photon Fusion or built-in sync"
url: https://recursiveai.org/onegame/articles/csharp-multiplayer-networking/
description: "LiteNetLib and Riptide move messages; LiteEntitySystem and Photon Fusion sync state. C# multiplayer networking compared, with one small game built twice."
date: 2026-10-27
author: onegame team
---

# LiteNetLib, Riptide, Photon Fusion or built-in sync

LiteNetLib and Riptide move messages; LiteEntitySystem and Photon Fusion sync state. C# multiplayer networking compared, with one small game built twice.

October 27, 2026 · onegame team

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](https://recursiveai.org/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:

1. **Transport.** Connections, reliable and ordered delivery over UDP, fragmentation, timeouts.
2. **Messages.** A wire format for each thing a client can ask for and each update the server sends, with a version story.
3. **State sync.** Which objects exist, which fields changed since the last update, and getting a late joiner or a reconnecting client up to date.
4. **Authority and trust.** Which player may do what to which object, checked on the server.
5. **Visibility.** Which client may receive which values. Anything you send, a modified client can read.
6. **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](https://github.com/RevenantX/LiteNetLib) and [NuGet](https://www.nuget.org/packages/LiteNetLib/) (2.1.5, published 2026-10-26); Riptide [docs](https://riptide.tomweiland.net/manual/overview/about-riptide.html), [README](https://github.com/RiptideNetworking/Riptide) and [NuGet](https://www.nuget.org/packages/RiptideNetworking.Riptide/) (2.2.1); LiteEntitySystem [README](https://github.com/RevenantX/LiteEntitySystem) and [NuGet](https://www.nuget.org/packages/LiteEntitySystem/) (1.2.2); [Fusion 2 introduction](https://doc.photonengine.com/fusion/v2/fusion-intro) and [Fusion pricing](https://www.photonengine.com/fusion/pricing), read 2026-10-27. onegame: [Client/server sync](https://recursiveai.org/onegame/cookbook/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](https://github.com/RevenantX/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:

*Terminal · .NET 10*

```
dotnet new console -n LockboxLnl
cd LockboxLnl
dotnet add package LiteNetLib --version 2.1.5
```

*Program.cs · LiteNetLib 2.1.5 · .NET 10*

```
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);
```

*Terminal · .NET 10*

```
dotnet run -c Release
```

*Output · dotnet run*

```
p1 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](https://riptide.tomweiland.net/manual/overview/about-riptide.html) 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](https://doc.photonengine.com/fusion/v2/fusion-intro) 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](https://www.photonengine.com/fusion/pricing) (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:

*Terminal · onegame 1.2.0*

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

*Sim.cs · onegame 1.2.0 · .NET 10*

```
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.Owner` means `Code` goes 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 are `Team`, `Hidden` (never leaves the server) and the default, everyone.
- `OwnerOnly = true` makes the server refuse `Lock` from anyone but the seat’s owner, before the command runs. Refused commands never reach the command log.
- `Guess` compares against `Code` on 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:

*server/Program.cs · onegame 1.2.0 · .NET 10*

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

*Game.cs · onegame 1.2.0 · .NET 10*

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

*Terminal · onegame 1.2.0*

```
onegame build
onegame export --server --project server --out ./srv
```

*Output · onegame export*

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

*cluster.yaml · onegame 1.2.0*

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

*Terminal · onegame cluster call*

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

*Terminal · onegame cluster call*

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

*Terminal · onegame 1.2.0*

```
onegame cluster hashes
```

*Output · onegame cluster hashes*

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

*cluster/hidden-codes.yaml · onegame 1.2.0*

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

*Terminal · onegame 1.2.0*

```
onegame cluster test cluster/hidden-codes.yaml --spec cluster.yaml
```

*Output · onegame cluster test*

```
PASS 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#](https://recursiveai.org/onegame/articles/client-side-prediction-csharp/). 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#](https://recursiveai.org/onegame/articles/deterministic-game-simulation-csharp/)).

## 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 `Authenticate` hook 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](https://recursiveai.org/onegame/cookbook/export-web-android-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 `Authenticate` hook sees only the transport and remote address, so a game that needs real accounts has to establish identity some other way for now. `ClientLeft` is 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](https://recursiveai.org/onegame/cookbook/client-server-sync/). To start from nothing, see [Getting started](https://recursiveai.org/onegame/docs/getting-started/).
