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
3 changes: 2 additions & 1 deletion docs/docs/network/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: "Overview of the engine networking docs"

# Network Overview

This page describes the engine's networking libraries: `network-server` and `network-client`. Together they provide a small, pragmatic networking layer used by the example `pong-network` game.
This page describes the engine's networking libraries: `network-server` and `network-client`. Together they provide a small, pragmatic networking layer used by the example `pong-network` game. The same apps can also run without a game server, hosted by one of the players.

## Goals

Expand All @@ -21,3 +21,4 @@ This page describes the engine's networking libraries: `network-server` and `net
- [Packet Channels](./packet-framing)
- [Network Server API](./network-server-api)
- [Network Client API](./network-client-api)
- [Peer-to-peer Networking](./network-peer)
134 changes: 134 additions & 0 deletions docs/docs/network/network-peer.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
title: "Peer-to-peer Networking"
icon: "book"
description: "Run the same client and server apps without a game server: one player hosts"
---

# Peer-to-peer Networking

A game made of a client app and a server app can run **without a game server**.
The first player to open the game hosts it: their browser tab runs the server
app next to their client app. The other players join that tab over WebRTC, on
the same four [channels](./packet-framing) and typed packets.

Nothing changes in the game code: the same systems, components and
`ctx.network` APIs run in both modes. Only one line of configuration picks the
mode.

## Turning it on

In the workspace `nanoforge.config.ts`:

```ts
import { defineConfig } from "@nanoforge-dev/config";

export default defineConfig({
type: "workspace",
packages: ["apps/*"],
network: "p2p", // "dedicated" (default) runs the server app in Bun
});
```

The environment is the same as with a dedicated server:

```dotenv
NANOFORGE_CLIENT_SERVER_ADDRESS=127.0.0.1
NANOFORGE_CLIENT_SERVER_TCP_PORT=4445
NANOFORGE_SERVER_LISTENING_TCP_PORT=4445

# STUN for every player; add a TURN server for players behind strict NATs
NANOFORGE_ICE_SERVERS=stun:stun.cloudflare.com:3478
```

## How it works

```
server process (Bun): signaling only
╱ ╲
host tab ───────── player tab one WebRTC connection per player,
server app ╲ carrying the four channels
+ client app╲───── player tab
```

- `nf build` builds the server app a second time, for the browser, into the
client's output (`server/`). The engine picks browser builds of
`@nanoforge-dev/ecs/server` and `@nanoforge-dev/network/server` on its own.
- `nf start` sets `NETWORK_MODE=p2p` for both apps. The Bun server process then
only pairs players for the WebRTC handshake, on `LISTENING_TCP_PORT`. Game
traffic never goes through it, and one process serves any number of games.
The server app still starts there, with no players: its sends do nothing.
- A page opened **without** `?room=` hosts: its client app starts the server
app in the same tab and joins it through an in-memory loopback. The server
app gets the client's environment.
- A page opened **with** `?room=CODE` joins that game's host over WebRTC.
- The host and its client app keep separate worlds, exactly as on two machines.

## Inviting players

The host logs its invite link to the console. The game can also show it:

| `ctx.network` member | Host | Player who joined | Dedicated mode |
| -------------------- | --------------------- | --------------------- | -------------- |
| `roomCode` | `"ABC234"` | `"ABC234"` | `undefined` |
| `inviteLink` | page URL `?room=CODE` | page URL `?room=CODE` | `undefined` |

```ts
export const showInvite = (registry: Registry, ctx: Context) => {
const link = ctx.network.inviteLink;
if (link) inviteText.text(`Invite your friends: ${link}`);
};
```

## Developing, testing and deploying

- **Editor:** play runs the game in dedicated mode. The game code is the same in
both modes.
- **Locally:** `nf build && nf start`, open the game, then open the logged
invite link in a second tab.
- **Hosted:** build and enable a host as usual. Each player who opens the game
without a link hosts their own game, so one host serves many games at once.

## Players coming and going

- **A player joins.** `server.clients.onConnect` fires on the host once the
player's four channels are open, so the listener can already send to it.
- **A player leaves.** `server.clients.onDisconnect` fires as soon as the
signaling server reports it gone, a data channel closes, or its connection
fails.
- **The host leaves.** The game ends: the signaling server closes the room, and
each player's `isConnected()` turns `false` about a second later.

## Limits

- **Message size.** Keep each packet at or below 64 KiB: WebRTC data channels
cap message size.
- **`ReliableUnordered` is really unordered**, unlike over a dedicated server's
WebSocket.
- **NAT.** A direct connection fails for some pairs of players, typically on
strict corporate or mobile networks. A TURN server in `ICE_SERVERS` relays
them; the default list only has STUN servers.
- **Trust.** The host runs the server app, so it can cheat. Check what players
send with `definePacket`'s `validate`. Anyone with the room code can join.
- **Background tabs.** Browsers slow down timers in hidden tabs, so the server
app in the host's tab times its ticks with a Web Worker: the game keeps its
pace for every player when the host switches tabs. Only the host's own view
slows down while hidden.
- **Server environment.** The server app gets the client's environment:
`NANOFORGE_SERVER_*` variables do not reach it in this mode.

## Signaling protocol

JSON text frames, at most 64 KiB each:

