What actually crosses the wire
A player moves a boat. Everyone else sees it move. Between those two sentences sits the hardest problem in multiplayer games, and most of the engine’s networking code exists to hide it.
Polytoria’s implementation is readable, and it makes the classic trade-offs explicitly enough to be worth walking through.
The constraint
Two machines cannot share state. They can only send messages, and messages take time, arrive out of order, and sometimes never arrive. Every networked game is an attempt to make separate simulations agree well enough that nobody notices they are separate.
Which forces three questions:
- Who decides what is true?
- How often do you say it?
- What does the receiver do between updates?
Authority
The engine separates sending to the server from broadcasting out of it. Client updates go to peer 1 — the server — and there is a validation hook on receipt before anything is applied:
RpcId(1, nameof(NetRecvTransformOnServer), objID, payload, lerpTransform);
// on receipt, before applying:
dyn.UpdateTransformFromNet(dyn.TransformNetworkPass(fromPeer, transform), ...);
TransformNetworkPass takes the sender and the claimed transform and returns
what will actually be applied. That is the seam where a client claiming to be somewhere
impossible gets corrected.
This is server-authoritative design and it is the only structure that survives contact with untrusted clients. Anything a client asserts is a request, not a fact. The rule generalises past games: never trust a value produced on a machine you do not control.
There is a rate limiter alongside it:
public class SlidingWindowRateLimiter(int maxMessages, TimeSpan timeWindow)
{
private readonly Queue<DateTime> _timestamps = new();
private readonly Lock _lock = new();
public bool TryAccept() { /* drop timestamps older than the window */ }
}
A sliding window rather than a fixed one, which matters: fixed windows let a caller send a full quota at the end of one bucket and again at the start of the next, producing twice the intended burst at the boundary. Sliding windows cost more memory — one timestamp per accepted message — and do not have that failure.
How often
Bandwidth is the budget everything else is spent from. The engine batches:
private const double BatchInterval = 0.05;
Fifty milliseconds — twenty updates a second, well under a rendering frame rate. Transform changes accumulate and flush together rather than each producing a packet.
The reason is packet overhead. Every datagram carries tens of bytes of headers before any payload, so a hundred tiny messages cost far more than one message carrying a hundred updates. The engine sends chunks of objects rather than one object at a time, which is the same insight applied at the object level.
Reliable or not, chosen per message
The transform sync exposes both:
NetRecvUpdateTransform // unreliable
NetRecvUpdateTransformReliable // reliable
Reliable delivery guarantees arrival and ordering by retransmitting losses, which means a dropped packet stalls everything behind it. For a position stream that is exactly wrong: by the time a lost update is retransmitted, two newer positions have already arrived and the old one is worthless.
So continuous state goes unreliable — lose one, the next fixes it — while discrete events that must not vanish go reliable. Getting this backwards is a classic source of rubber-banding under packet loss.
What the receiver does in between
Twenty updates a second against sixty rendered frames means two out of three frames have no new information. Snapping to the last known position gives visible stutter.
Hence the lerpTransform flag threaded through every send. When set, the
receiver interpolates toward the target instead of teleporting.
The cost is that interpolation is deliberate lag: to blend toward a position you must already have it, so you are rendering slightly in the past. Every game makes this trade, and the literature on latency compensation is largely about how to hide it — the Half-Life model of client prediction plus server reconciliation being the widely-copied treatment.
That it is a per-call flag rather than a global setting is the interesting part. A smoothly drifting platform wants interpolation. A teleport does not — blending to a teleport draws a line through the wall.
Practical note: if remote objects look smooth for you and jittery for players on worse connections, the variable to look at is not update rate. It is whether the interpolation buffer is long enough to absorb their jitter, which is a latency-versus- smoothness trade you have to choose deliberately.
Separate syncs for separate problems
The engine splits replication by kind — transforms, properties, scripts, and a general replicate path each have their own sync. That separation is not tidiness. A transform is a small fixed-size payload that changes constantly and tolerates loss; a property change is rare, arbitrary, and must not be lost. Batching, reliability, and rate limits should differ, and they cannot if one channel carries everything.
References
- Y. W. Bernier. “Latency Compensating Methods in Client/Server In-game Protocol Design and Optimization.” Game Developers Conference, 2001. The Half-Life model; still the standard treatment of prediction and reconciliation.
- M. Frohnmayer, T. Gift. “The TRIBES Engine Networking Model.” Game Developers Conference, 1999. Prioritised, bandwidth-budgeted replication.
- D. Aldridge. “I Shot You First: Networking the Gameplay of Halo: Reach.” Game Developers Conference, 2011. Authority and reconciliation under real conditions.
- G. Fiedler. “Networked Physics” series, gafferongames.com. Practical treatment of state synchronisation and interpolation buffers.
- M. Claypool, K. Claypool. “Latency and Player Actions in Online Games.” Communications of the ACM, 49(11), 2006. Measured tolerance thresholds by action type.
- Polytoria engine source,
github.com/Polytoria/polytoria-game
—
Polytoria/scripts/network/.