@hopple/netcode 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/lockstep.d.ts +252 -0
- package/lockstep.js +672 -0
- package/package.json +40 -4
- package/session.d.ts +226 -0
- package/session.js +361 -0
- package/shared-BR4Zxz2l.js +185 -0
- package/shared-B_2uj6dc.d.ts +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Hopple authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,196 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hopple/netcode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
What a game bundles to play a match over the network, on the Hopple SDK's
|
|
4
|
+
match: its messages as bytes, the lobby between games, and a lockstep helper,
|
|
5
|
+
with rollback.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @hopple/sdk-preview-network @hopple/netcode
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
A game **bundles** it, as it bundles
|
|
12
|
+
[`@hopple/deterministic`](https://www.npmjs.com/package/@hopple/deterministic):
|
|
13
|
+
whatever decides the game is frozen into the game when it is built, so every
|
|
14
|
+
device in a match runs the same bytes. It needs nothing but the game's own
|
|
15
|
+
`@hopple/sdk` and
|
|
16
|
+
[`@hopple/sdk-preview-network`](https://www.npmjs.com/package/@hopple/sdk-preview-network), peer
|
|
17
|
+
dependencies, runs the same in browsers and in Node, and a game takes only the
|
|
18
|
+
parts it imports. Install `@hopple/sdk-preview-network` beside it, as above, even where a
|
|
19
|
+
package manager would install it for you: depending on it is what tells
|
|
20
|
+
`hopple` the game plays over the network, and at which revision.
|
|
21
|
+
|
|
22
|
+
The only contract is the network preview's match: one log, every message in one order on
|
|
23
|
+
every device, the sender's own included, and what each player kept there for
|
|
24
|
+
whoever comes later. A game is welcome to write any part of this its own way.
|
|
25
|
+
|
|
26
|
+
## Bytes
|
|
27
|
+
|
|
28
|
+
A match's messages are bytes. `ByteWriter` and `ByteReader` write numbers and
|
|
29
|
+
strings, and read them back exactly: integers as varints (a byte under 128),
|
|
30
|
+
floats as their eight bytes, strings as UTF-8.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { ByteReader, ByteWriter } from '@hopple/netcode';
|
|
34
|
+
|
|
35
|
+
match.send(new ByteWriter().uint(PICK).uint(fighter).bool(ready).finish());
|
|
36
|
+
|
|
37
|
+
const read = new ByteReader(data); // throws a RangeError on a malformed message
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## The lobby
|
|
41
|
+
|
|
42
|
+
`@hopple/netcode/session` is the lobby every networked game has between its
|
|
43
|
+
games: each player's pick and readiness, the host's settings, and the start of
|
|
44
|
+
each game.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { ByteWriter } from '@hopple/netcode';
|
|
48
|
+
import { session } from '@hopple/netcode/session';
|
|
49
|
+
|
|
50
|
+
const lobby = session({
|
|
51
|
+
match: sdk.match,
|
|
52
|
+
// What a player picks, and how it travels: written after the session's own bytes.
|
|
53
|
+
pick: { initial: 0, write: (out, fighter) => out.uint(fighter), read: (input) => input.uint() },
|
|
54
|
+
// The host's settings.
|
|
55
|
+
settings: { initial: { rounds: 3 }, write: (out, s) => out.uint(s.rounds), read: (input) => ({ rounds: input.uint() }) },
|
|
56
|
+
// What the host's start carries of the game's own.
|
|
57
|
+
config: { write: (out, c) => out.uint(c.rounds), read: (input) => ({ rounds: input.uint() }) },
|
|
58
|
+
// Every device in the match, at the same place in the log: build the game from `start` alone.
|
|
59
|
+
onStart: (start) => play(start),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await sdk.match.play({ maxPlayers: 4 }); // the party's match, or a new one
|
|
63
|
+
lobby.pick(fighter);
|
|
64
|
+
lobby.ready(true);
|
|
65
|
+
for (const one of lobby.players) drawPlayer(one.person.name, one.pick, one.ready, one.host);
|
|
66
|
+
|
|
67
|
+
if (lobby.hosting) {
|
|
68
|
+
lobby.configure({ rounds: 5 });
|
|
69
|
+
if (lobby.waiting.length === 0) lobby.start({ rounds: lobby.settings.rounds });
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- **Nothing is said twice.** A pick and readiness are one message kept under
|
|
74
|
+
one key, the host's settings one kept under another: whoever joins later, or
|
|
75
|
+
whose game loads again, finds them in `match.kept`. A game loaded again picks
|
|
76
|
+
up its own pick, readiness and, as host, settings.
|
|
77
|
+
- **The host starts each game with one message** carrying a run number, a
|
|
78
|
+
seed, who sits where (every player in the order they joined, or the seats
|
|
79
|
+
`start` is given), what each picked as the host saw it, and the game's
|
|
80
|
+
config. Every device hears it at the same `seq` and builds the same game; a
|
|
81
|
+
device whose person has no seat watches. A start from anybody else starts
|
|
82
|
+
nothing. Nobody is ready for the next game until they say so again.
|
|
83
|
+
- **The host is the match's**: the player who made it, then whoever it was
|
|
84
|
+
handed to, then, once the host has been disconnected or away for 10 s, the
|
|
85
|
+
one still there who has played it longest. Read `lobby.hosting` every frame, not once. A new
|
|
86
|
+
host keeps the settings the last one left. `lobby.waiting` leaves out
|
|
87
|
+
anybody whose connection is down: seated anyway, a lockstep run hands their
|
|
88
|
+
seat to the game until they are back.
|
|
89
|
+
- **Being in the match is the game's.** The session follows
|
|
90
|
+
`sdk.match.current` and never joins or makes a match itself: the game calls
|
|
91
|
+
`sdk.match.play()` or `create()`, or the host puts the player in one from
|
|
92
|
+
an invite or a link, and the session is the lobby of whichever match that
|
|
93
|
+
is. A player whose connection comes back says their pick, and as host the
|
|
94
|
+
settings, again, as what was said as it went may have gone with it.
|
|
95
|
+
- Its messages start with `SESSION_TAG`, and `isSessionMessage` tells them
|
|
96
|
+
apart; it keeps under keys 0 and 1 unless `keys` says otherwise.
|
|
97
|
+
|
|
98
|
+
## Lockstep, if it suits
|
|
99
|
+
|
|
100
|
+
`@hopple/netcode/lockstep` is one way to keep a match's devices in step: every
|
|
101
|
+
device runs the same game, step by step, on every player's input for that
|
|
102
|
+
step. Its game is one that computes the same everywhere: `@hopple/deterministic`.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { hash } from '@hopple/deterministic';
|
|
106
|
+
import { lockstep } from '@hopple/netcode/lockstep';
|
|
107
|
+
|
|
108
|
+
// The session's start has what a run needs: the match, its `seq`, the run, the seats and `me`.
|
|
109
|
+
function play(start) {
|
|
110
|
+
game = newGame(start.seed, start.picks, start.config);
|
|
111
|
+
run = lockstep({
|
|
112
|
+
...start,
|
|
113
|
+
sample: () => Uint8Array.of(buttons()), // this device's input, a few bytes
|
|
114
|
+
step: (inputs) => game.step(inputs), // every seat's, or null for one the game plays
|
|
115
|
+
hash: () => hash(game.summary()),
|
|
116
|
+
onDesync: (tick) => report(tick),
|
|
117
|
+
onLost: () => leave(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
sdk.loop.tick((dt) => {
|
|
122
|
+
run?.advance(dt * 1000);
|
|
123
|
+
draw();
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- **Several players on one device.** The session's start says how many play
|
|
128
|
+
on each seat's device, as the match counts them (`start.locals`), and every
|
|
129
|
+
player of the game seat by seat (`start.players`: `seat`, `person`, `local`,
|
|
130
|
+
`me`). Given `locals`, which `...start` hands it, `sample` returns one input
|
|
131
|
+
for each player on this device and `step` is given one for each player of the
|
|
132
|
+
match; a device's players still travel in one message, and a device handed to
|
|
133
|
+
the game hands over each of its players.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const mine = start.players.filter((one) => one.me).length;
|
|
137
|
+
sample: () => sdk.input.players.slice(0, mine).map((player) => bits(player.read())),
|
|
138
|
+
step: (inputs) => game.step(inputs), // one for each of start.players
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Without the session, a run is made in the listener that hears the message
|
|
142
|
+
starting it, with that message's `seq`, a `run` number that differs from the
|
|
143
|
+
match's other runs, and the same `seats` on every device.
|
|
144
|
+
|
|
145
|
+
- **Input delay, as the network needs.** Each device's input is read some
|
|
146
|
+
steps ahead of the step it is for, so it can cross the relay in time. Every
|
|
147
|
+
device times its own round trips and says how many steps it needs, and all of
|
|
148
|
+
them read with the most any player needs: the same delay for everybody, so
|
|
149
|
+
nobody's controls answer later than another's. It grows as soon as somebody
|
|
150
|
+
needs more and shrinks after a few seconds of needing less. A game sets its
|
|
151
|
+
bounds, the same on every device: `delay: { min, max, start }` (2, 20 and 6
|
|
152
|
+
steps by default; a step is 16.7 ms). Round trips longer than `max` slow the
|
|
153
|
+
match down; `min` equal to `max` holds the delay fixed. `run.delay` is the
|
|
154
|
+
delay now, and `run.rtt` the last round trip.
|
|
155
|
+
- **Sampling.** `sample` is asked for each step in order, but not always once a
|
|
156
|
+
frame: several times while the delay grows, and now and then not at all while
|
|
157
|
+
it shrinks. Give each press to the one call after it.
|
|
158
|
+
- **Waiting and catching up.** A step runs once every seat's input for it is
|
|
159
|
+
in, the device's own included. Until then the game waits; after a hitch, or
|
|
160
|
+
time in the background, it catches up, up to `catchUp` steps a frame.
|
|
161
|
+
- **Seats handed to the game.** A seat whose person left the match, or kept
|
|
162
|
+
everybody waiting for `timeoutMs`, reaches `step` as `null` from a step every
|
|
163
|
+
device agrees on: the game plays it (its AI, or nobody). Its device, once
|
|
164
|
+
caught up, asks for it back.
|
|
165
|
+
- **Rollback, optionally.** With `rollback: { steps, save, restore, predict? }`
|
|
166
|
+
a device does not wait for the others' inputs: it runs on, up to `steps`
|
|
167
|
+
ahead of the log, guessing that each player still presses what they last
|
|
168
|
+
pressed (`predict` says otherwise, for inputs that say what went down this
|
|
169
|
+
step). When an input differs from the guess, it `restore`s the game to before
|
|
170
|
+
that step and runs it and the steps after it again, with `again` true: what
|
|
171
|
+
those played and showed has been. The delay then covers only the round trip
|
|
172
|
+
beyond what guessing covers, so a player's own controls answer after a step
|
|
173
|
+
or two. `run.confirmed` is how far the log has settled; hold back anything
|
|
174
|
+
that cannot be taken back (achievements, scores, the match's end) until it
|
|
175
|
+
passes the step. Somebody who only watches runs confirmed steps alone.
|
|
176
|
+
|
|
177
|
+
Lockstep suits games whose state is large or hard to copy, that are slow
|
|
178
|
+
enough not to mind the delay (turns, strategy, puzzles), or whose steps are
|
|
179
|
+
expensive. Rollback suits fast games with a small state, where a press must
|
|
180
|
+
answer at once: a bat, a fighter, a racer. It costs a `save` a step and up to
|
|
181
|
+
`steps` steps run again in a frame, and others' moves can jump when a guess
|
|
182
|
+
was wrong.
|
|
183
|
+
- **Desyncs.** Every `hashEvery` steps the devices compare fingerprints, and
|
|
184
|
+
`onDesync` says from which step they differ.
|
|
185
|
+
- **A device whose connection dropped** hears what it missed once it is back,
|
|
186
|
+
and carries on (its seat may have been handed to the game meanwhile, and is
|
|
187
|
+
asked back). One that missed more than the relay still held, a gap in its
|
|
188
|
+
numbering, cannot follow any more: `onLost`.
|
|
189
|
+
- **Thirty messages a second** at most from each device, a few bytes each.
|
|
190
|
+
- Messages of the run start with `LOCKSTEP_TAG`; a game's own messages in the
|
|
191
|
+
same match start with any other byte, and `isLockstepMessage` tells them
|
|
192
|
+
apart.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT; see `LICENSE`.
|
package/index.d.ts
ADDED
package/index.js
ADDED
package/lockstep.d.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
//#region src/lockstep.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* One way to run a match in lockstep: every device runs the same game, step by
|
|
4
|
+
* step, on every player's input for that step. Optional: a game is welcome to
|
|
5
|
+
* keep its devices in step its own way.
|
|
6
|
+
*
|
|
7
|
+
* **The match is a log.** Every device in a match — the sender included —
|
|
8
|
+
* hears every message in the relay's one order, and every decision below is
|
|
9
|
+
* made from that log alone. Two devices
|
|
10
|
+
* that heard the same log run the same steps with the same inputs, whenever
|
|
11
|
+
* the messages happened to reach each of them.
|
|
12
|
+
*
|
|
13
|
+
* **Input delay, as long as the slowest needs.** Each device's input for step t
|
|
14
|
+
* is read some steps earlier, the delay, and sent, so it has time to cross the
|
|
15
|
+
* relay before anybody needs it. A step runs once every seat's input for it has
|
|
16
|
+
* come back through the log, this device's own included; until then the game
|
|
17
|
+
* waits, and catches up after. Every device times its own messages' round trip
|
|
18
|
+
* and says how many steps it needs, and every device reads with the most any
|
|
19
|
+
* playing seat needs, within the game's bounds: the same delay for everybody,
|
|
20
|
+
* so nobody's controls answer later than another's. It grows as soon as a seat
|
|
21
|
+
* needs more, and shrinks once they have all needed less for a few seconds.
|
|
22
|
+
* Where each device reads from changes nothing in the log's order, so the
|
|
23
|
+
* delay can change without the devices agreeing on when.
|
|
24
|
+
*
|
|
25
|
+
* **Seats are handed to the game, and back.** A seat whose person left the
|
|
26
|
+
* match, or went quiet for `timeoutMs` while the others waited, is dropped:
|
|
27
|
+
* from a step every device works out from the log, its input arrives as `null`,
|
|
28
|
+
* and the game plays it (its AI, or nobody). Its device, once caught up, asks
|
|
29
|
+
* for it back, and gets it from a step that, again, the log decides. A drop
|
|
30
|
+
* takes effect from the first step the seat's input is missing for in the log
|
|
31
|
+
* before the drop, and a return after the furthest step any device could have
|
|
32
|
+
* run, so no device has ever run a step the others will run differently.
|
|
33
|
+
*
|
|
34
|
+
* **Desyncs and lost devices are reported.** Every `hashEvery` steps each device
|
|
35
|
+
* that plays sends a fingerprint of its state, and a difference is reported with
|
|
36
|
+
* the step it was found at. A device whose connection dropped for a while
|
|
37
|
+
* hears what it missed once it is back, and carries on: what it sent as the
|
|
38
|
+
* connection went, and never heard come back before a later message of its
|
|
39
|
+
* own, it sends again. One that missed more than the relay still held — a gap
|
|
40
|
+
* in the relay's numbering — cannot follow the log any longer, and says so.
|
|
41
|
+
*
|
|
42
|
+
* **Rollback, optionally.** With `rollback`, a device does not wait for the
|
|
43
|
+
* others' inputs: it runs on, up to `rollback.steps` steps ahead of the log,
|
|
44
|
+
* guessing what they press from what they pressed last, and shows that. When an
|
|
45
|
+
* input comes in that differs from the guess, it puts the game back where it was
|
|
46
|
+
* before that step (`restore`) and runs the steps again, so the picture catches
|
|
47
|
+
* up with what was pressed. The delay then has only to cover the round trip
|
|
48
|
+
* beyond what guessing covers, and a player's own controls answer after a step
|
|
49
|
+
* or two whatever the connection. Only the log confirms a step, this device's
|
|
50
|
+
* own input included, so what every device agrees on, fingerprints, drops and
|
|
51
|
+
* returns, is decided as it is without rollback; a guess past a drop or a return
|
|
52
|
+
* is simply run again. Somebody who only watches runs confirmed steps alone.
|
|
53
|
+
*
|
|
54
|
+
* Messages are small: varints, inputs as the game's own bytes, `sendEvery`
|
|
55
|
+
* steps a message. A match's other messages (a game's lobby, say) share the
|
|
56
|
+
* log: tell them apart with `isLockstepMessage`.
|
|
57
|
+
*
|
|
58
|
+
* @module lockstep
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* The first byte of every message this helper sends. A game sending messages
|
|
62
|
+
* of its own in the same match starts them with any other byte.
|
|
63
|
+
*
|
|
64
|
+
* @group Lockstep
|
|
65
|
+
*/
|
|
66
|
+
declare const LOCKSTEP_TAG = 245;
|
|
67
|
+
/**
|
|
68
|
+
* What the helper needs of a match: `@hopple/sdk-preview-network`'s `Match` has it all.
|
|
69
|
+
*
|
|
70
|
+
* @group Lockstep
|
|
71
|
+
*/
|
|
72
|
+
type LockstepMatch = {
|
|
73
|
+
/** Who plays now, for noticing a seat's person has left. */
|
|
74
|
+
readonly players: readonly {
|
|
75
|
+
readonly id: string;
|
|
76
|
+
}[];
|
|
77
|
+
send(data: Uint8Array): void;
|
|
78
|
+
onMessage(listener: (data: Uint8Array, from: {
|
|
79
|
+
readonly id: string;
|
|
80
|
+
}, seq: number) => void): () => void;
|
|
81
|
+
on(listener: () => void): () => void;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* What a game gives to run ahead of the log: a way to go back, and how far.
|
|
85
|
+
*
|
|
86
|
+
* @group Lockstep
|
|
87
|
+
*/
|
|
88
|
+
type Rollback<S> = {
|
|
89
|
+
/**
|
|
90
|
+
* The most steps the game may run ahead of what the log has confirmed, on
|
|
91
|
+
* guesses: the same on every device. Round trips beyond it, less a couple of
|
|
92
|
+
* steps kept for jitter, are covered by the delay. Every step ahead may be run
|
|
93
|
+
* again in a frame: it is a budget of the game's time as much as of the
|
|
94
|
+
* network's.
|
|
95
|
+
*/
|
|
96
|
+
steps: number;
|
|
97
|
+
/** The game's whole state as it is now, to come back to: anything `restore` takes back. */
|
|
98
|
+
save(): S;
|
|
99
|
+
/** Put the game back as `save` found it. */
|
|
100
|
+
restore(saved: S): void;
|
|
101
|
+
/**
|
|
102
|
+
* What a seat presses at a step its input has not come in for, from the last
|
|
103
|
+
* input it is known to have pressed. That input by default: a game whose
|
|
104
|
+
* inputs say what went down this step, rather than what is held, takes that
|
|
105
|
+
* out.
|
|
106
|
+
*/
|
|
107
|
+
predict?(last: Uint8Array, seat: number): Uint8Array;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* @group Lockstep
|
|
111
|
+
*/
|
|
112
|
+
type LockstepOptions<S = unknown> = {
|
|
113
|
+
match: LockstepMatch;
|
|
114
|
+
/**
|
|
115
|
+
* The relay's `seq` of the message that started this run, as every device
|
|
116
|
+
* heard it. Make the run in the listener that heard it: it hears only what
|
|
117
|
+
* follows.
|
|
118
|
+
*/
|
|
119
|
+
seq: number;
|
|
120
|
+
/**
|
|
121
|
+
* A number for this run, the same on every device and different from the
|
|
122
|
+
* match's other runs (a rematch is another run), so their messages are told
|
|
123
|
+
* apart.
|
|
124
|
+
*/
|
|
125
|
+
run: number;
|
|
126
|
+
/** Who plays, by person id, in seat order: the same list on every device. */
|
|
127
|
+
seats: readonly string[];
|
|
128
|
+
/** This device's person id. A device whose person has no seat only watches. */
|
|
129
|
+
me: string;
|
|
130
|
+
/**
|
|
131
|
+
* Steps between reading an input and running it, as the round trips through
|
|
132
|
+
* the relay need, less what `rollback` guesses: bounds for it, the same on
|
|
133
|
+
* every device. `min` equal to `max` holds it fixed.
|
|
134
|
+
*/
|
|
135
|
+
delay?: {
|
|
136
|
+
/** The least. 2 by default. */
|
|
137
|
+
min?: number;
|
|
138
|
+
/** The most. 20 by default: 333 ms at 60 steps a second. Round trips longer than it, and what guessing covers, slow the run down. */
|
|
139
|
+
max?: number;
|
|
140
|
+
/** The delay the first steps need, before any round trip is timed (guessing takes its share of it). 6 by default. */
|
|
141
|
+
start?: number;
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* How many players play on each seat's device, in seat order: `start.locals`
|
|
145
|
+
* from the session, the same on every device. With it, `sample` gives one
|
|
146
|
+
* input for each player on this device, `step` is given one for each player
|
|
147
|
+
* of the match (seat by seat, each seat's players in order) and `predict`'s
|
|
148
|
+
* `seat` is that player's place among them; a device's players still travel
|
|
149
|
+
* in one message. Left out, each seat is one input.
|
|
150
|
+
*/
|
|
151
|
+
locals?: readonly number[];
|
|
152
|
+
/**
|
|
153
|
+
* This device's input for step `tick`: a few bytes, the game's own encoding,
|
|
154
|
+
* or with `locals` one for each of its players.
|
|
155
|
+
* Asked for each step in order: usually once a step, but several times in a
|
|
156
|
+
* frame while the delay grows, and not for a step now and then while it
|
|
157
|
+
* shrinks, so a press goes to the one call after it, whenever that comes.
|
|
158
|
+
* An empty array is what every seat presses before its first input arrives.
|
|
159
|
+
*/
|
|
160
|
+
sample(tick: number): Uint8Array | readonly Uint8Array[];
|
|
161
|
+
/**
|
|
162
|
+
* Run step `tick`: each seat's input, in seat order, or `null` for a seat
|
|
163
|
+
* handed to the game; with `locals`, each player's. The same on every
|
|
164
|
+
* device once confirmed.
|
|
165
|
+
*
|
|
166
|
+
* With `rollback`, a step may be run on guesses and then `again`, after the
|
|
167
|
+
* game was put back to before it: what it played or showed the first time was
|
|
168
|
+
* played and shown, and anything that cannot be taken back (an achievement, a
|
|
169
|
+
* score, the end of the match) waits until `confirmed` has passed the step.
|
|
170
|
+
*/
|
|
171
|
+
step(inputs: readonly (Uint8Array | null)[], tick: number, again: boolean): void;
|
|
172
|
+
/** A fingerprint of the game's state after a step, to compare between devices: `hash` from this package. */
|
|
173
|
+
hash?(): string;
|
|
174
|
+
/** Steps between fingerprints. 60 by default. */
|
|
175
|
+
hashEvery?: number;
|
|
176
|
+
/** Two devices' states differ after step `tick`. Said once. */
|
|
177
|
+
onDesync?(tick: number): void;
|
|
178
|
+
/** This device missed some of the match's messages and cannot follow it any longer. */
|
|
179
|
+
onLost?(): void;
|
|
180
|
+
/** How long a step is. A sixtieth of a second by default. */
|
|
181
|
+
tickMs?: number;
|
|
182
|
+
/** Steps in a message, at most. 2 by default: thirty messages a second. */
|
|
183
|
+
sendEvery?: number;
|
|
184
|
+
/** How long a seat may say nothing, while the others wait for it, before it is handed to the game. 3 s by default; `Infinity` waits for ever. */
|
|
185
|
+
timeoutMs?: number;
|
|
186
|
+
/** The most steps one `advance` runs to catch up. 30 by default. */
|
|
187
|
+
catchUp?: number;
|
|
188
|
+
/** The wall clock, in milliseconds: for timeouts and pacing, never for the game. */
|
|
189
|
+
now?: () => number;
|
|
190
|
+
/** Run ahead of the log on guesses, and back when they were wrong. Without it, every step waits for every input. */
|
|
191
|
+
rollback?: Rollback<S>;
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* A lockstep run in progress.
|
|
195
|
+
*
|
|
196
|
+
* @group Lockstep
|
|
197
|
+
*/
|
|
198
|
+
type Lockstep = {
|
|
199
|
+
/**
|
|
200
|
+
* Let `ms` of real time pass: run every step that is due and ready, and
|
|
201
|
+
* catch up when behind. Call it once a frame. Returns how many steps ran.
|
|
202
|
+
*/
|
|
203
|
+
advance(ms: number): number;
|
|
204
|
+
/** Steps the game has run: the next step to run. Ahead of `confirmed` by the steps it guessed. */
|
|
205
|
+
readonly tick: number;
|
|
206
|
+
/** Steps the log has every input for: the same on every device, and never run again. `tick` without rollback. */
|
|
207
|
+
readonly confirmed: number;
|
|
208
|
+
/** The people the next step waits for, by id: empty when it is not waiting. */
|
|
209
|
+
readonly waiting: readonly string[];
|
|
210
|
+
/** `running`, `lost` once this device missed messages, or `stopped`. */
|
|
211
|
+
readonly status: 'running' | 'lost' | 'stopped';
|
|
212
|
+
/** Whether a seat is played by its person at the next step, rather than handed to the game. */
|
|
213
|
+
playing(seat: number): boolean;
|
|
214
|
+
/** The last round trip of this device's messages through the relay, in ms, or `null` before one. */
|
|
215
|
+
readonly rtt: number | null;
|
|
216
|
+
/** The delay inputs are read with now, in steps: at `max`, the connection is slower than the game allows. */
|
|
217
|
+
readonly delay: number;
|
|
218
|
+
/** Stop listening and running, sending what input it has read first. */
|
|
219
|
+
stop(): void;
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Whether a match message is one of a lockstep run's, for a game's own
|
|
223
|
+
* listener to skip.
|
|
224
|
+
*
|
|
225
|
+
* @group Lockstep
|
|
226
|
+
*/
|
|
227
|
+
declare function isLockstepMessage(data: Uint8Array): boolean;
|
|
228
|
+
/**
|
|
229
|
+
* Run a match in lockstep. See the module for how.
|
|
230
|
+
*
|
|
231
|
+
* ```ts
|
|
232
|
+
* const run = lockstep({
|
|
233
|
+
* match, seq, run: 1, seats, me: match.me!.id,
|
|
234
|
+
* sample: () => Uint8Array.of(pad.bits()),
|
|
235
|
+
* step: (inputs) => world.step(inputs),
|
|
236
|
+
* hash: () => hash(world.summary()),
|
|
237
|
+
* });
|
|
238
|
+
* sdk.loop.tick((dt) => { run.advance(dt * 1000); draw(); });
|
|
239
|
+
* ```
|
|
240
|
+
*
|
|
241
|
+
* With rollback, the game also says how to go back:
|
|
242
|
+
*
|
|
243
|
+
* ```ts
|
|
244
|
+
* rollback: { steps: 8, save: () => world.save(), restore: (saved) => world.restore(saved) },
|
|
245
|
+
* step: (inputs, tick, again) => world.step(inputs, { quiet: again }),
|
|
246
|
+
* ```
|
|
247
|
+
*
|
|
248
|
+
* @group Lockstep
|
|
249
|
+
*/
|
|
250
|
+
declare function lockstep<S>(options: LockstepOptions<S>): Lockstep;
|
|
251
|
+
//#endregion
|
|
252
|
+
export { LOCKSTEP_TAG, Lockstep, LockstepMatch, LockstepOptions, Rollback, isLockstepMessage, lockstep };
|