| Who | Sends | Server forwards |
| ------ | ------------------------------------ | ------------------------------------- |
| Host | connects without a `room` | `{type:"room", room}` to the host |
| Player | connects with `?room=CODE` | HTTP 404 when the room does not exist |
| Player | any frame (its WebRTC offer and ICE) | `{type:"signal", from, data}` to host |
| Host | `{type:"signal", to, data}` | `data` to that player of its room |
| Player | closes | `{type:"leave", from}` to the host |
| Host | closes | close code `4001` to its players |

The host welcomes a player (`{type:"welcome", id, token}`) once its data
channels are open, so players speak the same protocol as with a dedicated UDP
server.
4 changes: 4 additions & 0 deletions modules/ecs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
}
},
"./server": {
"browser": {
"types": "./dist/client/index.d.ts",
"default": "./dist/client/index.js"
},
"require": {
"types": "./dist/server/index.d.cts",
"default": "./dist/server/index.cjs"
Expand Down
33 changes: 32 additions & 1 deletion modules/network/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

## About

`@nanoforge-dev/network` is NanoForge's built-in networking library. It ships two subpath entry points — `@nanoforge-dev/network/client` and `@nanoforge-dev/network/server` — each sending packets on four channels: `Channel.ReliableOrdered` and `Channel.ReliableUnordered` over TCP (WebSocket), `Channel.UnreliableOrdered` and `Channel.UnreliableUnordered` over UDP (WebRTC data channels). The root entry, `@nanoforge-dev/network`, exposes the types shared by both, such as `NetworkConfig`. Splitting client and server into separate entry points keeps the server's runtime-only dependencies (`Bun.serve`, `node-datachannel`) out of a client's bundle graph entirely.
`@nanoforge-dev/network` is NanoForge's built-in networking library. It ships two subpath entry points — `@nanoforge-dev/network/client` and `@nanoforge-dev/network/server` — each sending packets on four channels: `Channel.ReliableOrdered` and `Channel.ReliableUnordered` over TCP (WebSocket), `Channel.UnreliableOrdered` and `Channel.UnreliableUnordered` over UDP (WebRTC data channels). The root entry, `@nanoforge-dev/network`, exposes the types shared by both, such as `NetworkConfig`. Splitting client and server into separate entry points keeps the server's runtime-only dependencies (`Bun.serve`, `node-datachannel`) out of a client's bundle graph entirely. The same two entry points also run a game without a game server, hosted by one of the players (see [Peer-to-peer](#peer-to-peer)).

> **The server entry point requires the [Bun](https://bun.sh) runtime.** `@nanoforge-dev/network/server` is built on Bun's native WebSocket server (`Bun.serve`) and does not run on Node.js. The client entry point is runtime-agnostic and targets the browser.

Expand Down Expand Up @@ -241,6 +241,37 @@ starts a new session with a new id.

Using a channel whose port wasn't configured on that side throws.

### Peer-to-peer

Set `network: "p2p"` in the workspace `nanoforge.config.ts` and the same client
and server apps run without a game server: the first player to open the game
hosts it, running the server app in their own tab, and the others join that tab
over WebRTC, on the same four channels and typed packets. No game code changes.

```ts
// nanoforge.config.ts (workspace)
export default defineConfig({ type: "workspace", packages: ["apps/*"], network: "p2p" });
```

- `nf build` also builds the server app for the browser, into the client's
output. `nf start` sets `NETWORK_MODE=p2p` for both apps.
- The Bun server process only pairs players (WebRTC signaling), on
`LISTENING_TCP_PORT`. Players reach it with the usual `SERVER_ADDRESS` and
`SERVER_TCP_PORT`, so the env stays the same as with a dedicated server.
- A page opened without `?room=` hosts a game; `?room=CODE` joins one. The host
logs its invite link, and the game can show it:

```ts
ctx.network.roomCode; // "ABC234", on the host and on players who joined
ctx.network.inviteLink; // "https://example.com/?room=ABC234", to send to other players
```

The game ends when the host leaves: each player's `isConnected()` turns `false`.
Keep packets at or below 64 KiB and add a TURN server to `ICE_SERVERS` for
players behind strict NATs. The host's server keeps its pace when its tab is
hidden. See the
[peer-to-peer docs](https://github.com/NanoForge-dev/Engine/blob/main/docs/docs/network/network-peer.mdx).

## Links

- [GitHub][source]
Expand Down
4 changes: 4 additions & 0 deletions modules/network/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
}
},
"./server": {
"browser": {
"types": "./dist/server-browser/browser.d.ts",
"default": "./dist/server-browser/browser.js"
},
"require": {
"types": "./dist/server/index.d.cts",
"default": "./dist/server/index.cjs"
Expand Down
11 changes: 9 additions & 2 deletions modules/network/src/client/client-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,24 @@ export type WelcomeWait = {
* @param ip - Server hostname or IP.
* @param port - Server port.
* @param session - Current client session.
* @param params - Extra query parameters, such as a peer-to-peer `room`.
* @returns The URL to connect to.
*/
export const buildServerUrl = (
wss: boolean,
ip: string,
port: number,
session: ClientSession,
params: Record<string, string> = {},
): string => {
const url = `ws${wss ? "s" : ""}://${ip}:${port}`;
if (session.token === undefined) return url;
return `${url}/?${SESSION_TOKEN_PARAM}=${encodeURIComponent(session.token)}`;
const query = Object.entries({
...params,
...(session.token !== undefined && { [SESSION_TOKEN_PARAM]: session.token }),
})
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&");
return query === "" ? url : `${url}/?${query}`;
};

/**
Expand Down
Loading
Loading