flutter3d
Showcase Changelog 38 packages API reference

Rollback netcode over a loopback

since 0.6.0 Widgets and the rest

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_net is not a dependency of this app. Both classes need only flutter3d_sim's Snapshot and GameRandom underneath, 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.