Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 71 additions & 24 deletions docs/docs/network/network-client-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,20 @@ description: "API reference for the client networking package"
Reliable channels need `SERVER_TCP_PORT` and unreliable ones `SERVER_UDP_PORT`.
Using a channel whose port is not configured throws.

The client connects during `__init`, so its channels are open once the game runs.
TCP connects first, then UDP with the TCP session token, so both share one client
id. `__init` rejects when a transport fails, or when its channels are not open
within 10 seconds.

```ts
import { Channel } from "@nanoforge-dev/network/client";

await ctx.network.connect({ channels: [Channel.ReliableOrdered, Channel.UnreliableOrdered] });

ctx.network.sendData({ type: "join" }); // Channel.ReliableOrdered
ctx.network.sendData({ x: 1, y: 2 }, { channel: Channel.UnreliableOrdered });

const messages = ctx.network.getReceivedPackets().map((packet) => packet.json()); // every channel
```

## connect(options?)

Connect to the server and resolve once the channels are open. Only the
transports the channels need are opened: TCP for reliable channels, UDP for
unreliable ones. TCP connects first, then UDP with the TCP session token, so both
share one client id. Calling it again for channels that are already open resolves
right away.

Option `channels`: `Channel[]`, the channels to open and wait for. Every
configured channel by default.

Option `timeout`: `number`, milliseconds before giving up. `10000` by default.

Rejects when a channel's port is not configured, when a transport fails to
connect, or when the channels are not open before the timeout.

Returns: `Promise<void>`

## sendData(data, options?)

Send a payload to the server.
Expand All @@ -59,13 +44,75 @@ Returns: `void`

## getReceivedPackets(options?)

Return the packets received since the last call. Call it once per frame.
Return the packets received since the last call. Call it once per frame:
packets left unread for a whole tick are dropped. Typed packets never show up
here.

Option `channel`: `Channel` to read from. Without it, the packets of every
configured channel are returned, reliable channels first.

