Rollback netcode over a loopback
Open the live demo · Read the source · View on GitHub
NetSession keeps two players' worth of a fixed-step simulation in step
across a connection that delays messages and drops some of them: input
frames, a fixed delay, prediction for a step whose remote input has not
arrived, and rollback once a late confirmation disagrees with the guess.
LoopbackTransport is the harness it is tested against: two transports
joined by a real delay and a real loss rate, not a mock that only records
what was asked of it.
Note.
flutter3d_netis not a dependency of this app. Both classes need onlyflutter3d_sim'sSnapshotandGameRandomunderneath, so this page reimplements the transport half against those same real types.flutter3d_net_webrtc, the transport for an actual connection between two browsers, is a native plugin with no page of its own.
Step 1: A lossy, delayed pair #
/// A pair of transports joined by a fixed delay and a loss rate, so a test
/// is separated by an unreliable network rather than by nothing.
final class _LoopbackTransport {
_LoopbackTransport._(this._delaySteps, this._lossRate, this._random);
final int _delaySteps;
final double _lossRate;
final GameRandom _random;
late final _LoopbackTransport _peer;
int _now = 0;
final List<(int dueAt, int value)> _inbox = <(int, int)>[];
void Function(int value)? _listener;
static (_LoopbackTransport, _LoopbackTransport) pair({
required int stepsPerSecond,
double delaySeconds = 0.0,
double lossRate = 0.0,
int seed = 1,
}) {
final random = GameRandom(seed);
final delaySteps = (delaySeconds * stepsPerSecond).round();
final a = _LoopbackTransport._(delaySteps, lossRate, random);
final b = _LoopbackTransport._(delaySteps, lossRate, random);
a._peer = b;
b._peer = a;
return (a, b);
}
void send(int value) {
if (_random.nextDouble() < _lossRate) return;
_peer._inbox.add((_peer._now + _delaySteps, value));
}
void listen(void Function(int value) onMessage) => _listener = onMessage;
void tick() {
_now++;
_inbox.removeWhere((entry) {
if (entry.$1 > _now) return false;
_listener?.call(entry.$2);
return true;
});
}
}
Step 2: Two sides listening to each other #
// Each peer's own count of "my presses the other side has heard about",
// standing in for a `NetSession.applyAndStep` that folds a remote frame
// into a shared simulation. Every message repeats the last few sends,
// the same redundancy `NetSession` uses, so one dropped packet is not
// one lost step.
final (linkA, linkB) = _LoopbackTransport.pair(
stepsPerSecond: 30,
delaySeconds: 0.05,
lossRate: lossRate,
);
var aKnowsOfB = 0;
var bKnowsOfA = 0;
linkA.listen((int value) => aKnowsOfB = value);
linkB.listen((int value) => bKnowsOfA = value);
Step 3: Send with redundancy #
Every message a real NetSession sends repeats the last few steps as well
as the current one, so one dropped packet costs nothing as long as a later
message carrying the same step's data gets through. This page repeats the
current step three times running, the same idea in miniature.
var bSends = 0;
var aSends = 0;
for (var step = 1; step <= 60; step++) {
aSends = step;
bSends = step;
// The current count sent three times running: the same message a
// `redundancy` window resends, so a single dropped packet is caught
// by the next one.
for (var r = 0; r < 3; r++) {
linkA.send(aSends);
linkB.send(bSends);
}
linkA.tick();
linkB.tick();
}
At a 30% loss rate, both sides still end up within a step or two of the truth, and agreeing with each other about where that is — which is the whole point of resending rather than trusting one packet each.