Rollback netcode over a loopback: the source
Open the live demo · Read the guide · View on GitHub
Regions the guide quotes: transport (line 20), session (line 90), redundant (line 105).
1/// Two players' worth of a fixed-step simulation, kept in step across an
2/// unreliable transport: input frames, delay, prediction, and rollback on a
3/// confirmation that disagreed.
4///
5/// **`flutter3d_net` is not a dependency of this app.** `NetSession` and
6/// `LoopbackTransport` both need only `flutter3d_sim`'s `Snapshot` and
7/// `GameRandom` underneath, so this page reimplements the small pieces of
8/// each that the demo below actually exercises, against those same real
9/// types. `flutter3d_net_webrtc`, the transport for an actual connection
10/// between two browsers, is a native plugin and has no page of its own.
11///
12/// Quoted by `rollback_netcode.md` and shown whole in the Source tab.
13library;
15import 'package:flutter3d/flutter3d.dart';
16import 'package:flutter3d_showcase/src/demo/demo.dart';
17import 'package:flutter3d_sim/flutter3d_sim.dart';
18import 'package:vector_math/vector_math.dart';
20/// A pair of transports joined by a fixed delay and a loss rate, so a test
21/// is separated by an unreliable network rather than by nothing.
22final class _LoopbackTransport {
23 _LoopbackTransport._(this._delaySteps, this._lossRate, this._random);
25 final int _delaySteps;
26 final double _lossRate;
27 final GameRandom _random;
28 late final _LoopbackTransport _peer;
30 int _now = 0;
31 final List<(int dueAt, int value)> _inbox = <(int, int)>[];
32 void Function(int value)? _listener;
34 static (_LoopbackTransport, _LoopbackTransport) pair({
35 required int stepsPerSecond,
36 double delaySeconds = 0.0,
37 double lossRate = 0.0,
38 int seed = 1,
39 }) {
40 final random = GameRandom(seed);
41 final delaySteps = (delaySeconds * stepsPerSecond).round();
42 final a = _LoopbackTransport._(delaySteps, lossRate, random);
43 final b = _LoopbackTransport._(delaySteps, lossRate, random);
44 a._peer = b;
45 b._peer = a;
46 return (a, b);
47 }
49 void send(int value) {
50 if (_random.nextDouble() < _lossRate) return;
51 _peer._inbox.add((_peer._now + _delaySteps, value));
52 }
54 void listen(void Function(int value) onMessage) => _listener = onMessage;
56 void tick() {
57 _now++;
58 _inbox.removeWhere((entry) {
59 if (entry.$1 > _now) return false;
60 _listener?.call(entry.$2);
61 return true;
62 });
63 }
64}
66final class RollbackNetcodeDemo extends ShowcaseDemo {
67 double lossRate = 0.3;
68 late String _report;
71 Scene build(DemoContext context) {
72 _report = _run(lossRate);
73 final material = Material(
74 name: 'peer',
75 baseColor: Vector4(0.5, 0.6, 0.9, 1.0),
76 );
77 final node = MeshNode(
78 DeviceMesh.upload(context.device, SphereShape(segments: 16).build()),
79 material,
80 );
81 return Scene()
82 ..add(node)
83 ..add(
84 LightNode(name: 'sun', intensity: 3.0)
85 ..setLocalForward(Vector3(-0.4, -1.0, -0.3)),
86 );
87 }
89 static String _run(double lossRate) {
90 // Each peer's own count of "my presses the other side has heard about",
91 // standing in for a `NetSession.applyAndStep` that folds a remote frame
92 // into a shared simulation. Every message repeats the last few sends,
93 // the same redundancy `NetSession` uses, so one dropped packet is not
94 // one lost step.
95 final (linkA, linkB) = _LoopbackTransport.pair(
96 stepsPerSecond: 30,
97 delaySeconds: 0.05,
98 lossRate: lossRate,
99 );
100 var aKnowsOfB = 0;
101 var bKnowsOfA = 0;
102 linkA.listen((int value) => aKnowsOfB = value);
103 linkB.listen((int value) => bKnowsOfA = value);
105 var bSends = 0;
106 var aSends = 0;
107 for (var step = 1; step <= 60; step++) {
108 aSends = step;
109 bSends = step;
110 // The current count sent three times running: the same message a
111 // `redundancy` window resends, so a single dropped packet is caught
112 // by the next one.
113 for (var r = 0; r < 3; r++) {
114 linkA.send(aSends);
115 linkB.send(bSends);
116 }
117 linkA.tick();
118 linkB.tick();
119 }
121 final agree = aKnowsOfB == bKnowsOfA;
122 final close = (bSends - aKnowsOfB) <= 3 && (aSends - bKnowsOfA) <= 3;
123 return 'after sixty steps at a ${(lossRate * 100).round()}% loss rate:\n'
124 'A last heard B at $aKnowsOfB (B actually reached $bSends)\n'
125 'B last heard A at $bKnowsOfA (A actually reached $aSends)\n'
126 'both sides agree on the same number: $agree\n'
127 'both are within three steps of the truth: $close';
128 }
131 List<DemoControl> controls(DemoContext context) => <DemoControl>[
132 SliderControl(
133 'Loss rate',
134 min: 0.0,
135 max: 0.9,
136 value: () => lossRate,
137 onChanged: (double v) => lossRate = v,
138 ),
139 ];
142 void verify(Scene scene, FrameResult frame) {
143 if (frame.drawCalls < 1) {
144 throw StateError('the peer marker was not drawn');
145 }
146 if (!_report.contains('both sides agree on the same number: true')) {
147 throw StateError(
148 'both sides should end up knowing the other reached the same step, '
149 'even at a 30% loss rate',
150 );
151 }
152 if (!_report.contains('both are within three steps of the truth: true')) {
153 throw StateError(
154 'resending the last few steps should keep both sides close to the '
155 'truth, not stuck far behind it',
156 );
157 }
158 }
159}