A peer-to-peer networking library for Unity, built from raw UDP sockets rather than an existing netcode package. It's the foundation my co-op multiplayer project Soteria runs on.
Without an engine package to lean on, reliability, interpolation, and RPC (remote procedure call — sending a named message that runs a matching function on other machines) routing all had to be worked out on top of raw UDP sockets. Below are the key systems that make up the library.
UDP only guarantees that a packet either arrives or doesn't, so three delivery
channels sit on top of it: Unreliable for high-frequency cosmetic data,
UnreliableSequenced for latest-wins streams (stale packets are dropped
using sequence-number comparison), and ReliableOrdered for RPCs and lobby
messages, which acknowledges every packet and buffers out-of-order arrivals so they're
delivered to the game in the order they were sent.
Every connection is ticked once per frame from the main thread. Round-trip time per
peer is tracked with an exponential moving average (a running estimate that leans
toward recent samples so it adapts as the connection changes), and the
reliable-resend interval scales off it (1.5× RTT, with a floor) so
resends stay tight on a good connection without spamming a laggy one. Silent peers
past a timeout are dropped automatically, and peers still mid-handshake (the initial
back-and-forth that establishes a connection) get their own retry/give-up logic.
Joining a game is as simple as sharing a code — no matchmaking backend, no account
system. The "join code" isn't a lookup key at all: it's the host's IPv4 address and
port, packed into 48 bits and encoded as a 10-character Crockford base32 string —
a text encoding (Douglas Crockford's variant of the standard) built specifically to
be typed or read aloud by a person, since it drops visually ambiguous characters like
I, L, O. Decoding it just unpacks those same
bits back into an IP and port — the whole thing works over LAN or a port-forwarded
connection with no server infrastructure.
The owner of a transform streams its position/rotation at a fixed rate. Every remote copy buffers incoming snapshots and deliberately renders slightly in the past — about two packets' worth — then interpolates between the two snapshots either side of that render time. That small delay is what turns "teleport on every packet" into smooth motion, and it's fully generic: it works on players, spawned projectiles, or any networked object.
Gameplay code fires named events without needing to know whether the session is
running over the UDP transport or Steam P2P. Event names are hashed with FNV-1a (a
fast, deterministic hashing algorithm — the same input always produces the same
output, on any machine) into a wire id (the number actually sent over the network in
place of the string), so there's no enum bookkeeping and no risk of two builds
disagreeing on numbering. RaiseToAll/RaiseToHost/
RaiseToPeer all funnel through one Raise method that decides
whether to dispatch locally, send over the wire, or both.
A player joining mid-session shouldn't see the world as it looked at level start —
they should see it as it looks right now. Any stateful system registers
itself as an ISnapshotProvider; when a client finishes spawning, it asks
the host once, the host gathers every provider's state into one buffer keyed by a
hashed id, and the client applies it. Unknown provider ids are simply skipped rather
than corrupting the stream, so old and new versions of the game can still talk.
Dropped items, projectiles, and effects all spawn through one path. On the host,
Spawn creates the object immediately and broadcasts it; on a client it
sends a request to the host and returns null — the real instance arrives
a moment later via the broadcast. Spawns are idempotent (running the same spawn twice
has the same effect as running it once) against races with the late-join snapshot —
a spawn that already exists is just skipped — and every spawned object automatically
registers with the snapshot system above so it reaches late joiners too.
The local owner captures the microphone, resamples it to a fixed 16 kHz regardless of
the device's native rate, and gates out silence using voice activity detection (VAD)
— checking whether the signal is actually someone speaking rather than background
noise — with a short "hangover" (a brief grace period after speech stops, so the
gate doesn't snap shut mid-word) so word-endings aren't clipped. Each peer's voice
plays back from their own avatar through a 3D AudioSource, so proximity
attenuation (the voice getting quieter the further away that avatar is) comes from
Unity's own spatial audio rather than any distance math of its own. Filters (radio
effect, echo, etc.) plug in per-speaker afterwards.
P2PNet is a peer-to-peer networking library for Unity, built directly on raw UDP sockets rather than an existing netcode package. It's the foundation my co-op multiplayer project Soteria runs on, structured so it could be reused elsewhere too: a UDP transport with its own reliability protocol underneath, and a transport-agnostic gameplay layer on top that behaves the same whether the session is direct UDP or Steam P2P.
System.Net.Sockets and UnityEngine.RaiseToAll/RaiseToHost/RaiseToPeer) that works identically over UDP or Steam.Reliability From Nothing: UDP guarantees nothing — no ordering, no delivery, no acknowledgement. The reliable-ordered channel handles that itself: per-packet ids, acknowledgements, an out-of-order receive buffer that drains once the gap is filled, and an adaptive resend timer that scales off measured round-trip time so it stays responsive without flooding a slow connection.
Smooth Motion Without Extra Bandwidth: Interpolating remote players naively (just lerping toward the latest packet) looks jittery under real network conditions. Rendering deliberately behind — buffering a couple of snapshots and interpolating between the two either side of the render time — fixes this without sending a single extra byte over the wire.
Making Two Transports Behave Identically: UDP and Steam P2P have different
connection models underneath, but gameplay code shouldn't need to know which one is running.
Shared interfaces (IEventSink, ITransformRelay) sit between them, so
the RPC layer, transform sync, and snapshot system are written once and work on either.
Voice Without a Codec Dependency: Streaming raw microphone audio means handling whatever sample rate the local device's mic actually captures at, not the rate the wire protocol expects. A phase-continuous resampler — one that remembers the exact fractional position it left off at, rather than restarting the count with each new batch of audio — carries leftover fractional samples between chunks, so there are no audible clicks at chunk boundaries, without pulling in a codec (a library that compresses and decompresses audio, like the ones behind MP3 or Opus).