twinwire 0.0.0-stage → 1.0.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/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +128 -2
- package/dist/browser/twinwire.js +2075 -0
- package/dist/browser/twinwire.js.map +7 -0
- package/dist/browser/twinwire.min.js +1 -0
- package/dist/browser/twinwire.mjs +2052 -0
- package/dist/browser/twinwire.mjs.map +7 -0
- package/dist/browser.d.ts +5 -0
- package/dist/browser.js +6 -0
- package/dist/browser.js.map +1 -0
- package/dist/client/client.d.ts +128 -0
- package/dist/client/client.js +470 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/lobby.d.ts +40 -0
- package/dist/client/lobby.js +122 -0
- package/dist/client/lobby.js.map +1 -0
- package/dist/client/rtc.d.ts +17 -0
- package/dist/client/rtc.js +102 -0
- package/dist/client/rtc.js.map +1 -0
- package/dist/core/budget.d.ts +27 -0
- package/dist/core/budget.js +39 -0
- package/dist/core/budget.js.map +1 -0
- package/dist/core/endpoint.d.ts +144 -0
- package/dist/core/endpoint.js +501 -0
- package/dist/core/endpoint.js.map +1 -0
- package/dist/core/lobby.d.ts +59 -0
- package/dist/core/lobby.js +53 -0
- package/dist/core/lobby.js.map +1 -0
- package/dist/core/protocol.d.ts +52 -0
- package/dist/core/protocol.js +100 -0
- package/dist/core/protocol.js.map +1 -0
- package/dist/core/reliable.d.ts +52 -0
- package/dist/core/reliable.js +116 -0
- package/dist/core/reliable.js.map +1 -0
- package/dist/core/rtc.d.ts +28 -0
- package/dist/core/rtc.js +21 -0
- package/dist/core/rtc.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/netcode/clock.d.ts +15 -0
- package/dist/netcode/clock.js +37 -0
- package/dist/netcode/clock.js.map +1 -0
- package/dist/netcode/codec.d.ts +10 -0
- package/dist/netcode/codec.js +95 -0
- package/dist/netcode/codec.js.map +1 -0
- package/dist/netcode/index.d.ts +9 -0
- package/dist/netcode/index.js +10 -0
- package/dist/netcode/index.js.map +1 -0
- package/dist/netcode/inputs.d.ts +7 -0
- package/dist/netcode/inputs.js +43 -0
- package/dist/netcode/inputs.js.map +1 -0
- package/dist/netcode/interpolation.d.ts +44 -0
- package/dist/netcode/interpolation.js +101 -0
- package/dist/netcode/interpolation.js.map +1 -0
- package/dist/netcode/prediction.d.ts +40 -0
- package/dist/netcode/prediction.js +97 -0
- package/dist/netcode/prediction.js.map +1 -0
- package/dist/netcode/replication.d.ts +36 -0
- package/dist/netcode/replication.js +90 -0
- package/dist/netcode/replication.js.map +1 -0
- package/dist/netcode/snapshots.d.ts +29 -0
- package/dist/netcode/snapshots.js +78 -0
- package/dist/netcode/snapshots.js.map +1 -0
- package/dist/netcode/spatial.d.ts +16 -0
- package/dist/netcode/spatial.js +63 -0
- package/dist/netcode/spatial.js.map +1 -0
- package/dist/netcode/timing.d.ts +37 -0
- package/dist/netcode/timing.js +41 -0
- package/dist/netcode/timing.js.map +1 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/lobby.d.ts +134 -0
- package/dist/server/lobby.js +462 -0
- package/dist/server/lobby.js.map +1 -0
- package/dist/server/rtc.d.ts +38 -0
- package/dist/server/rtc.js +102 -0
- package/dist/server/rtc.js.map +1 -0
- package/dist/server/server.d.ts +145 -0
- package/dist/server/server.js +457 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/turn.d.ts +22 -0
- package/dist/server/turn.js +52 -0
- package/dist/server/turn.js.map +1 -0
- package/docs/ADOPTING.md +64 -0
- package/docs/API.md +181 -0
- package/docs/ARCHITECTURE.md +101 -0
- package/docs/OPERATIONS.md +93 -0
- package/docs/RELEASE-CHECKLIST.md +25 -0
- package/package.json +100 -4
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { validIceServers } from '../core/rtc.js';
|
|
3
|
+
/**
|
|
4
|
+
* Short-lived TURN credentials for coturn's `use-auth-secret` (the "TURN REST API" scheme): the username is
|
|
5
|
+
* `<expiry unix seconds>:<user>` and the password is base64(HMAC-SHA1(secret, username)). Never ship the secret.
|
|
6
|
+
*/
|
|
7
|
+
export function turnCredentials(o) {
|
|
8
|
+
if (!o.secret)
|
|
9
|
+
throw new Error('TURN secret required');
|
|
10
|
+
const ttl = o.ttlSeconds ?? 3600;
|
|
11
|
+
if (!Number.isInteger(ttl) || ttl < 60 || ttl > 7 * 86400)
|
|
12
|
+
throw new RangeError('Invalid TURN ttl');
|
|
13
|
+
const user = (o.user ?? 'player').replace(/[^\w.-]/g, '').slice(0, 64) || 'player';
|
|
14
|
+
const username = `${Math.floor((o.now ?? Date.now()) / 1000) + ttl}:${user}`;
|
|
15
|
+
const server = { urls: o.urls, username, credential: createHmac('sha1', o.secret).update(username).digest('base64') };
|
|
16
|
+
if (!validIceServers([server]))
|
|
17
|
+
throw new Error('Invalid TURN URL');
|
|
18
|
+
return server;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* ICE servers from Cloudflare Realtime TURN (a managed relay with anycast TURN over UDP, TCP and TLS:443).
|
|
22
|
+
* Credentials are fetched with your key and cached until shortly before they expire.
|
|
23
|
+
*/
|
|
24
|
+
export function cloudflareTurn(o) {
|
|
25
|
+
const ttl = o.ttlSeconds ?? 86400, get = o.fetch ?? fetch;
|
|
26
|
+
let cached = null, until = 0, inflight = null;
|
|
27
|
+
return async () => {
|
|
28
|
+
if (cached && Date.now() < until)
|
|
29
|
+
return cached;
|
|
30
|
+
inflight ??= (async () => {
|
|
31
|
+
try {
|
|
32
|
+
const res = await get(`https://rtc.live.cloudflare.com/v1/turn/keys/${encodeURIComponent(o.keyId)}/credentials/generate-ice-servers`, {
|
|
33
|
+
method: 'POST', headers: { Authorization: `Bearer ${o.apiToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ ttl }),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
throw new Error(`Cloudflare TURN ${res.status}`);
|
|
37
|
+
const body = await res.json();
|
|
38
|
+
const list = Array.isArray(body.iceServers) ? body.iceServers : body.iceServers ? [body.iceServers] : [];
|
|
39
|
+
if (!validIceServers(list))
|
|
40
|
+
throw new Error('Cloudflare TURN returned invalid ICE servers');
|
|
41
|
+
cached = list;
|
|
42
|
+
until = Date.now() + ttl * 900;
|
|
43
|
+
return list;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
inflight = null;
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
return inflight;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=turn.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turn.js","sourceRoot":"","sources":["../../src/server/turn.ts"],"names":[],"mappings":"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"}
|
package/docs/ADOPTING.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Adopting Twinwire in an existing game
|
|
2
|
+
|
|
3
|
+
Most games that come to Twinwire already have netcode of some kind: a WebSocket, maybe geckos.io or a hand-rolled
|
|
4
|
+
WebRTC path, a reliability layer, rooms. Swap the transport first and keep the game's own model of the world; then
|
|
5
|
+
move pieces onto Twinwire's helpers only where they're better than what you have.
|
|
6
|
+
|
|
7
|
+
## A staged path
|
|
8
|
+
|
|
9
|
+
1. **Keep your world, swap the wire.** Leave the simulation, the snapshot format and the rendering alone. Replace the
|
|
10
|
+
connection code with a `GameClient` on the page and `createGameServer` on the server, behind a flag if you can.
|
|
11
|
+
2. **Sort your traffic into two kinds.**
|
|
12
|
+
- Things that must arrive, in order (hits, doors, purchases, chat, map changes): `sendEvent`. Remove any old
|
|
13
|
+
reliability layer on that path instead of running two acknowledgement schemes.
|
|
14
|
+
- Things where only the newest matters (positions, snapshots, inputs): `sendState` on a stable stream number.
|
|
15
|
+
3. **Stamp what depends on time.** Events and states travel separately, so nothing orders one against the other.
|
|
16
|
+
Put the server tick (or a map/world generation number) in each event, and in each snapshot; a snapshot that belongs
|
|
17
|
+
to a change the client hasn't heard about yet waits for it, and one from before a change is dropped.
|
|
18
|
+
4. **Rooms on the lobby.** Codes, quick play, seats and build matching come from `Lobby`/`LobbyClient`. Put the build
|
|
19
|
+
id in room `meta` (`match` for quick play, `canJoin` for codes), and keep your world in `room.data`. Seats survive
|
|
20
|
+
reconnects through the session itself; `onReconnect` is where your world re-sends whatever a fresh client needs.
|
|
21
|
+
5. **Then the helpers, if they beat yours.** Deltas against acknowledged snapshots (`ack: true`, `SnapshotHistory`),
|
|
22
|
+
interpolation (`Timeline`, `AdaptivePlayback`), prediction (`Predictor`, `InputInbox`, `encodeInputs`).
|
|
23
|
+
|
|
24
|
+
## Sizes
|
|
25
|
+
|
|
26
|
+
A state can be up to 64 KiB (`maxStateBytes`, up to 256 KiB), but a per-tick packet that fits 1078 bytes fits one
|
|
27
|
+
datagram on any path. Bigger states are fragmented by SCTP on the datagram path, and anything over `datagramStateBytes`
|
|
28
|
+
(8 KiB) takes the WebSocket instead, where it can't be lost piecewise: fine for a whole snapshot after a map change, not
|
|
29
|
+
for every tick. Events are JSON up to 64 KiB each.
|
|
30
|
+
|
|
31
|
+
## By kind of game
|
|
32
|
+
|
|
33
|
+
- **Action games (shooters, platformers, fighting):** keep your own movement, collision and hit rules on the server.
|
|
34
|
+
Send inputs redundantly (`encodeInputs` with the last few), apply them with `InputInbox`, and reconcile on the client.
|
|
35
|
+
A shot that matters is an event that names the input/tick it came from, so the server can check it against its own
|
|
36
|
+
history.
|
|
37
|
+
- **Social and co-op worlds:** interest-filter what each player gets (`SpatialGrid` for "who is near"), send compact
|
|
38
|
+
records at a modest rate (10-20 Hz), and acknowledged records (`ReplicaSender`) for things that change rarely.
|
|
39
|
+
- **Strategy:** commands are events, applied by the server; snapshots at the simulation's own rate.
|
|
40
|
+
- **Turn-based:** WebSocket only (`rtc: false`): there's nothing for UDP to speed up.
|
|
41
|
+
|
|
42
|
+
## A real port: Nocturne Keep
|
|
43
|
+
|
|
44
|
+
[Nocturne Keep](https://nocturnekeep.com) is a pixel-art platformer whose rooms run the game's own code headless on
|
|
45
|
+
the server (a Node `vm` per room, each in its own worker thread), with each phone simulating only its own player.
|
|
46
|
+
Before Twinwire it used geckos.io for WebRTC, a separate WebSocket fallback, and a Quake-3-style reliable layer that
|
|
47
|
+
repeated unacknowledged messages in every packet.
|
|
48
|
+
|
|
49
|
+
- The page's connection code (about 120 lines) became a `GameClient` and a `LobbyClient`; the server's hello, codes,
|
|
50
|
+
quick play and seat tokens became `Lobby` callbacks.
|
|
51
|
+
- The reliable layer went away on both sides. Its messages are events, each stamped `[serverFrame, message]`.
|
|
52
|
+
- Its JSON snapshots, already deltas against the last snapshot the phone acknowledged, stayed exactly as they were:
|
|
53
|
+
they became stream 0 states, with the map-change number added so a snapshot that beats its map event waits for it.
|
|
54
|
+
- A reload keeps the seat: the page saves `checkpoint()` in sessionStorage and resumes it when the player rejoins.
|
|
55
|
+
- All of its multi-browser suites (co-op story, every map, every boss, maker rooms, races, the roguelike together) pass
|
|
56
|
+
over WebRTC, and its live check plays the public site in Chromium and WebKit.
|
|
57
|
+
|
|
58
|
+
## Checks worth copying
|
|
59
|
+
|
|
60
|
+
- Two browsers agree on the same world at the same tick (same enemies, same positions within a tolerance).
|
|
61
|
+
- A lost stop or state change heals by itself (the next state repairs it).
|
|
62
|
+
- Both directions survive UDP failing while the WebSocket stays up (`IMPAIR`, `RTC=0` in the browser test).
|
|
63
|
+
- A phone that drops (tunnel, app switch) comes back to its seat; a reload with a checkpoint too.
|
|
64
|
+
- Measure before and after with the same inputs: corrections, input-to-visible delay, missed snapshots, bytes.
|
package/docs/API.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# API and integration
|
|
2
|
+
|
|
3
|
+
All modules are ESM with TypeScript declarations.
|
|
4
|
+
|
|
5
|
+
| Import | For |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `twinwire/client` | Browser: `GameClient`, `LobbyClient`, `BrowserRtcPeer` |
|
|
8
|
+
| `twinwire/server` | Node: `createGameServer`, `GameServer`, `GameSession`, `Lobby`, `Room`, `createNativeRtcFactory`, `turnCredentials`, `cloudflareTurn` |
|
|
9
|
+
| `twinwire/netcode` | Both: clock, interpolation, prediction, timing, snapshots, replication, codecs, spatial |
|
|
10
|
+
| `twinwire` | Both: the wire protocol and `Endpoint` |
|
|
11
|
+
| `twinwire/browser` | `dist/browser/twinwire.js` (also `.min.js`; ESM `.mjs` for `import`): client + lobby + netcode as `window.Twinwire` |
|
|
12
|
+
|
|
13
|
+
Browser code never imports the server entry. Node needs `ws`; WebRTC on the server needs `node-datachannel@0.33.x`.
|
|
14
|
+
|
|
15
|
+
## GameClient
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const client = new GameClient({
|
|
19
|
+
url: 'wss://game.example.com/connect',
|
|
20
|
+
room: 'mygame', // a namespace the server sees; rooms with codes come from the lobby
|
|
21
|
+
auth: { name: 'WREN', build }, // any JSON, checked by the server's authenticate hook; also session.hello
|
|
22
|
+
rtc: {}, // default. false = WebSocket only. { iceServers } overrides the server's list
|
|
23
|
+
endpoint: { bytesPerSecond: 32 * 1024 },
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Member | Contract |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `connect()` | Starts join/resume and pumping. |
|
|
30
|
+
| `whenOnline()` | Promise: resolves when online (at once if already), rejects if the session is lost or closed first. |
|
|
31
|
+
| `status`, `onStatus(s)` | `idle`, `connecting`, `online`, `paused`, `closed`, `lost`. |
|
|
32
|
+
| `transport`, `onTransport(t)` | `rtc` (datagrams proven both ways), `ws`, `none`. |
|
|
33
|
+
| `onSession({id,resumed})` | Identity is available. `resumed: false` means a brand-new session: reset game state. |
|
|
34
|
+
| `onSessionLost({pendingEvents,reason})` | Expiry, kick, takeover by another tab, authentication or protocol rejection. Retrying stops; call `newSession()`. |
|
|
35
|
+
| `sendEvent(json)` | Ordered, acknowledged, deduplicated; replayed after reconnects. Returns its seq; throws when the outbox is full (512 events / 1 MiB) or the event is over 64 KiB. |
|
|
36
|
+
| `onEvent(data, seq)` | Each event exactly once, in order, whichever path brought it first. |
|
|
37
|
+
| `sendState(stream, bytes, {ttlMs, priority, ack}?)` | Latest wins per stream (0 to 65535). Returns the seq, or 0 before the session is online and the clock synced. Up to `maxStateBytes` (64 KiB). |
|
|
38
|
+
| `onState({stream,seq,time,data,via})` | Newer than anything seen on that stream this connection. `time` is the sender's server-clock estimate: never trust it for authority. |
|
|
39
|
+
| `onStateAck(stream, seq)` | For states sent with `ack: true`: the newest the peer has seen. |
|
|
40
|
+
| `iceServers` | What the server suggested in its welcome. |
|
|
41
|
+
| `clock` | `synced`, `rttMs`, `uncertaintyMs`, `serverTime(performance.now())`. |
|
|
42
|
+
| `pause()` / `resume()` | Close and reopen the connection, keeping the session. |
|
|
43
|
+
| `reconnectNow()` | Skip the backoff (the network came back). |
|
|
44
|
+
| `newSession()` | Drop the old session and its unsent actions; get a new identity. |
|
|
45
|
+
| `close()` | Leave for good (the server ends the session at once). |
|
|
46
|
+
| `checkpoint()` / `options.checkpoint` | Carry a session across page loads. Holds a bearer token: never put it in a URL. |
|
|
47
|
+
| `bindPageLifecycle({hiddenPauseMs, wakeCheckMs})` | Pause on `pagehide` or after 20 s hidden; when the page is back, resume, or replace a socket that stays silent for 1.5 s; reconnect at once on `online`. |
|
|
48
|
+
| `intercept(fn)`, `on('session' \| 'lost', fn)` | Extra listeners for helpers such as `LobbyClient`. |
|
|
49
|
+
| `endpoint.stats` | Traffic, stale/expired/coalesced states, `eventsViaDatagram`, `datagramRttMs`, and more. |
|
|
50
|
+
| `options.impair` | `{lagMs, jitterMs, loss, duplicate}` on this client's sends, and asked of the server (which honours it only with `allowImpairment`). `lagMs` is one way. |
|
|
51
|
+
|
|
52
|
+
Callbacks should be short. Don't throw from `onEvent` after side effects: a throw means "not accepted" and the event
|
|
53
|
+
comes again.
|
|
54
|
+
|
|
55
|
+
## LobbyClient
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const lobby = new LobbyClient(client);
|
|
59
|
+
const room = await lobby.create({ mode: 'night', visibility: 'private', maxMembers: 4, meta: { build } });
|
|
60
|
+
await lobby.join('KQXT'); // case-insensitive
|
|
61
|
+
await lobby.quickPlay({ mode: 'night', match: { build }, create: { maxMembers: 4 } });
|
|
62
|
+
await lobby.setReady(true); await lobby.setName('WREN'); await lobby.setMeta({ skin: 2 });
|
|
63
|
+
await lobby.kick(2); await lobby.leave();
|
|
64
|
+
lobby.onRoom = room => {}; // RoomView | null on every change
|
|
65
|
+
lobby.onLeft = reason => {}; // kicked, replaced, closed, expired, new session
|
|
66
|
+
lobby.room; lobby.me; lobby.isHost; lobby.countdownMs();
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`RoomView`: `{ code, mode, visibility, phase, maxMembers, meta, you, members: [{seat, name, ready, connected, host, meta}], countdownEndsAt }`.
|
|
70
|
+
Requests reject with the server's message (`no room called ZZZZ`, `that room is full`, your `canJoin` text). Lobby
|
|
71
|
+
traffic uses events with a `$lobby` key; `onEvent` never sees them.
|
|
72
|
+
|
|
73
|
+
## Server
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const server = await createGameServer({
|
|
77
|
+
host: '127.0.0.1', port: 8787, origins: ['https://game.example.com'],
|
|
78
|
+
rtc: await createNativeRtcFactory({ port: 8788, maxMessageSize: 64 * 1024 + 22 }),
|
|
79
|
+
iceServers: session => [turnCredentials({ secret: process.env.TURN_SECRET, urls: 'turn:turn.example.com:3478', user: session.id })],
|
|
80
|
+
trustProxy: true, resumeGraceMs: 90_000,
|
|
81
|
+
authenticate: ({ auth, room, resuming, ip }) => validBuild(auth) ? 'anon' : null,
|
|
82
|
+
endpoint: { bytesPerSecond: 48 * 1024 },
|
|
83
|
+
});
|
|
84
|
+
await server.listen();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Option | Default | |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `origins` | required | Exact page origins. `allowMissingOrigin` lets non-browser clients in. |
|
|
90
|
+
| `rtc` | none | `createNativeRtcFactory({port, publicIp?, bindAddress?, iceServers?, maxMessageSize?})`. Without it: WebSocket only. |
|
|
91
|
+
| `iceServers` | `[]` | Array or `(session) => array \| Promise`. Sent in each welcome. |
|
|
92
|
+
| `trustProxy` | false | Read the client address from the last `X-Forwarded-For` hop when the peer is a local proxy. |
|
|
93
|
+
| `resumeGraceMs` | 90 000 | How long a dropped session (and its lobby seat) waits. |
|
|
94
|
+
| `maxSessions` / `maxConnections` / `maxPerIp` | 256 / 512 / 32 | |
|
|
95
|
+
| `rateLimit` | 300 msg/s, 256 KiB/s | Per connection, both transports together. |
|
|
96
|
+
| `allowImpairment` | false | Honour a client's `impair`. Development only. |
|
|
97
|
+
| `endpoint` | | Endpoint options for every session (below). |
|
|
98
|
+
|
|
99
|
+
| Member | |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `sessions` | Map of id to `GameSession`: `id`, `room`, `subject`, `ip`, `hello` (latest `auth`), `connected`, `transport`, `data` (yours), `sendEvent`, `sendState`, `onEvent`, `onState`, `onStateAck`, `endpoint`. |
|
|
102
|
+
| `on('session' \| 'disconnect' \| 'expire', fn)` | Listeners beside `onSession` / `onDisconnect` / `onExpire`. |
|
|
103
|
+
| `endSession(session, reason)` | Kick: the client gets `onSessionLost(reason)`. |
|
|
104
|
+
| `broadcast(room, json)` | To every session that said hello with that `room`. |
|
|
105
|
+
| `metrics()` | Sessions, connections, rtc/ws counts, bytes, pending events, protocol errors. For `/health`. |
|
|
106
|
+
| `addressOf(req)` | The address the limits use. |
|
|
107
|
+
|
|
108
|
+
## Lobby
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const lobby = new Lobby(server, {
|
|
112
|
+
maxMembers: 4, codeLength: 4, emptyCloseMs: 30_000,
|
|
113
|
+
joinablePhases: ['lobby', 'playing'], // join by code mid-game (drop-in)
|
|
114
|
+
quickPlayPhases: ['lobby'], // quick play only into rooms still waiting
|
|
115
|
+
ready: { countdownMs: 3000, minMembers: 1 }, // or false
|
|
116
|
+
evictDisconnected: true, allowHostKick: true, maxRoomsPerIpPerHour: 30,
|
|
117
|
+
clientMetaKeys: ['skin'], // meta keys members may set themselves (default: any)
|
|
118
|
+
canCreate: (session, req) => null, // or an error message
|
|
119
|
+
canJoin: (room, session, req) => room.meta.build !== session.hello?.build ? 'A NEW VERSION IS OUT' : null,
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The lobby owns each session's `onEvent`/`onState`; use its callbacks: `onCreate(room, creator)`, `onJoin(room, member)`,
|
|
124
|
+
`onLeave(room, member, reason)`, `onDisconnect`, `onReconnect`, `onStart(room)`, `onClose(room, reason)`,
|
|
125
|
+
`onEvent(room, member, data, session)`, `onState(room, member, state, session)`. `room`/`member` are null for a
|
|
126
|
+
session that isn't in a room.
|
|
127
|
+
|
|
128
|
+
`Room`: `code`, `mode`, `visibility`, `phase`, `meta`, `data` (yours), `members` (by session id), `host`,
|
|
129
|
+
`memberAt(seat)`, `connectedCount`, `broadcast(json, except?)`, `send(member, json)`, `setPhase(p)` (`'lobby'` clears
|
|
130
|
+
ready), `setMeta(patch)`, `setVisibility(v)`, `start()`, `close(reason)`. `Member`: `session`, `seat` (stable),
|
|
131
|
+
`name`, `ready`, `connected`, `meta`, `data` (yours). `lobby.createRoom(...)`, `roomOf(session)`, `memberOf(session)`.
|
|
132
|
+
|
|
133
|
+
A member keeps its seat while disconnected, until its session expires (`resumeGraceMs`). Seats are the lowest free
|
|
134
|
+
number. The host is the earliest-joined connected member.
|
|
135
|
+
|
|
136
|
+
## Endpoint options
|
|
137
|
+
|
|
138
|
+
`bytesPerSecond` (32 KiB) and `burstBytes`: scheduling budget for states and events (a message bigger than the burst
|
|
139
|
+
goes once the bucket is full). `maxBufferedBytes` (64 KiB): socket backpressure. `maxStateBytes` (64 KiB, at most
|
|
140
|
+
256 KiB). `datagramStateBytes` (8 KiB): bigger states use the WebSocket. `datagramEvents` (true). `stateAckIntervalMs`
|
|
141
|
+
(50). `probeIntervalMs`/`probeTimeoutMs` (250/1500). `maxStreams` (1024). `events: {maxEvents, maxBytes,
|
|
142
|
+
maxEventBytes}`. `impair`. Both ends should agree on `maxStateBytes`.
|
|
143
|
+
|
|
144
|
+
## Netcode
|
|
145
|
+
|
|
146
|
+
**Clock and timing.** `NetworkClock` (the client has one). `FixedStepper(hz, maxCatchUp)` runs a fixed simulation
|
|
147
|
+
step and reports dropped time instead of spiralling. `PROFILES` has starting rates for social, action, strategy and
|
|
148
|
+
turn-based games.
|
|
149
|
+
|
|
150
|
+
**Snapshot interpolation.** One `Timeline` per entity, `AdaptivePlayback(intervalMs)` to choose a render time that
|
|
151
|
+
covers the jitter you actually see: `observe(clock.serverTime(now), state.time)` on arrival, `advance(serverNow, dt)`
|
|
152
|
+
per frame, then `timeline.sample(renderTime, lerp, extrapolate?, maxMs)`. Mark teleports as `discontinuity`.
|
|
153
|
+
|
|
154
|
+
**Prediction.** `Predictor(initial, step, dt)`: `push(input)` predicts, `recent(n)` for redundant sends
|
|
155
|
+
(`encodeInputs`), `reconcile({ack, tick, state})` replays what the server hasn't applied. The server feeds
|
|
156
|
+
`InputInbox` and acks the last input it applied. `rewindTime` clamps lag-compensation requests.
|
|
157
|
+
|
|
158
|
+
**Delta snapshots.**
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const history = new SnapshotHistory<Entities>(); // one per player
|
|
162
|
+
session.onStateAck = (stream, seq) => history.acknowledge(seq);
|
|
163
|
+
const base = history.baseline; // null: send everything
|
|
164
|
+
const seq = session.sendState(0, encode({ b: base?.seq ?? 0, d: diffEntities(base?.value ?? null, world) }), { ack: true });
|
|
165
|
+
history.record(seq, world);
|
|
166
|
+
// client: view = applyEntityDelta(states.get(msg.b) ?? null, msg.d); states.set(s.seq, view)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Reset both sides when the connection epoch changes (`onSession`). A null field and a missing field are the same.
|
|
170
|
+
|
|
171
|
+
**Acknowledged records.** `ReplicaSender`/`ReplicaReceiver` resend whole small entity records until acknowledged,
|
|
172
|
+
with generations for reused ids.
|
|
173
|
+
|
|
174
|
+
**Codecs and interest.** `defineSchema({x: {type: 'i16', scale: 10}, ...})` for fixed binary layouts.
|
|
175
|
+
`SpatialGrid` for "who is near whom"; your rules still decide who may see what.
|
|
176
|
+
|
|
177
|
+
## Limits worth knowing
|
|
178
|
+
|
|
179
|
+
No order holds between events and states: stamp events with the server tick or a map generation when a snapshot
|
|
180
|
+
depends on them. Events are acknowledged when your callback returns, not when a database commits: give purchases
|
|
181
|
+
an action id and make handlers idempotent.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Architecture and guarantees
|
|
2
|
+
|
|
3
|
+
## Boundaries
|
|
4
|
+
|
|
5
|
+
The unit is a **logical session**, not a socket. The server keeps a session's identity and event counters while its
|
|
6
|
+
physical connections come and go. Every accepted WebSocket gets a new **epoch**; frames from an older epoch are
|
|
7
|
+
ignored on either path. The WebSocket carries control, events and WebRTC signalling; state uses a WebRTC data
|
|
8
|
+
channel when one is proven, else the WebSocket.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
game: input, simulation, rendering game: rooms, authoritative simulation
|
|
12
|
+
| |
|
|
13
|
+
GameClient (+ LobbyClient) <---- one session ----> GameServer (+ Lobby)
|
|
14
|
+
events: ordered, acked WebSocket: control, events, signalling, fallback state
|
|
15
|
+
states: latest wins WebRTC datagrams: state, probes, mirrored events, state acks
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The application owns authority. The library can't decide whether a position, hit or purchase is legal.
|
|
19
|
+
|
|
20
|
+
## Connection lifecycle
|
|
21
|
+
|
|
22
|
+
1. WebSocket: version, origin, room name, authentication; create or resume one session. The welcome carries the
|
|
23
|
+
epoch, a resume token and the ICE servers for this session.
|
|
24
|
+
2. Pings estimate the server clock. States aren't sent before the client has an estimate.
|
|
25
|
+
3. The client offers an unordered, zero-retransmit data channel (signalled over the WebSocket). The server answers
|
|
26
|
+
from one UDP port shared by every connection.
|
|
27
|
+
4. Each side probes the datagram path and needs its own replies back. "Open" isn't proof: one-way loss is caught.
|
|
28
|
+
5. State uses datagrams while proof is recent (1.5 s); otherwise the WebSocket. RTC is retried with backoff.
|
|
29
|
+
6. A lost WebSocket pauses the session; the client reconnects with its token (backoff from 200 ms, at once when the
|
|
30
|
+
browser reports `online` or the page comes back). The server rechecks authentication and the room/subject
|
|
31
|
+
binding. Unacknowledged events are replayed both ways.
|
|
32
|
+
7. After `resumeGraceMs` the session expires. The client must call `newSession()`; old actions are not replayed into
|
|
33
|
+
a new player.
|
|
34
|
+
|
|
35
|
+
A second tab resuming the same token takes the session over; the first reports session loss rather than fighting.
|
|
36
|
+
|
|
37
|
+
## Wire protocol (version 3)
|
|
38
|
+
|
|
39
|
+
Binary frames, little-endian, 22-byte header: magic `TW`, version, kind, epoch (u32), stream (u16), seq (u32),
|
|
40
|
+
time (f64).
|
|
41
|
+
|
|
42
|
+
| Kind | Path | Meaning |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| 1 state | either | Latest-wins application bytes. |
|
|
45
|
+
| 6 state-acked | either | The same, and the receiver reports the newest seq it saw. |
|
|
46
|
+
| 5 state acks | datagram if up | Pairs of (stream u16, seq u32), at most every 50 ms. |
|
|
47
|
+
| 2/3 probe/reply | datagram | Liveness proof and RTT. |
|
|
48
|
+
| 4 events | datagram | Copies of WebSocket-sent unacknowledged events (`[[seq, json], ...]`, under 1 KB) and, in the seq field, the sender's own receive ack. |
|
|
49
|
+
|
|
50
|
+
Control messages are JSON text on the WebSocket: `hello`, `welcome`, `event`, `ack`, `ping`/`pong`, `signal`,
|
|
51
|
+
`rtc-reset`, `leave`, `reset`.
|
|
52
|
+
|
|
53
|
+
## Delivery contracts
|
|
54
|
+
|
|
55
|
+
### Replaceable state
|
|
56
|
+
|
|
57
|
+
`sendState` keeps only the newest unsent payload per stream, expires it after `ttlMs`, and sends by priority and age
|
|
58
|
+
within the byte budget. Receivers drop anything not newer than what they have on that stream, whichever path it
|
|
59
|
+
came by. A payload up to 1078 bytes fits one datagram; larger ones (up to `maxStateBytes`) are fragmented by SCTP on
|
|
60
|
+
the datagram path, and anything over `datagramStateBytes` (8 KiB) goes on the WebSocket instead, where a large
|
|
61
|
+
snapshot can't be lost piecewise.
|
|
62
|
+
|
|
63
|
+
Sending is not delivering. Make each payload self-contained or a delta against a snapshot the receiver
|
|
64
|
+
**acknowledged** (`ack: true`, `SnapshotHistory`). The WebSocket fallback can't drop queued bytes the way UDP does;
|
|
65
|
+
coalescing and the buffer limit keep that backlog short.
|
|
66
|
+
|
|
67
|
+
### Acknowledged events
|
|
68
|
+
|
|
69
|
+
Ordered sequence numbers, a bounded outbox, duplicate suppression within a session, replay after reconnects. Every
|
|
70
|
+
event goes on the WebSocket; while datagrams are proven, copies are also sent on them and retried at about 1.5×RTT
|
|
71
|
+
(six tries). The receiver delivers each seq once, in order, from whichever copy arrives first, holding early ones in a
|
|
72
|
+
bounded buffer, and acknowledges on both paths. The result: event latency close to UDP under loss, with TCP as the
|
|
73
|
+
guarantee.
|
|
74
|
+
|
|
75
|
+
An ACK means your callback returned. It isn't a database commit. For purchases and transfers, give the action an id,
|
|
76
|
+
persist it with the result, and reply with success or failure.
|
|
77
|
+
|
|
78
|
+
## Lobby
|
|
79
|
+
|
|
80
|
+
Rooms are the lobby's, not the transport's: a session joins a room by request (create, code, quick play), keeps a
|
|
81
|
+
stable seat across reconnects, and leaves when it asks, is kicked or replaced, or its session expires. Room changes go
|
|
82
|
+
to members as `$lobby` events with a view tailored to each (their own seat). Ready-up counts down on the server clock
|
|
83
|
+
and starts the room; any change cancels the countdown.
|
|
84
|
+
|
|
85
|
+
## Performance controls
|
|
86
|
+
|
|
87
|
+
Simulation, replication and rendering rates are separate (`FixedStepper`). Budgets: per-peer token bucket for
|
|
88
|
+
states and events (control and probes are exempt so recovery isn't starved), socket buffer high-water, per-connection
|
|
89
|
+
message and byte rate limits on both transports.
|
|
90
|
+
|
|
91
|
+
## Security
|
|
92
|
+
|
|
93
|
+
Production needs HTTPS/WSS, exact origins, input validation and, where accounts exist, `authenticate`. Origin isn't
|
|
94
|
+
authentication. Resume tokens are bearer secrets. Behind a proxy use `trustProxy` so per-address limits see real
|
|
95
|
+
addresses. TURN secrets stay on the server (`turnCredentials` mints short-lived ones). The native adapter keeps DTLS
|
|
96
|
+
fingerprint verification on. `allowImpairment` is for development.
|
|
97
|
+
|
|
98
|
+
## Not here (yet)
|
|
99
|
+
|
|
100
|
+
WebTransport (Safari shipped it in 26.4; a transport adapter can replace the data channel later), multi-region
|
|
101
|
+
routing, durable session storage across server restarts, rollback netcode, voice.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# VPS operation and the $5 target
|
|
2
|
+
|
|
3
|
+
The package has no hosting fees. It does not impose a provider bill cap, buy servers, or guarantee that a $5 VPS can run every game. Simulation cost, concurrency, entity density, activity hours, transfer allowance, and public-IP costs determine that result. A New Jersey server can be a test baseline for nearby players; it cannot provide equally low latency to players worldwide.
|
|
4
|
+
|
|
5
|
+
## Budget before deployment
|
|
6
|
+
|
|
7
|
+
Use your actual provider terms. The calculator's defaults are illustrative, not a live Vultr quote:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm run budget -- --base 5 --included-gb 1000 --overage 0.01 \
|
|
11
|
+
--bytes 12000 --players 5 --hours 100 --overhead 0.20
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`--players` is average concurrency during the specified active hours, not registered users. `--bytes` is observed encoded downlink bytes/sec/player, not an estimated schema size alone. `--overhead` estimates additional network framing. The calculator does not include backups, VAT, managed TLS services, relays, logs, storage, or transfer used by other processes.
|
|
15
|
+
|
|
16
|
+
Arithmetic example, not a plan recommendation: 50 players at 40,000 bytes/sec for 720 hours generate 5,184 GB of application payload. With a hypothetical $5 base, 1,000 GB allowance, and $0.01/GB overage, that is $46.84 even before protocol overhead. A transport library cannot make continuous traffic free.
|
|
17
|
+
|
|
18
|
+
To approach the target, send compact state only to relevant viewers, reduce idle updates, batch small entity records, bound rates, and leave static assets on the existing CDN. The default endpoint budget is a starting cap, not an established visual-quality optimum. Event/control overhead and transport accounting still need measurement.
|
|
19
|
+
|
|
20
|
+
## Deployment templates
|
|
21
|
+
|
|
22
|
+
`deploy/Dockerfile`, `deploy/Caddyfile`, and `deploy/twinwire-demo.service` are templates, not tested deployments. They run the arena as a lab; replace the command with your actual room entry point after integration. Nothing here provisions infrastructure.
|
|
23
|
+
|
|
24
|
+
Suggested layout: client/static assets through the existing host; a conventional Node process on the VPS; local Caddy terminates HTTPS/WSS on 443 and proxies `/connect`; WebRTC connects directly to a public UDP socket. The UDP endpoint must be reachable independently of the HTTP proxy. Do not assume a standard HTTP CDN forwards this UDP port.
|
|
25
|
+
|
|
26
|
+
A browser on HTTPS requires a secure WebSocket endpoint. Configure exact allowed origins. Keep admin/health endpoints restricted as appropriate. The sample `/health` intentionally contains no tokens or player data, but does expose process counts and memory.
|
|
27
|
+
|
|
28
|
+
The native server uses one UDP port for its connections. Use a distinct UDP port for each project/process on the same host. Muxing within a process does not let unrelated processes safely share the same port. The optional `publicIp` setting only rewrites IPv4 host candidates; it does not set up NAT or port forwarding. Test IPv6-only client paths and NAT64 explicitly before claiming them supported.
|
|
29
|
+
|
|
30
|
+
Example environment for a staging lab:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
HOST=127.0.0.1
|
|
34
|
+
PORT=8787
|
|
35
|
+
ALLOWED_ORIGINS=https://game.example.com
|
|
36
|
+
RTC=1
|
|
37
|
+
RTC_PORT=8788
|
|
38
|
+
# Set only when candidate rewriting is required by your network layout:
|
|
39
|
+
# RTC_PUBLIC_IP=203.0.113.7
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Serve it behind TLS, allow the configured UDP port through the relevant firewall, and test from outside the VPS. Do not expose plain WS from an HTTPS page. Do not advertise example addresses as working endpoints.
|
|
43
|
+
|
|
44
|
+
## Resource and security operations
|
|
45
|
+
|
|
46
|
+
Run the process as a dedicated unprivileged user. Restrict writable paths; back up durable game data separately. Process restart loses the transport's in-memory sessions/outboxes, so the app must implement full resync and action idempotency across restarts. Database-backed transport resumption is not implemented.
|
|
47
|
+
|
|
48
|
+
Per-IP connection limits use the actual TCP peer address. Behind Caddy, all peers may be attributed to the proxy. Set an appropriate cap and rate limit at the proxy; never blindly trust client-supplied `X-Forwarded-For` headers.
|
|
49
|
+
|
|
50
|
+
Monitor process RSS/CPU, event-loop p99, simulation tick p99, queued bytes, state expiry/coalescing, pending events, session resume failures, datagram success/fallback/retry, total NIC transfer, and provider-billed egress. Server transport counters do not include full network overhead or all handshake traffic.
|
|
51
|
+
|
|
52
|
+
Keep UDP optional, but do not hide a missing native dependency. `RTC=1` fails startup if native support is unavailable. A connection that uses WS fallback is a valid compatibility result, not proof that the UDP deployment works.
|
|
53
|
+
|
|
54
|
+
## Measurement plan
|
|
55
|
+
|
|
56
|
+
Use a staging VPS with the intended memory/CPU allocation. Run 50 clients from a different machine with your actual game simulation and visible-entity density. Record busy and idle behavior separately. Do not infer capacity from the localhost lab's server+clients combined CPU.
|
|
57
|
+
|
|
58
|
+
Use OS-level network impairment on machines you control to test both protocols with real transport behavior. The deterministic test harness injects application-level drops/reordering and does not model actual TCP congestion control. Never label that as cellular measurement.
|
|
59
|
+
|
|
60
|
+
Use two physical devices for remote input-to-pixel latency, or a calibrated camera/capture method. Report measurement error, render buffering, and clock assumptions. A snapshot's interpolation error at a delayed timestamp is not its error relative to the other player's present position.
|
|
61
|
+
|
|
62
|
+
## STUN and TURN
|
|
63
|
+
|
|
64
|
+
Players reach your server's UDP port directly: the server has a public address, so the browser's connectivity checks
|
|
65
|
+
arrive from wherever the player is and no relay is needed for ordinary home, office or cellular NAT. Some networks
|
|
66
|
+
block UDP outright (some corporate and school Wi-Fi, some hotel captive portals); those players stay on the
|
|
67
|
+
WebSocket, which works everywhere HTTPS does. TURN helps one more group: networks that block UDP to arbitrary ports
|
|
68
|
+
but allow it (or TURN over TLS on 443) to a relay.
|
|
69
|
+
|
|
70
|
+
Give clients ICE servers from the server so you can rotate them without a new build:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import { turnCredentials, cloudflareTurn } from 'twinwire/server';
|
|
74
|
+
// your own coturn (use-auth-secret, static-auth-secret=...): credentials expire after ttlSeconds
|
|
75
|
+
iceServers: session => [
|
|
76
|
+
{ urls: 'stun:turn.mygame.com:3478' },
|
|
77
|
+
turnCredentials({ secret: process.env.TURN_SECRET, user: session.id.slice(0, 8),
|
|
78
|
+
urls: ['turn:turn.mygame.com:3478?transport=udp', 'turns:turn.mygame.com:443?transport=tcp'] }),
|
|
79
|
+
],
|
|
80
|
+
// or Cloudflare's managed relay (anycast, UDP/TCP/TLS 443), cached until shortly before expiry
|
|
81
|
+
iceServers: cloudflareTurn({ keyId: process.env.CF_TURN_KEY_ID, apiToken: process.env.CF_TURN_TOKEN }),
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Relayed traffic counts against the relay's bandwidth (your VPS's for coturn, Cloudflare's pricing otherwise). Add
|
|
85
|
+
TURN once you've measured players stuck on the WebSocket path (`server.metrics().ws`) who would benefit.
|
|
86
|
+
|
|
87
|
+
On a VPS behind 1:1 NAT (some clouds give the instance a private address), set `publicIp` on the native factory so
|
|
88
|
+
its host candidates carry the public address, or add a STUN server to the factory's own `iceServers`.
|
|
89
|
+
|
|
90
|
+
## Health endpoint
|
|
91
|
+
|
|
92
|
+
`server.metrics()` returns sessions, connections, how many are on rtc vs ws, bytes each way, pending events and
|
|
93
|
+
protocol errors: serve it from your `http` handler on `/health`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Release checklist
|
|
2
|
+
|
|
3
|
+
For maintainers, before tagging `vX.Y.Z` (the tag publishes to npm: `.github/workflows/release.yml`).
|
|
4
|
+
|
|
5
|
+
- [ ] `npm ci && npm run check` (build, all tests, 0 skipped).
|
|
6
|
+
- [ ] `npm run test:native`: datagrams selected, fallback and recovery in the same session.
|
|
7
|
+
- [ ] `npm run test:browser`, `IMPAIR=1 npm run test:browser`, `RTC=0 npm run test:browser`: Chromium, Firefox, WebKit.
|
|
8
|
+
- [ ] `npm run test:example` and `RTC=1 npm run test:example`.
|
|
9
|
+
- [ ] `npm audit` clean; `npm pack --dry-run` lists only `dist`, docs, README, LICENSE, CHANGELOG.
|
|
10
|
+
- [ ] Wire change? `VERSION` bumped in `src/core/protocol.ts`, and the change called out in CHANGELOG.md.
|
|
11
|
+
- [ ] `validation/STATUS.md` updated with what actually ran; `CHANGELOG.md` has the version's entry.
|
|
12
|
+
- [ ] `version` in package.json matches the tag.
|
|
13
|
+
|
|
14
|
+
# Before you ship a game on it
|
|
15
|
+
|
|
16
|
+
Things a library's tests can't establish for your game:
|
|
17
|
+
|
|
18
|
+
- Safari on the oldest iOS you support and the newest, on a real phone: Wi-Fi and cellular, the hand-off between them,
|
|
19
|
+
screen lock, backgrounding, reload and page restore.
|
|
20
|
+
- Your server corrects invalid movement and judges hits by its own rules; nothing private leaks through snapshots,
|
|
21
|
+
interest filtering, broadcasts or reconnects.
|
|
22
|
+
- Actions that must happen exactly once (purchases, transfers) carry an action id and are stored with their result.
|
|
23
|
+
- Your simulation, serialization and traffic stay inside their frame budget at the player counts you expect, on the
|
|
24
|
+
machine you'll run (`tools/bench.mjs` for the transport; your own world for the rest).
|
|
25
|
+
- Your monthly transfer fits your host's allowance (`npm run budget`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,102 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twinwire",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Multiplayer networking for browser games: one resumable session on two wires (WebSocket + WebRTC datagrams), reliable events at UDP latency, delta snapshots, a lobby with codes, quick play and ready-up, and netcode helpers. Works in every browser, iPhone Safari included.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"multiplayer",
|
|
7
|
+
"netcode",
|
|
8
|
+
"game",
|
|
9
|
+
"gamedev",
|
|
10
|
+
"webrtc",
|
|
11
|
+
"websocket",
|
|
12
|
+
"datachannel",
|
|
13
|
+
"udp",
|
|
14
|
+
"lobby",
|
|
15
|
+
"matchmaking",
|
|
16
|
+
"snapshot-interpolation",
|
|
17
|
+
"client-side-prediction",
|
|
18
|
+
"turn",
|
|
19
|
+
"stun",
|
|
20
|
+
"browser",
|
|
21
|
+
"safari"
|
|
22
|
+
],
|
|
23
|
+
"homepage": "https://github.com/studiokimeiga/twinwire#readme",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/studiokimeiga/twinwire.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/studiokimeiga/twinwire/issues"
|
|
30
|
+
},
|
|
31
|
+
"author": "Studio Kimeiga",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"type": "module",
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"import": "./dist/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./client": {
|
|
44
|
+
"types": "./dist/client/index.d.ts",
|
|
45
|
+
"import": "./dist/client/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./server": {
|
|
48
|
+
"types": "./dist/server/index.d.ts",
|
|
49
|
+
"import": "./dist/server/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./netcode": {
|
|
52
|
+
"types": "./dist/netcode/index.d.ts",
|
|
53
|
+
"import": "./dist/netcode/index.js"
|
|
54
|
+
},
|
|
55
|
+
"./browser": {
|
|
56
|
+
"import": "./dist/browser/twinwire.mjs",
|
|
57
|
+
"default": "./dist/browser/twinwire.js"
|
|
58
|
+
},
|
|
59
|
+
"./package.json": "./package.json"
|
|
60
|
+
},
|
|
61
|
+
"files": [
|
|
62
|
+
"dist",
|
|
63
|
+
"README.md",
|
|
64
|
+
"LICENSE",
|
|
65
|
+
"CHANGELOG.md",
|
|
66
|
+
"docs"
|
|
67
|
+
],
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "npm run clean && tsc -p tsconfig.json && node tools/bundle.mjs",
|
|
70
|
+
"check": "npm run build && npm test",
|
|
71
|
+
"test": "node --test --test-concurrency=1 test/*.test.mjs",
|
|
72
|
+
"test:browser": "node tools/browser-e2e.mjs",
|
|
73
|
+
"test:native": "node tools/native-rtc-test.mjs",
|
|
74
|
+
"bench": "node tools/bench.mjs",
|
|
75
|
+
"budget": "node tools/budget.mjs",
|
|
76
|
+
"demo": "node examples/arena/server.mjs",
|
|
77
|
+
"prepack": "npm run build",
|
|
78
|
+
"prepare": "npm run build",
|
|
79
|
+
"prepublishOnly": "npm run check && npm run test:native",
|
|
80
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
81
|
+
"example": "node examples/party/server.mjs",
|
|
82
|
+
"test:example": "node tools/party-e2e.mjs"
|
|
83
|
+
},
|
|
84
|
+
"dependencies": {
|
|
85
|
+
"ws": "^8.18.3"
|
|
86
|
+
},
|
|
87
|
+
"peerDependencies": {
|
|
88
|
+
"node-datachannel": ">=0.33.4 <0.34.0"
|
|
89
|
+
},
|
|
90
|
+
"peerDependenciesMeta": {
|
|
91
|
+
"node-datachannel": {
|
|
92
|
+
"optional": true
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"devDependencies": {
|
|
96
|
+
"@types/node": "^22.15.0",
|
|
97
|
+
"esbuild": "^0.28.2",
|
|
98
|
+
"node-datachannel": "^0.33.4",
|
|
99
|
+
"playwright": "^1.63.0",
|
|
100
|
+
"typescript": "~5.8.3"
|
|
101
|
+
}
|
|
102
|
+
}
|