dotframe 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,54 @@
1
1
  ---
2
2
  name: ios
3
- description: Build and install the iOS target of a dotframe game. Use when building for iPhone, fixing vendor links, signing, or installing on a device.
3
+ description: Build and install the iOS target of a dotframe game. Use when building for iPhone or iPad, writing the iOS entry, setting the bundle id, team, icon or orientation, installing on a device, or debugging touch input on iOS.
4
4
  ---
5
5
  # ios
6
6
 
7
7
  ```sh
8
- dotframe doctor --json # vendor links, scriptc, xcodegen, xcodebuild
9
- dotframe doctor --fix # creates missing vendor symlinks
10
- dotframe build ios --json # glue, scriptc library, xcodegen, xcodebuild
8
+ dotframe vendor ios # once per machine: SDL3 (Xcode generator) and wgpu-native for iOS
9
+ dotframe doctor # vendor:ios, ios-runtime (scriptc pack matching the compiler), team placeholder
10
+ dotframe build ios --json # dist/ios/<Scheme>.app
11
11
  dotframe device install ios --dry-run
12
- dotframe device install ios --yes # after approval; phone unlocked and trusted
12
+ dotframe device install ios --yes # after the human approves; phone unlocked and trusted
13
13
  ```
14
14
 
15
- - The iOS build needs SDL3 and wgpu-native iOS builds under `vendor/dotframe/vendor/`. They are local symlinks (`links` in dotframe.json), not in git. `doctor --fix` creates them when the targets exist.
16
- - Signing uses the team in the Xcode project; `-allowProvisioningUpdates` lets xcodebuild fetch profiles. A locked password manager can fail signing with `failed to fill whole buffer`: ask the human to unlock it and retry.
17
- - `targets.ios.app` is the built `.app`; `targets.ios.device` comes from `xcrun devicectl list devices`.
18
- - `build ios --release` refuses assets marked local-only (see `assets`). Debug builds for your own phone are fine.
19
- - Confirm on the device by asking the human, or with a screenshot if available. A successful install is not a working game.
15
+ ## The target
16
+
17
+ ```json
18
+ "ios": { "native": { "platform": "ios", "entry": "main.ios.ts", "bundleId": "run.crafter.mygame", "team": "ABCDE12345",
19
+ "icon": "assets/icon.png", "orientation": "landscape", "assets": "assets", "displayName": "My Game" },
20
+ "device": "<udid from xcrun devicectl list devices>" }
21
+ ```
22
+
23
+ The CLI stages the entry's import graph (like macOS), generates the library glue and profile, builds the scriptc library, generates the SDL3 host (`main.c`), `Info.plist` and `project.yml`, scales `icon` to 1024 px (iOS derives every other size; without `icon` the app has none), copies `assets` to `<app>/game/<assets>` and the engine fonts to `<app>/game/dotframe/assets/fonts`, and runs xcodegen and xcodebuild. Everything generated lives in `.dotframe/native/ios/`; nothing to commit.
24
+
25
+ ## The entry
26
+
27
+ scriptc library mode: the host calls `init(base)` once with the bundle's game folder and `frame(time)` every refresh. There are no promises, so load assets synchronously with the library platform's `readFile`, `image` and `sound`.
28
+
29
+ ```ts
30
+ import type { Frame } from "dotframe/src/gpu";
31
+ import { openLibraryPlatform } from "dotframe/src/native/library";
32
+
33
+ let tick: Frame | null = null;
34
+ export function init(base: string): void {
35
+ const platform = openLibraryPlatform(WINDOW);
36
+ const png = platform.readFile(`${base}/assets/hero.png`);
37
+ tick = createSetup(/* ... */)(platform);
38
+ }
39
+ export function frame(time: number): boolean {
40
+ return tick ? tick(time) : true;
41
+ }
42
+ ```
43
+
44
+ Templates ship this as `main.ios.ts` over the same `src/setup.ts` web and macOS use. A game with async loaders adds a sync variant that takes a `(path) => Uint8Array` reader, or writes one loader against that reader for every target.
45
+
46
+ ## Notes
47
+
48
+ - The scriptc runtime pack (`@scriptc/runtime-ios-arm64`) must match `scriptc --version`. Right after a scriptc release, bun's minimum release age blocks it; doctor prints the install command with `--minimum-release-age 0`.
49
+ - Touches never synthesize a mouse (SDL_HINT_TOUCH_MOUSE_EVENTS is off), so read `input.touches()` for fingers; `input.pointer()` stays the mouse.
50
+ - Size the logical canvas from `gpu.aspect()` (templates do): a 1280x720 canvas on a 2.16 phone stretches otherwise.
51
+ - Library mode has no `WebSocket`; online play is web only for now (`dotframe/src/relay-client` is web only).
52
+ - Signing is automatic with `team`; a locked password manager can fail signing with `failed to fill whole buffer`: ask the human to unlock it and retry.
53
+ - `build ios --release` refuses assets marked local-only (see `assets`).
54
+ - Confirm on the device by asking the human. A successful install is not a working game.
@@ -39,6 +39,33 @@ scriptc compiles a subset of TypeScript. What tripped the templates:
39
39
 