Returns: [`NetworkData[]`](#networkdata)

## packets

Typed packets, declared with `definePacket` (see below).
Packets of a type are routed to its listeners and to `read`, never to
`getReceivedPackets`.

### packets.on(packet, listener)

Call `listener(data, { channel, ctx })` for every packet of this type, each tick
before the ECS systems (not while paused). Packets are dispatched in the order
they arrived, reliable channels first. A listener that throws, or returns a
rejected promise, is logged and does not stop the others.

Returns: `() => void`, a function that removes the listener.

### packets.read(packet)

Return the packets of this type received this tick, as `{ data, channel }[]`.
Meant for ECS systems, which run after the packets are dispatched.

Returns: `{ data: T; channel: Channel }[]`

### packets.send(packet, data?, options?)

Send a packet to the server. `data` must match the definition's payload type and
is encoded as JSON. Leave it out for a packet without payload, declared without
a type: `send(Play)`.

Option `channel`: `Channel` to send on. The definition's channel by default.

Throws a `TypeError` when `data` cannot be encoded as JSON.

Returns: `void`

## definePacket(name, options?)

Declare a packet type, in a file both apps import. Also exported by
`@nanoforge-dev/network` itself, for that shared file.

Parameter `name`: `string`, 1 to 255 UTF-8 bytes, unique in the game. It is
written in front of every packet of this type.

Option `channel`: `Channel` used when `send` gets no `channel`.
`Channel.ReliableOrdered` by default.

Option `validate`: `(data: unknown) => data is T`, run on every received payload
before listeners and `read`. Packets that fail it are dropped with a warning.

Throws when the name is empty or too long, and later, in `packets.on` or `read`,
when another definition of the same name has another channel or `validate`.

Returns: `PacketDefinition<T>`. Without a type, `T` is `void`: the packet
carries no payload.

```ts
export const Move = definePacket<{ id: number; x: number; y: number }>("move", {
channel: Channel.UnreliableOrdered,
});
```

## isConnected(options?)

Return `true` when packets can be sent and received.
Expand All @@ -78,15 +125,15 @@ Returns: `boolean`
## clientId

Client id assigned by the server, shared by TCP and UDP. `undefined` until
`connect` has been welcomed by the server. It is only kept in memory: a page
the server has welcomed the client. It is only kept in memory: a page
reload gets a new id.

Type: `string | undefined`

## NetworkPayload

What `sendData` accepts. Packets carry no format tag: the receiver reads a
packet the way it was sent.
What `sendData` accepts. Its payload carries no format tag: the receiver reads
a packet the way it was sent.

| Value | Encoded as | Read back with |
| -------------------------------------------------- | --------------------- | ----------------------------- |
Expand Down
45 changes: 42 additions & 3 deletions docs/docs/network/network-client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ four [channels](packet-framing), picked with the `channel` option:
```javascript
import { Channel } from "@nanoforge-dev/network/client";

// Open the channels the game uses and wait until they are ready
await network.connect({ channels: [Channel.ReliableOrdered, Channel.UnreliableOrdered] });

// Send a value as JSON, on Channel.ReliableOrdered
network.sendData({ hello: "world" });

Expand All @@ -43,6 +40,48 @@ const latestPackets = network.getReceivedPackets();
const decodedPackets = latestPackets.map((packet) => packet.json());
```

## Typed packets

Instead of switching on a `type` field, declare each packet type once, in a file
both the client and the server import, and let the library route packets to it.

```ts
// shared/protocol.ts
import { Channel, definePacket } from "@nanoforge-dev/network";

export const Play = definePacket("play"); // no payload
export const Input = definePacket<{ key: "up" | "down" }>("input");
export const Move = definePacket<{ id: number; x: number; y: number }>("move", {
channel: Channel.UnreliableOrdered, // default channel of this type
});
```

```ts
// client: listen once, after app.init()
const off = network.packets.on(Move, (move, { channel, ctx }) => {
applyMove(ctx.ecs.registry, move); // `move` is typed
});

// or read this tick's packets from an ECS system
export const applyMoves = (registry, ctx) => {
for (const { data } of ctx.network.packets.read(Move)) applyMove(registry, data);
};

// send: the payload is checked against the definition
ctx.network.packets.send(Input, { key: "up" }); // Channel.ReliableOrdered
ctx.network.packets.send(Play); // no payload to give
```

- Listeners are called every tick before the ECS systems, in the order the
packets arrived, reliable channels first. They are not called while the game
is paused: packets wait for the next tick.
- `read` returns the packets of the current tick; a system that does not read
them every tick misses some.
- Typed packets never appear in `getReceivedPackets`, which only returns the
packets sent with `sendData`.
- A packet type with no listener and no reader is dropped with a warning,
printed once per type.

## Notes

- See [Network Client API](network-client-api) for the full list of available functions,
Expand Down
45 changes: 42 additions & 3 deletions docs/docs/network/network-server-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ Returns: `void`

### getReceivedPackets(options?)

Return the packets each client sent since the last call. Call it once per frame.
Return the packets each client sent since the last call. Call it once per
frame: packets left unread for a whole tick are dropped. Typed packets never
show up here.

Option `channel`: `Channel` to read from. Without it, the packets of every
configured channel are merged per client, reliable channels first.
Expand All @@ -88,10 +90,47 @@ Parameter `clientId`: `ClientId`

Returns: `ClientInfo | undefined` (`undefined` once the client is gone)

## packets (`ctx.network.packets`)

Typed packets, declared with `definePacket` (see the
[network-client API](network-client-api)). Packets of a type
are routed to its listeners and to `read`, never to `getReceivedPackets`.

### on(packet, listener)

Call `listener(data, { clientId, channel, ctx })` for every packet of this type,
each tick before the ECS systems (not while paused). Packets are dispatched in
the order they arrived, reliable channels first. A listener that throws, or
returns a rejected promise, is logged and does not stop the others.

Returns: `() => void`, a function that removes the listener.

### read(packet)

Return the packets of this type received this tick, as
`{ data, clientId, channel }[]`. Meant for ECS systems, which run after the
packets are dispatched.

Returns: `{ data: T; clientId: ClientId; channel: Channel }[]`

### sendToAll(packet, data?, options?)

Send a packet to every client reachable on the channel. `data` must match the
definition's payload type and is encoded as JSON. Leave it out for a packet
without payload: `sendToAll(Start)`.

Option `channel`: `Channel` to send on. The definition's channel by default.

Throws a `TypeError` when `data` cannot be encoded as JSON.

### sendToClient(clientId, packet, data?, options?)

Send a packet to one client, like `sendToAll`.

## NetworkPayload

What `sendToAll` and `sendToClient` accept. Packets carry no format tag: the
receiver reads a packet the way it was sent.
What `sendToAll` and `sendToClient` accept. Their payload carries no format
tag: the receiver reads a packet the way it was sent.

| Value | Encoded as | Read back with |
| -------------------------------------------------- | --------------------- | ----------------------------- |
Expand Down
25 changes: 25 additions & 0 deletions docs/docs/network/network-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ const firstClientPackets = allPackets.get(connectedClients[0]);
const decodedPackets = firstClientPackets.map((packet) => packet.json());
```

## Typed packets

The server uses the same packet types as the client, from the shared protocol
file (see [Typed packets](network-client#typed-packets)). Its listeners and
`read` also get the sender.

```ts
import { Input, Move, Play } from "../../shared/protocol";

// listen once, after app.init()
network.packets.on(Input, ({ key }, { clientId, ctx }) => {
handleInput(clientId, key, ctx);
});

// or read this tick's packets from an ECS system
for (const { data, clientId } of ctx.network.packets.read(Input)) handleInput(clientId, data.key);

// send to everybody or to one client, on the definition's channel by default
ctx.network.packets.sendToAll(Move, { id: 1, x: 2, y: 3 });
ctx.network.packets.sendToClient(clientId, Move, { id: 1, x: 2, y: 3 });
```

Payloads come from the clients, so check the ones the game cannot trust with
the `validate` option of `definePacket`.

## Notes

- See [Network Server API](network-server-api) for the full list of available functions.
Expand Down
35 changes: 27 additions & 8 deletions docs/docs/network/packet-framing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ description: "The four delivery channels and how packets are framed on each"
Every packet is sent on one of four channels, picked with the `channel` option
of the network methods, for example
`ctx.network.sendData(data, { channel: Channel.UnreliableOrdered })`. Without
it, packets are sent on `Channel.ReliableOrdered`. Pick the channel by what the
it, packets are sent on `Channel.ReliableOrdered`, and typed packets on the
channel of their definition. Pick the channel by what the
packet needs: whether it may be lost, and whether an older packet is still
useful once a newer one has arrived.

Expand All @@ -32,22 +33,40 @@ promises that nothing is lost.
Both transports deliver whole messages, so one message is one packet: there is
no delimiter and payloads may contain any bytes.

The payload carries no format tag. Bytes are sent as they are, a string as its
UTF-8 text, and any other value as its `JSON.stringify` text. The receiver gets a
`NetworkData` and reads it the way it was sent, with `bytes()`, `text()` or
`json()`.
Every packet starts with the name of its packet type: one length byte, then the
name in UTF-8, then the payload.

- **Typed packets** (`ctx.network.packets.send`, see
[Typed packets](network-client#typed-packets)) carry the name given to
`definePacket`, and their payload is the `JSON.stringify` text of the data,
or nothing for a packet without payload.
The receiver routes them to the listeners and `read` of that type.
- **`sendData` packets** have a length of `0`, so no name. Their payload carries
no format tag: bytes are sent as they are, a string as its UTF-8 text, and any
other value as its `JSON.stringify` text. The receiver gets a `NetworkData`
from `getReceivedPackets` and reads it the way it was sent, with `bytes()`,
`text()` or `json()`.

```
typed : [4]["move"][{"id":1,"x":2}]
sendData : [0][payload]
```

- **TCP**: each binary WebSocket frame starts with one tag byte, `0` for
`ReliableOrdered` and `1` for `ReliableUnordered`, followed by the payload.
`ReliableOrdered` and `1` for `ReliableUnordered`, followed by the packet.
Text frames are reserved for control messages such as the server `welcome`.
- **WebRTC**: the client opens one data channel per unreliable channel, labelled
`unreliable-ordered` and `unreliable-unordered`. Both use `ordered: false` and
`maxRetransmits: 0`.

## Dropping late packets

`UnreliableOrdered` packets start with a big-endian `uint32` sequence number,
counted per peer and per direction. The receiver delivers a packet only if it is
Each `UnreliableOrdered` message starts with a big-endian `uint32` sequence
number, followed by the packet; the number is counted per peer and per
direction. The receiver delivers a packet only if it is
newer than the last one it delivered, and drops it otherwise. Nothing ever waits
for a lost packet: a gap is skipped as soon as a newer packet arrives. Numbers
are compared as serial numbers, so the counter can wrap around.

The sequence is shared by every packet type sent on the channel: a late packet
is dropped even when the newer one was of another type.
20 changes: 12 additions & 8 deletions example/pong-network/apps/client/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ import { NanoforgeFactory } from "@nanoforge-dev/core";
import { EcsLibrary } from "@nanoforge-dev/ecs/client";
import { Circle, Graphics2DLibrary, Layer, Rect } from "@nanoforge-dev/graphics-2d";
import { InputEnum, InputLibrary } from "@nanoforge-dev/input";
import { Channel, NetworkClientLibrary } from "@nanoforge-dev/network/client";
import { NetworkClientLibrary } from "@nanoforge-dev/network/client";

import { AssignId, Play } from "../../../shared/protocol";
import {
CircleComponent,
Controller,
NetworkId,
Position,
RectangleComponent,
Velocity,
} from "./components/components";
import { controlPlayer, draw, move, packetHandler } from "./systems/systems";
import { applyMoves, controlPlayer, draw, move } from "./systems/systems";

export const layer = new Layer();

Expand Down Expand Up @@ -82,16 +84,18 @@ export const main = async (options: ClientRunOptions): Promise<void> => {
new RectangleComponent(new Rect({ fill: "blue", width: 30, height: 300 })),
);

registry.addSystem(packetHandler);
// Dispatched before the systems, so an entity gets its id before `applyMoves` reads its moves.
network.packets.on(AssignId, ({ assigned, id }) => {
const index = { ball: 2, paddle1: 4, paddle2: 5 }[assigned];
registry.addComponent(registry.entityFromIndex(index), new NetworkId(id));
});

registry.addSystem(applyMoves);
registry.addSystem(move);
registry.addSystem(controlPlayer);
registry.addSystem(draw);

// Wait for every channel the game uses, so no early move is missed.
await network.connect({
channels: [Channel.ReliableOrdered, Channel.UnreliableOrdered, Channel.UnreliableUnordered],
});
network.sendData({ type: "play" });
network.packets.send(Play);

await app.run();
};
Loading
Loading