@aethervtt/shard-net 0.0.0-stage → 0.3.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Dias
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,103 @@
1
- # Temporary Holding Version
1
+ # @aethervtt/shard-net
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Multiplayer for [Shard](https://github.com/aethervtt/shard): state replication with per-player
4
+ interest and redaction, validated commands and events, accounts and player persistence, and
5
+ peer-to-peer hosting. The authority is always a Shard app running your game's systems. Transports
6
+ are separate packages:
7
+
8
+ - `@aethervtt/shard-net-websocket`: a browser/Node client and a Node server (`./server`).
9
+ - `@aethervtt/shard-net-webrtc`: peer-to-peer data channels, a lobby client, and a lobby server
10
+ (`./server`).
11
+
12
+ Design: specs 0086–0092 in the repository.
13
+
14
+ ## Shared definitions
15
+
16
+ ```ts
17
+ import { defineComponent, t } from '@aethervtt/shard-core'
18
+ import { Transform } from '@aethervtt/shard-transform'
19
+ import { defineNetCommand, defineNetEvent, ownerOnly, replicate } from '@aethervtt/shard-net'
20
+
21
+ export const Health = defineComponent('game/Health', { hp: t.u16({ max: 1000 }) })
22
+ export const Gold = defineComponent('game/Gold', { amount: t.u32() })
23
+ export const Attack = defineNetCommand('game/Attack', { target: t.entity() })
24
+ export const Chat = defineNetEvent('game/Chat', { text: t.string() })
25
+
26
+ // Both sides pass the same lists; a mismatch is refused at connect (net/schema-mismatch).
27
+ export const net = {
28
+ replicate: [
29
+ replicate(Transform, { mode: 'interpolate', quantize: { translation: { step: 0.01 } } }),
30
+ replicate(Health),
31
+ replicate(Gold, { visibleTo: ownerOnly }),
32
+ ],
33
+ commands: [Attack],
34
+ events: [Chat],
35
+ }
36
+ ```
37
+
38
+ ## Server (Node)
39
+
40
+ ```ts
41
+ import { App, intervalRunner } from '@aethervtt/shard-runtime'
42
+ import { commands, netPlugin, Replicated } from '@aethervtt/shard-net'
43
+ import { webSocketServer } from '@aethervtt/shard-net-websocket/server'
44
+
45
+ const app = new App()
46
+ app.addPlugin(
47
+ netPlugin({
48
+ ...net,
49
+ role: 'server',
50
+ transport: webSocketServer({ port: 7000 }),
51
+ async accept(peer, token) {
52
+ return { user: await verify(token) } // or a ShardError to refuse
53
+ },
54
+ players: {
55
+ spawn: (world, { peer }) => void world.spawn([Replicated, { owner: peer }], [Health, { hp: 100 }]),
56
+ capture: (world, { owned }) => world.get(owned[0]!, Health),
57
+ store: ({ user, data }) => db.save(user, data),
58
+ },
59
+ }),
60
+ )
61
+ // Game rules: systems gated with onServer read commands(world, Attack) and accept or reject them.
62
+ app.setRunner(intervalRunner())
63
+ await app.run()
64
+ ```
65
+
66
+ ## Client (browser)
67
+
68
+ ```ts
69
+ import { netPlugin, sendCommand } from '@aethervtt/shard-net'
70
+ import { webSocketTransport } from '@aethervtt/shard-net-websocket'
71
+
72
+ app.addPlugin(
73
+ netPlugin({ ...net, role: 'client', transport: webSocketTransport(), address: 'wss://game.example.com', token }),
74
+ )
75
+ const pending = sendCommand(app.world, Attack, { target }) // pending.status: accepted | rejected
76
+ ```
77
+
78
+ ## Peer to peer: a lobby, a host, and a room code
79
+
80
+ ```ts
81
+ // Node: the lobby only introduces peers; game traffic goes peer to peer.
82
+ import { lobbyServer } from '@aethervtt/shard-net-webrtc/server'
83
+ await lobbyServer({ port: 7500, host: '0.0.0.0' })
84
+
85
+ // Browser, hosting: the game's server runs in this page; the host joins it over loopback.
86
+ import { startHost } from '@aethervtt/shard-net'
87
+ import { webRtcLobby } from '@aethervtt/shard-net-webrtc'
88
+ const session = webRtcLobby({ url: 'wss://lobby.example.com', iceServers: [{ urls: 'stun:…' }] })
89
+ const host = await startHost({ plugins: () => [game()], net, session })
90
+ clientApp.addPlugin(netPlugin({ ...net, role: 'client', ...host.local }))
91
+ show(host.code)
92
+
93
+ // Browser, joining with the code.
94
+ clientApp.addPlugin(netPlugin({ ...net, role: 'client', session, code }))
95
+ ```
96
+
97
+ Peers on different networks need STUN servers in `iceServers`, and TURN behind strict NATs.
98
+
99
+ ## Tests
100
+
101
+ `@aethervtt/shard-net/testing` runs a server and N clients in one process over a loopback with
102
+ seeded latency, jitter and loss: `netHarness({ clients, net, conditions })`, then `connect()`,
103
+ `run(ticks)`, and `differences(...components)` to check every client matches the server.
@@ -0,0 +1,76 @@
1
+ import { type Entity, ShardError, type World } from '@aethervtt/shard-core';
2
+ import type { NetCommandDef } from './registry.js';
3
+ import type { NetConnection } from './transport.js';
4
+ import { type Manifest } from './wire.js';
5
+ export interface ClientConfig {
6
+ readonly manifest: Manifest;
7
+ readonly ticksPerNet: number;
8
+ readonly maxMessage: number;
9
+ readonly token: string;
10
+ readonly spawnPrefab: ((world: World, prefab: string) => Entity) | undefined;
11
+ readonly now: () => number;
12
+ }
13
+ /** A command this client sent, until the server answers (0089). */
14
+ export declare class PendingCommand {
15
+ readonly def: NetCommandDef;
16
+ readonly seq: number;
17
+ status: 'pending' | 'accepted' | 'rejected';
18
+ error: ShardError | undefined;
19
+ constructor(def: NetCommandDef, seq: number);
20
+ }
21
+ export declare class NetClient {
22
+ readonly world: World;
23
+ readonly config: ClientConfig;
24
+ readonly byNetId: Map<number, number>;
25
+ connection: NetConnection | undefined;
26
+ /** Why the server refused or dropped this client, if it did. */
27
+ lastError: ShardError | undefined;
28
+ readonly pending: Map<number, PendingCommand>;
29
+ private readonly fragmenter;
30
+ private readonly inbox;
31
+ private readonly applied;
32
+ private readonly interp;
33
+ private readonly codecs;
34
+ private readonly commandCodecs;
35
+ private readonly eventCodecs;
36
+ private readonly floatFields;
37
+ private readonly widths;
38
+ private readonly commandIndex;
39
+ private readonly reader;
40
+ private readonly out;
41
+ private latestSeq;
42
+ private seqBits;
43
+ private ackDirty;
44
+ private lastPing;
45
+ private lastNetTick;
46
+ private nextCommand;
47
+ private readonly rtts;
48
+ private readonly offsets;
49
+ private readonly sortScratch;
50
+ private closedReason;
51
+ private unsubscribe;
52
+ constructor(world: World, config: ClientConfig);
53
+ attach(connection: NetConnection): void;
54
+ dispose(): void;
55
+ private toWire;
56
+ private forget;
57
+ receive(): void;
58
+ private handle;
59
+ private welcome;
60
+ private pong;
61
+ private stale;
62
+ /** Decodes one component update into its entity. False if it named an unknown entity. */
63
+ private apply;
64
+ private reliable;
65
+ private state;
66
+ private received;
67
+ private sample;
68
+ /** Writes interpolated values for every remote entity (PreUpdate). */
69
+ interpolate(): void;
70
+ private renderTick;
71
+ private readonly interpolateEntity;
72
+ private write;
73
+ /** Runs in Last: acks and pings on network ticks. */
74
+ send(): void;
75
+ sendCommand(def: NetCommandDef, data: Record<string, unknown>): PendingCommand;
76
+ }