40
40
  Run `dotframe build macos` early and after each feature: one scriptc error is a quick fix, ninety are a port.
41
41
 
42
+ ## Porting existing TypeScript
43
+
44
+ What a real port (Craft Ones, 66 errors) hit, and the fix for each:
45
+
46
+ | scriptc rejects | Do instead |
47
+ |---|---|
48
+ | `Object.assign(target, ...)` | Assign fields one by one |
49
+ | `ArrayLike<T>` / `Iterable<T>` parameters | Take `T[]` |
50
+ | `readonly T[]`, `.find`/`.filter` on readonly tuples, `for...of` over tuples | Plain arrays (`T[]`), indexed loops |
51
+ | Destructuring an `unknown` network payload | Narrow field by field with `typeof` checks |
52
+ | Non-literal index into a tuple | Make it an array |
53
+ | Dynamic keyed reads on a record whose entries have different shapes | Give every entry the same shape (optional fields), or a `switch` |
54
+ | `string[i]` | `s.charAt(i)` or `s.charCodeAt(i)` |
55
+ | `Number.parseInt` in a static build | `parseInt` or `Math.trunc(Number(s))` |
56
+ | `++`/`--` inside an expression | Its own statement |
57
+ | `satisfies Record<K, Record<string, T>>` | A plain type annotation |
58
+
59
+ Runtime traps (compile clean, fail when run):
60
+
61
+ - **Structural coercion copies.** Passing a class instance, or a wider record, to a parameter typed as a narrower structural type passes a copy: the callee's writes to scalar fields are lost (arrays inside still alias). In Craft Ones every projectile froze mid-flight. Pass the exact type, or return the updated record and copy it back. Reported to scriptc.
62
+ - **Out-of-range reads.** `rows[y - 1]?.[x]` typed as `string` but `undefined` at runtime crashes with "undefined is not representable in the target union". Bounds-check before reading.
63
+ - **`JSON.stringify` key order** differed from JavaScript in Craft Ones (not reproduced in a minimal case). Never build checksums or netplay comparisons from it.
64
+
65
+ ## Debugging a native crash
66
+
67
+ `--optimization dev` builds failed to link in Craft Ones (undefined `_main`; a template game links fine). When that happens, debug the release binary with lldb: `lldb ./dist/macos/<name>`, `breakpoint set -n scr_error_new`, `run`, then `bt` shows the TypeScript call site of a runtime error.
68
+
42
69
  ## Other notes
43
70
 
44
71
  - Benchmarks mean nothing while other processes load the machine. Check `top` first and say what else was running.
@@ -4,6 +4,19 @@ description: Rollback netplay for dotframe games. Use when building or debugging
4
4
  ---
5
5
  # netplay
6
6
 
