Solo Project · Custom Netcode · Unity · C#

P2PNet

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.

Unity C# Custom UDP Transport Reliability Protocol Snapshot Interpolation Steam P2P Proximity Voice Chat

How I Built It

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.


Full Code Available on Request


1. Three Reliability Channels Over Raw UDP

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.

Per-Channel Packet Handling

2. Heartbeats, Timeouts, and Adaptive Resend

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.

Main-Thread Tick Loop

3. Serverless Join Codes

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.

Join Code Encode / Decode

4. Smooth Remote Movement Without Extra Network Traffic

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.

Snapshot Interpolation

5. A Transport-Agnostic RPC Layer

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.

Event Routing & Stable Hashing

6. Late-Join World State Sync

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.

Snapshot Registration & Sync

7. Host-Authoritative Runtime Spawning

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.

Spawn / Despawn Core

8. Proximity Voice Chat, No Distance Code Required

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.

Mic Capture, Resample & VAD Gate

About the Project

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.


Key Features


Design Challenges

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


What I Learned