7
+ ## The engine
8
+
9
+ `dotframe/src/netplay` is a game-agnostic rollback engine: `createRollback({ sim, transport, localPort, neutral, inputDelay, maxRollback, resimulating })`, where `sim` is anything with `step(inputs)`, `save()`, `restore(s)` and `checksum()` (the Sim contract's shape). Call `tick(localInput)` once per fixed step; it returns whether a frame was simulated. `stats()` has rollbacks, stalls, round trip and the first desynced frame; `sumAt(frame)` gives this peer's checksum.
10
+
11
+ `dotframe/src/relay-client` (web only) connects to a relay: `connectRelay(url, room)` gives `status()`, `slot()`, game messages (`send`/`receive`) and the `transport` for createRollback. Use `dotframe/src/checksum` for checksums and `dotframe/src/detmath` for any trig, exp, log or pow in simulation code.
12
+
13
+ ```sh
14
+ dotframe relay serve # local relay on :8787, same protocol as production
15
+ dotframe play --online --frames 900 # two browsers, scripted match, checksums compared, screenshots saved
16
+ ```
17
+
18
+ `play --online` needs the web entry to honor `?room=`, `?relay=` and `?mash=<seed>` and to publish `globalThis.__dotframe = { frame, confirmed, status, sums }` (sums: checksum per 30th confirmed frame). The templates show it.
19
+
7
20
  dotframe online play is rollback netcode: each peer predicts the remote input (repeat the last one), simulates ahead, and when the real input arrives and differs, restores a snapshot and resimulates. It only works if every peer computes bit-identical state from the same inputs.
8
21
 
9
22
  ## Test before going online
@@ -30,6 +43,10 @@ Run several seeds and a high latency (474 ms is what a transatlantic relay measu
30
43
 
31
44
  ## Rules
32
45
 
46
+ - Simulation code uses `dotframe/src/detmath` (dsin, dcos, datan2, dexp, dlog, dpow, ...), never `Math.sin` and friends or `**`: those differ in the last bits between engines and operating systems, so peers and replays drift (Craft Ones' replays diverged between macOS and Linux at frame 120). `dotframe doctor` warns about them (`sim:math`); mark render-only lines with `// dotframe-allow-math`. A replay passing on one machine proves nothing about another: run CI on a second OS.
47
+ - `desync` reports `restoreMs` early vs late; it warns when late restores cost more than twice the early ones (a heuristic for restore that replays from an earlier frame, which passes desync but stalls real rollbacks late in a match).
48
+ - Build checksums from numbers in a fixed order, never from `JSON.stringify`: native builds can order keys differently from JavaScript, so a web peer and a native peer would report false desyncs.
49
+
33
50
  - The camera, effects, and HUD timers are simulation state if the simulation ever reads them. Update them once per step, never per drawn frame.
34
51
  - Render must restore any random state it uses, or use a separate visual generator.
35
52
  - Input delay (`--delay`, default 2) trades latency for fewer rollbacks.
@@ -0,0 +1,22 @@
1
+ // A checksum over simulation numbers in a fixed order. Netplay peers compare it every few frames, so it must not
2
+ // depend on object key order (JSON.stringify differs between JavaScript and scriptc builds) or on float printing.
3
+ export interface Checksum {
4
+ // Adds a number, rounded to `scale` (default 1e-6 resolution) so harmless float noise does not count.
5
+ add: (value: number) => Checksum;
6
+ value: () => number;
7
+ }
8
+
9
+ export function createChecksum(scale = 1e6): Checksum {
10
+ let h = 2166136261;
11
+ const sum: Checksum = {
12
+ add: (value: number): Checksum => {
13
+ const v = Number.isNaN(value) ? 0x7ff8 : Math.round(value * scale);
14
+ // Low and high 32 bits, so large values still change the hash.
15
+ h = Math.imul(h ^ (v | 0), 16777619);
16
+ h = Math.imul(h ^ Math.floor(v / 4294967296), 16777619);
17
+ return sum;
18
+ },
19
+ value: (): number => h >>> 0,
20
+ };
21
+ return sum;
22
+ }
package/src/detmath.ts ADDED
@@ -0,0 +1,217 @@
1
+ // Deterministic math for simulation code (from Craft Ones' packages/shared, extended). Math.sin, Math.cos, Math.exp and Math.hypot are not specified to the
2
+ // last bit, and engines differ (JavaScriptCore on macOS and on Linux disagreed and broke the golden replays), so
3
+ // two peers on different platforms would drift apart. These use only +, -, *, / and Math.sqrt, which IEEE 754
4
+ // fixes exactly, with fdlibm's kernels. Accuracy is within a couple of ulps, far below anything a player sees.
5
+
6
+ const PIO2_HI = 1.5707963267341256; // first 33 bits of pi/2
7
+ const PIO2_LO = 6.077100506506192e-11; // pi/2 - PIO2_HI
8
+ const TWO_OVER_PI = 0.6366197723675814;
9
+
10
+ // fdlibm __kernel_sin and __kernel_cos on [-pi/4, pi/4].
11
+ function kernelSin(x: number): number {
12
+ const z = x * x;
13
+ const v = z * x;
14
+ const r =
15
+ 0.00833333333332249 +
16
+ z *
17
+ (-0.0001984126982985795 +
18
+ z *
19
+ (2.7557313707070068e-6 +
20
+ z * (-2.5050760253406863e-8 + z * 1.58969099521155e-10)));
21
+ return x + v * (-0.16666666666666632 + z * r);
22
+ }
23
+
24
+ function kernelCos(x: number): number {
25
+ const z = x * x;
26
+ const r =
27
+ z *
28
+ (0.0416666666666666 +
29
+ z *
30
+ (-0.001388888888887411 +
31
+ z *
32
+ (2.480158728947673e-5 +
33
+ z *
34
+ (-2.7557314351390663e-7 +
35
+ z * (2.087572321298175e-9 + z * -1.1359647557788195e-11)))));
36
+ const hz = 0.5 * z;
37
+ const w = 1 - hz;
38
+ return w + (1 - w - hz + z * r);
39
+ }
40
+
41
+ // Cody-Waite reduction; game angles stay within a few turns, where it is exact enough.
42
+ function reduce(x: number): { r: number; q: number } {
43
+ const k = Math.round(x * TWO_OVER_PI);
44
+ const r = x - k * PIO2_HI - k * PIO2_LO;
45
+ return { r, q: ((k % 4) + 4) % 4 };
46
+ }
47
+
48
+ export function dsin(x: number): number {
49
+ const { r, q } = reduce(x);
50
+ return q === 0
51
+ ? kernelSin(r)
52
+ : q === 1
53
+ ? kernelCos(r)
54
+ : q === 2
55
+ ? -kernelSin(r)
56
+ : -kernelCos(r);
57
+ }
58
+
59
+ export function dcos(x: number): number {
60
+ const { r, q } = reduce(x);
61
+ return q === 0
62
+ ? kernelCos(r)
63
+ : q === 1
64
+ ? -kernelSin(r)
65
+ : q === 2
66
+ ? -kernelCos(r)
67
+ : kernelSin(r);
68
+ }
69
+
70
+ // fdlibm exp: x = k*ln2 + r, |r| <= ln2/2, then a rational approximation of e^r.
71
+ const LN2_HI = 0.6931471803691238;
72
+ const LN2_LO = 1.9082149292705877e-10;
73
+ const INV_LN2 = Math.LOG2E;
74
+ export function dexp(x: number): number {
75
+ if (x > 709) return Number.POSITIVE_INFINITY;
76
+ if (x < -745) return 0;
77
+ const k = Math.round(x * INV_LN2);
78
+ const hi = x - k * LN2_HI;
79
+ const lo = k * LN2_LO;
80
+ const r = hi - lo;
81
+ const t = r * r;
82
+ const c =
83
+ r -
84
+ t *
85
+ (0.16666666666666602 +
86
+ t *
87
+ (-0.0027777777777015593 +
88
+ t *
89
+ (6.613756321437934e-5 +
90
+ t * (-1.6533902205465252e-6 + t * 4.1381367970572385e-8))));
91
+ let y = 1 - (lo - (r * c) / (2 - c) - hi);
92
+ // Scale by 2^k with exact multiplications.
93
+ let n = k;
94
+ while (n > 0) {
95
+ y *= 2;
96
+ n -= 1;
97
+ }
98
+ while (n < 0) {
99
+ y *= 0.5;
100
+ n += 1;
101
+ }
102
+ return y;
103
+ }
104
+
105
+ export function dtan(x: number): number {
106
+ const { r, q } = reduce(x);
107
+ const s = kernelSin(r);
108
+ const c = kernelCos(r);
109
+ return q % 2 === 0 ? s / c : -c / s;
110
+ }
111
+
112
+ // fdlibm log: x = 2^k * (1 + f) with sqrt(2)/2 <= 1 + f < sqrt(2), then a polynomial in s = f / (2 + f).
113
+ export function dlog(x: number): number {
114
+ if (Number.isNaN(x) || x < 0) return Number.NaN;
115
+ if (x === 0) return Number.NEGATIVE_INFINITY;
116
+ if (x === Number.POSITIVE_INFINITY) return x;
117
+ let m = x;
118
+ let k = 0;
119
+ // Exact power-of-two scaling, no bit access.
120
+ while (m >= 2) {
121
+ m *= 0.5;
122
+ k += 1;
123
+ }
124
+ while (m < 1) {
125
+ m *= 2;
126
+ k -= 1;
127
+ }
128
+ if (m > Math.SQRT2) {
129
+ m *= 0.5;
130
+ k += 1;
131
+ }
132
+ const f = m - 1;
133
+ const s = f / (2 + f);
134
+ const z = s * s;
135
+ const w = z * z;
136
+ const t1 = w * (0.3999999999940942 + w * (0.22222198432149784 + w * 0.15313837699209373));
137
+ const t2 = z * (0.6666666666666735 + w * (0.2857142874366239 + w * (0.1818357216161805 + w * 0.14798198605116586)));
138
+ const hfsq = 0.5 * f * f;
139
+ return k * LN2_HI - (hfsq - (s * (hfsq + t1 + t2) + k * LN2_LO) - f);
140
+ }
141
+
142
+ // fdlibm atan: reduce |x| to a small argument around 0, 0.5, 1, 1.5 or infinity, then a polynomial.
143
+ const ATAN_HI = [0.4636476090008061, 0.7853981633974483, 0.982793723247329, 1.5707963267948966];
144
+ const ATAN_LO = [2.2698777452961687e-17, 3.061616997868383e-17, 1.3903311031230998e-17, 6.123233995736766e-17];
145
+ export function datan(x: number): number {
146
+ if (Number.isNaN(x)) return x;
147
+ const negative = x < 0;
148
+ let t = negative ? -x : x;
149
+ let id = -1;
150
+ if (t >= 0.4375) {
151
+ if (t < 0.6875) {
152
+ id = 0;
153
+ t = (2 * t - 1) / (2 + t);
154
+ } else if (t < 1.1875) {
155
+ id = 1;
156
+ t = (t - 1) / (t + 1);
157
+ } else if (t < 2.4375) {
158
+ id = 2;
159
+ t = (t - 1.5) / (1 + 1.5 * t);
160
+ } else {
161
+ id = 3;
162
+ t = -1 / t;
163
+ }
164
+ }
165
+ const z = t * t;
166
+ const w = z * z;
167
+ const s1 =
168
+ z *
169
+ (0.3333333333333293 +
170
+ w * (0.14285714272503466 + w * (0.09090887133436507 + w * (0.06661073137387531 + w * (0.049768779946159324 + w * 0.016285820115365782)))));
171
+ const s2 = w * (-0.19999999999876483 + w * (-0.11111110405462356 + w * (-0.0769187620504483 + w * (-0.058335701337905735 + w * -0.036531572744216916))));
172
+ const result = id < 0 ? t - t * (s1 + s2) : ATAN_HI[id] - (t * (s1 + s2) - ATAN_LO[id] - t);
173
+ return negative ? -result : result;
174
+ }
175
+
176
+ const PI_LO = 1.2246467991473532e-16;
177
+ export function datan2(y: number, x: number): number {
178
+ if (Number.isNaN(x) || Number.isNaN(y)) return Number.NaN;
179
+ if (x === 0) return y === 0 ? (1 / x < 0 ? (1 / y < 0 ? -Math.PI : Math.PI) : y) : y > 0 ? Math.PI / 2 : -Math.PI / 2;
180
+ const z = datan(Math.abs(y / x));
181
+ const angle = x > 0 ? z : Math.PI - (z - PI_LO);
182
+ return y < 0 || (y === 0 && 1 / y < 0) ? -angle : angle;
183
+ }
184
+
185
+ // Integer exponents multiply exactly; anything else goes through dexp and dlog (deterministic, a few ulps).
186
+ export function dpow(x: number, y: number): number {
187
+ if (Number.isInteger(y) && Math.abs(y) <= 64) {
188
+ let result = 1;
189
+ let base = y < 0 ? 1 / x : x;
190
+ let n = Math.abs(y);
191
+ while (n > 0) {
192
+ if (n % 2 === 1) result *= base;
193
+ base *= base;
194
+ n = Math.floor(n / 2);
195
+ }
196
+ return result;
197
+ }
198
+ if (x === 0) return y > 0 ? 0 : Number.POSITIVE_INFINITY;
199
+ if (x < 0) return Number.isInteger(y) ? (y % 2 === 0 ? 1 : -1) * dexp(y * dlog(-x)) : Number.NaN;
200
+ return dexp(y * dlog(x));
201
+ }
202
+
203
+ export function dhypot(x: number, y: number): number {
204
+ return Math.sqrt(x * x + y * y);
205
+ }
206
+
207
+ // An angle wrapped to (-pi, pi], without atan2.
208
+ const TAU = 6.283185307179586;
209
+ export function wrapAngle(a: number): number {
210
+ let w = a - TAU * Math.round(a / TAU);
211
+ if (w <= -Math.PI) w += TAU;
212
+ return w;
213
+ }
214
+
215
+ export function sq(x: number): number {
216
+ return x * x;
217
+ }
package/src/netplay.ts ADDED
@@ -0,0 +1,232 @@
1
+ // Rollback netplay for two peers, game-agnostic (generalized from Crafter Smash). Each peer simulates every frame
2
+ // immediately, predicting the remote input as "same as the last one seen". When the real input arrives and differs,
3
+ // it restores the snapshot taken before that frame and re-simulates to the present. Local input is delayed a few
4
+ // frames to hide most of the latency, so rollbacks stay short.
5
+ //
6
+ // The relay client (web only) is in src/relay-client.
7
+ //
8
+ // The simulation is anything with the shape of the CLI's SimRun: step, save, restore, checksum. `dotframe desync`
9
+ // tests the same contract.
10
+
11
+ export interface NetSim<S = unknown> {
12
+ step: (inputs: number[]) => void;
13
+ save: () => S;
14
+ restore: (snapshot: S) => void;
15
+ checksum: () => number;
16
+ }
17
+
18
+ export interface InputMessage {
19
+ t: "input";
20
+ // The sender's current frame, and the latest `now` it has received from us (an echo, to measure the round trip).
21
+ now: number;
22
+ ack: number;
23
+ // Consecutive inputs starting at frame `from`.
24
+ from: number;
25
+ inputs: number[];
26
+ }
27
+
28
+ export interface SumMessage {
29
+ t: "sum";
30
+ frame: number;
31
+ sum: number;
32
+ }
33
+
34
+ export type NetMessage = InputMessage | SumMessage;
35
+
36
+ export interface Transport {
37
+ send: (message: NetMessage) => void;
38
+ // Messages received since the last call, in order.
39
+ receive: () => NetMessage[];
40
+ }
41
+
42
+ export interface RollbackOptions<S = unknown> {
43
+ sim: NetSim<S>;
44
+ transport: Transport;
45
+ // 0 or 1: which player this peer controls.
46
+ localPort: number;
47
+ // The encoded "nothing pressed" input.
48
+ neutral: number;
49
+ inputDelay: number;
50
+ // Past this many unconfirmed frames the peer waits instead of predicting further.
51
+ maxRollback: number;
52
+ // Called around re-simulation, so the game can mute sound and skip effects that should play once.
53
+ resimulating?: (active: boolean) => void;
54
+ }
55
+
56
+ export interface RollbackStats {
57
+ frame: number;
58
+ rollbacks: number;
59
+ longestRollback: number;
60
+ stalls: number;
61
+ // First frame whose checksum differed from the peer's, or -1.
62
+ desync: number;
63
+ // Smoothed round trip and lead over the peer, in frames.
64
+ rtt: number;
65
+ ahead: number;
66
+ // Time spent in the last tick (rollback, re-simulation and the new frame), in ms.
67
+ tickMs: number;
68
+ }
69
+
70
+ export interface Rollback {
71
+ // One display tick: sends local input, applies remote input, rolls back if needed, then advances a frame unless
72
+ // too far ahead of the peer. Returns whether a frame was simulated.
73
+ tick: (localInput: number) => boolean;
74
+ // Applies received input and rolls back without advancing.
75
+ settle: () => void;
76
+ // Highest frame below which both peers' inputs are known (the state there is final).
77
+ confirmedFrame: () => number;
78
+ // This peer's checksum after simulating `frame`, final once frame < confirmedFrame().
79
+ sumAt: (frame: number) => number | undefined;
80
+ stats: () => RollbackStats;
81
+ }
82
+
83
+ const SUM_EVERY = 30;
84
+ // Weight of each new sample in the round-trip and lead averages (an exponential moving average over about 10 ticks).
85
+ const SMOOTHING = 0.1;
86
+ // Inputs resent with every message so a late peer catches up without acknowledgments.
87
+ const RESEND = 8;
88
+
89
+ export function createRollback<S>(options: RollbackOptions<S>): Rollback {
90
+ const { sim, transport, localPort, neutral, inputDelay, maxRollback } = options;
91
+ const remotePort = 1 - localPort;
92
+ const local: number[] = [];
93
+ const remote: number[] = [];
94
+ // Remote input each simulated frame actually used, to detect mispredictions.
95
+ const used: number[] = [];
96
+ const snapshots: (S | undefined)[] = [];
97
+ const sums: number[] = [];
98
+ const peerSums = new Map<number, number>();
99
+ let frame = 0;
100
+ // Every remote input below this frame is known.
101
+ let confirmed = inputDelay;
102
+ let peerNow = 0;
103
+ let rtt = 0;
104
+ let ahead = 0;
105
+ let sentSums = 0;
106
+ const stats: RollbackStats = { frame: 0, rollbacks: 0, longestRollback: 0, stalls: 0, desync: -1, rtt: 0, ahead: 0, tickMs: 0 };
107
+
108
+ for (let f = 0; f < inputDelay; f++) {
109
+ local[f] = neutral;
110
+ remote[f] = neutral;
111
+ }
112
+
113
+ const remoteFor = (f: number): number => {
114
+ if (f < confirmed) return remote[f];
115
+ return confirmed > 0 ? remote[confirmed - 1] : neutral;
116
+ };
117
+
118
+ const simulate = (f: number): void => {
119
+ snapshots[f] = sim.save();
120
+ const inputs = [neutral, neutral];
121
+ inputs[localPort] = local[f] ?? neutral;
122
+ const r = remoteFor(f);
123
+ inputs[remotePort] = r;
124
+ used[f] = r;
125
+ sim.step(inputs);
126
+ sums[f] = sim.checksum();
127
+ // Old snapshots are never rolled back to.
128
+ const drop = f - maxRollback - 2;
129
+ if (drop >= 0) snapshots[drop] = undefined;
130
+ };
131
+
132
+ const compareSums = (): void => {
133
+ for (const [f, sum] of peerSums) {
134
+ if (f >= confirmed || f >= frame) continue;
135
+ if (sums[f] !== sum && stats.desync < 0) stats.desync = f;
136
+ peerSums.delete(f);
137
+ }
138
+ };
139
+
140
+ const settle = (): void => {
141
+ let rollbackTo = frame;
142
+ for (const message of transport.receive()) {
143
+ if (message.t === "sum") {
144
+ peerSums.set(message.frame, message.sum);
145
+ continue;
146
+ }
147
+ if (message.now >= peerNow) {
148
+ peerNow = message.now;
149
+ rtt = rtt * (1 - SMOOTHING) + Math.max(0, frame - message.ack) * SMOOTHING;
150
+ }
151
+ for (let i = 0; i < message.inputs.length; i++) {
152
+ const f = message.from + i;
153
+ if (f < confirmed) continue;
154
+ if (f !== confirmed) break;
155
+ remote[f] = message.inputs[i];
156
+ confirmed = f + 1;
157
+ if (f < frame && used[f] !== remote[f] && f < rollbackTo) rollbackTo = f;
158
+ }
159
+ }
160
+ // Frames after the last confirmed one were predicted from it; a new confirmation can change them too.
161
+ for (let f = confirmed; f < frame && rollbackTo === frame; f++) if (used[f] !== remoteFor(f)) rollbackTo = f;
162
+
163
+ if (rollbackTo < frame) {
164
+ const snapshot = snapshots[rollbackTo];
165
+ if (snapshot !== undefined) {
166
+ stats.rollbacks += 1;
167
+ stats.longestRollback = Math.max(stats.longestRollback, frame - rollbackTo);
168
+ sim.restore(snapshot);
169
+ options.resimulating?.(true);
170
+ for (let f = rollbackTo; f < frame; f++) simulate(f);
171
+ options.resimulating?.(false);
172
+ }
173
+ }
174
+ };
175
+
176
+ const step = (localInput: number): boolean => {
177
+ settle();
178
+ // Wait rather than predict too far, or run ahead of a slower peer. The peer's last reported frame is one trip
179
+ // old; its current frame is about that plus half the round trip. Waiting on the raw gap instead would make both
180
+ // peers wait for each other and play at round-trip speed.
181
+ ahead = ahead * (1 - SMOOTHING) + (frame - (peerNow + rtt / 2)) * SMOOTHING;
182
+ if (frame - confirmed >= maxRollback || ahead > 1) {
183
+ stats.stalls += 1;
184
+ const from = Math.max(0, frame + inputDelay - RESEND);
185
+ transport.send({ t: "input", now: frame, ack: peerNow, from, inputs: local.slice(from, frame + inputDelay) });
186
+ return false;
187
+ }
188
+ local[frame + inputDelay] = localInput;
189
+ const from = Math.max(0, frame + inputDelay + 1 - RESEND);
190
+ transport.send({ t: "input", now: frame, ack: peerNow, from, inputs: local.slice(from, frame + inputDelay + 1) });
191
+ simulate(frame);
192
+ frame += 1;
193
+ while (sentSums + SUM_EVERY < confirmed && sentSums + SUM_EVERY < frame) {
194
+ sentSums += SUM_EVERY;
195
+ transport.send({ t: "sum", frame: sentSums, sum: sums[sentSums] });
196
+ }
197
+ compareSums();
198
+ stats.frame = frame;
199
+ return true;
200
+ };
201
+
202
+ return {
203
+ tick: (localInput: number): boolean => {
204
+ const started = performance.now();
205
+ const advanced = step(localInput);
206
+ stats.tickMs = performance.now() - started;
207
+ stats.rtt = rtt;
208
+ stats.ahead = ahead;
209
+ return advanced;
210
+ },
211
+ settle,
212
+ confirmedFrame: (): number => Math.min(confirmed, frame),
213
+ sumAt: (f: number): number | undefined => sums[f],
214
+ stats: (): RollbackStats => stats,
215
+ };
216
+ }
217
+
218
+ // --- Relay client ---------------------------------------------------------------------------------------------
219
+
220
+ export interface RelayLink<A = unknown> {
221
+ room: string;
222
+ // "connecting", "waiting" (alone in the room), "paired", "closed".
223
+ status: () => string;
224
+ // 0 or 1 once the relay answers, -1 before.
225
+ slot: () => number;
226
+ // Game messages (lobby, picks, match start): anything that is not netplay traffic.
227
+ send: (message: A) => void;
228
+ receive: () => A[];
229
+ // Netplay traffic for createRollback.
230
+ transport: Transport;
231
+ close: () => void;
232
+ }
@@ -0,0 +1,42 @@
1
+ // Web only: a WebSocket to a `dotframe relay serve` relay. Native and iOS builds have no WebSocket, so this lives
2
+ // apart from src/netplay, which every target compiles.
3
+ import type { NetMessage, RelayLink } from "./netplay";
4
+
5
+ // Splits relay traffic into game messages and netplay messages.
6
+ export function connectRelay<A = unknown>(url: string, room: string): RelayLink<A> {
7
+ const socket = new WebSocket(`${url}${url.includes("?") ? "&" : "?"}room=${encodeURIComponent(room)}`);
8
+ let status = "connecting";
9
+ let slot = -1;
10
+ const app: A[] = [];
11
+ const net: NetMessage[] = [];
12
+ socket.onmessage = (event: MessageEvent): void => {
13
+ const message = JSON.parse(String(event.data)) as { t?: string; slot?: number; here?: boolean };
14
+ if (message.t === "hello") {
15
+ slot = message.slot ?? -1;
16
+ status = "waiting";
17
+ } else if (message.t === "peer") {
18
+ status = message.here ? "paired" : "waiting";
19
+ // A peer leaving drops whatever netplay traffic was in flight.
20
+ if (!message.here) net.length = 0;
21
+ } else if (message.t === "input" || message.t === "sum") net.push(message as NetMessage);
22
+ else app.push(message as A);
23
+ };
24
+ socket.onclose = (): void => {
25
+ status = "closed";
26
+ };
27
+ const send = (message: unknown): void => {
28
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
29
+ };
30
+ return {
31
+ room,
32
+ status: (): string => status,
33
+ slot: (): number => slot,
34
+ send: (message: A): void => send(message),
35
+ receive: (): A[] => app.splice(0, app.length),
36
+ transport: {
37
+ send: (message: NetMessage): void => send(message),
38
+ receive: (): NetMessage[] => net.splice(0, net.length),
39
+ },
40
+ close: (): void => socket.close(),
41
+ };
42
+ }
@@ -8,7 +8,16 @@
8
8
  "deploy": { "provider": "vercel", "project": "__NAME__", "scope": "__SCOPE__" }
9
9
  },
10
10
  "macos": { "native": { "platform": "macos", "entry": "main.native.ts" } },
11
- "windows": { "native": { "platform": "windows", "entry": "main.native.ts" } }
11
+ "windows": { "native": { "platform": "windows", "entry": "main.native.ts" } },
12
+ "ios": {
13
+ "native": {
14
+ "platform": "ios",
15
+ "entry": "main.ios.ts",
16
+ "bundleId": "com.example.__NAME__",
17
+ "team": "YOUR_TEAM_ID",
18
+ "orientation": "landscape"
19
+ }
20
+ }
12
21
  },
13
22
  "assets": { "localOnly": [] }
14
23
  }