FERRETGame engine over Babylon.js
Ferret

@ferret/net

Replication over pluggable transports, remote avatars, gateways to a game's backend, the Camerlengo link.

46 exports · source in packages/net/src

Classes

CamerlengoClient class

class CamerlengoClient
constructor(readonly opts: CamerlengoOptions)
async call(command: string, params: Record<string, unknown> = {}): Promise<Record<string, any>>
async login(path: string, user: string, password: string): Promise<string>Signs in; resolves with the session token.

packages/net/src/camerlengo.ts:23

CamerlengoError class

class CamerlengoError extends Error
constructor(readonly command: string, readonly code: string, reason: string)

packages/net/src/camerlengo.ts:19

CamerlengoOccupancy class

class CamerlengoOccupancy implements OccupancyGateway

Zone occupancy for the backend's registry (zone:setActive): a zone with players is active, one that emptied is set inactive. Uses a service key, so it belongs on the game server (or a trusted host); level is the registry's level id.

constructor(private client: CamerlengoClient, private opts: { key: string; level: string })
async report(occupancy: Record<string, number>)zone hash -> number of players there

packages/net/src/camerlengo.ts:61

CamerlengoVoice class

class CamerlengoVoice implements VoiceGateway

The voice room of a zone for the signed-in player. session is read on every call, so it can change (sign-in, refresh).

constructor(private client: CamerlengoClient, private session: () => string | null)
async roomFor(zoneHash: string)a token and URL for the LiveKit room of a zone (zoneHash, never the zone's name)

packages/net/src/camerlengo.ts:46

Gateways class

class Gateways implements Service

Holds the gateways and calls them safely. call(name, fn, fallback) runs fn(gateway) and returns fallback if the gateway is absent, switched off, down (inside its cooldown) or the call fails or times out. Events: gateway:down { name, error }, gateway:up { name }.

readonly name: "gateways"
constructor(readonly set: GatewaySet = {}, readonly opts: GatewayOptions = {})
init(world: World)
has(name: GatewayName): booleanIs there a gateway of that name, switched on? (Not whether it is reachable.)
isDown(name: GatewayName): boolean
async call<K extends GatewayName, R>(name: K, fn: (g: NonNullable<GatewaySet[K]>) => Promise<R>, fallback: R): Promise<R>
user()
balance()
change(amount: number, reason: string): Promise<number | null>Earn or spend. Resolves with the new balance, or null when it did not happen (no account, backend down, refused).
voiceRoom(zoneHash: string)
async reportOccupancy(o: Record<string, number>)
dialogue(npc: string, utterance: string, context?: Record<string, unknown>)The NPC's line from the backend, or null: the caller then says its local line.

packages/net/src/gateways.ts:55

HttpPollTransport class

class HttpPollTransport implements Transport

Presence-server transport: every client sets one record (its entities' latest state and its last events) and lists the records of all. The server needs no change, but this is polling: positions about twice a second, events as soon as the next poll. Enough to see other players walk about; use websocket or livekit for anything quicker. The record is { state, events: [{seq,name,data}], time } under the key = the client's id.

readonly name: "http-poll"
healthy: booleanfalse while the server cannot be reached
constructor(readonly opts: HttpPollOptions)
connect(selfId: string)
disconnect()
send(msg: NetMessage, _reliable: boolean)
onMessage(fn: (m: NetMessage) => void)
update()Poll when it is time. Call every frame; it does nothing in between.

packages/net/src/transport.ts:162

LiveKitTransport class

class LiveKitTransport implements Transport
readonly name: "livekit"
constructor(private room: () => DataRoom | null)
connect()
bind(room: DataRoom)Call when the room (re)joins: start listening on it.
disconnect()
send(msg: NetMessage, reliable: boolean)
onMessage(fn: (m: NetMessage) => void)

packages/net/src/transport.ts:242

LiveKitVoice class

class LiveKitVoice implements VoiceAdapter

Joins and leaves the voice room of a zone. createRoom makes a fresh Room each time (() => new Room() from livekit-client), so the engine does not depend on that package. room is the connected Room, for LiveKitTransport.bind.

room: LiveKitRoomLike | null
constructor(private createRoom: () => LiveKitRoomLike, private opts: { microphone?: boolean; onJoin?: (room: LiveKitRoomLike) => void; /** where the audio elements of other players go (default: document.body, when there is a document) */ container?: { appendChild(n: any): unknown } } = {})
async join(r: { url: string; token: string; room: string })
async leave()

packages/net/src/camerlengo.ts:92

MemoryHub class

class MemoryHub
latency: numberartificial one-way delay in ms (0 = delivered at once)
loss: numberdrop this share of unreliable messages
join(t: MemoryTransport, id: string)
leave(id: string)
deliver(from: string, msg: NetMessage, reliable: boolean)

packages/net/src/transport.ts:38

MemoryTransport class

class MemoryTransport implements Transport
readonly name: "memory"
constructor(readonly hub: MemoryHub)
connect(selfId: string)
disconnect()
send(msg: NetMessage, reliable: boolean)
onMessage(fn: (m: NetMessage) => void)
receive(m: NetMessage)

packages/net/src/transport.ts:56

NetService class

class NetService implements Service

Replication: publishes the state of the entities this machine owns, creates and moves copies of the others', carries events (a door opened, an item taken), tells when players come and go. The transport underneath is a setting. Game code does not know about it: it marks entities (replicate), says which events are shared (replicateEvent) and how a copy is made (spawnable); with NetService left out the same game is a single-player game. Events on the world: net:join { peer }, net:leave { peer }, net:event { from, name, data }.

readonly name: "net"
readonly id: string
readonly tickRate: number
readonly delay: number
constructor(readonly transport: Transport, readonly opts: NetOptions = {})
init(world: World)
isInTransit(e: Entity): booleanTrue while the entity is travelling and is not published.
async start(): Promise<void>Connect the transport. A transport that cannot connect leaves the game playing alone.
get peerIds(): string[]
copies(): Entity[]the copies of other players' entities
spawnable(kind: string, factory: Factory): thisHow to make the copy of an entity of this kind. It returns the entity (node, look, animator); the service adds the rest.
replicate(e: Entity, params: ReplicatedParams): ReplicatedPublish an entity this machine owns.
replicateEvent(name: string, rule: EventRule): thisShare a world event: when it happens here, others get it; when it comes from others, apply does it here.
send(name: string, data: unknown): voidA message of your own to everyone else (reliable).
on(name: string, fn: (data: any, from: string) => void): () => voidListen for a message of that name from other players.
fixedUpdate(dt: number)Called before the components' fixed update, at the fixed rate.
update()Called once per rendered frame.
dispose()

packages/net/src/net.ts:67

OccupancyReporter class

class OccupancyReporter implements Service

Reports the zone occupancy (hashed names) to the occupancy gateway every few seconds, when it changes.

readonly name: "occupancy-reporter"
constructor(readonly opts: { interval?: number } = {})
init(world: World)
update(dt: number)Called once per rendered frame.

packages/net/src/gateways.ts:153

PresenceRecordStore class

class PresenceRecordStore

Named records on the presence server, speaking the original game's wire: POST {op:"set", name, x, y, z, yaw, model} to assert one and POST {op:"get", name} to read it; a reply with an error field means missing or stale. The server forgets a record that nobody posts for ~3 s, so "nobody is near" needs no message. read is synchronous: it returns the last answer and starts the next poll in the background (one in flight per name), so a caller that ticks reads the state one tick late, as the original does. When the server cannot be reached the client's own posts are kept for stale seconds so a player alone, offline, still drives the state. Structurally a RecordStore of @ferret/transform.

offline: booleantrue after a failed request, false after a successful one
constructor(readonly opts: PresenceRecordStoreOptions)
post(name: string, record: NamedRecord)
read(name: string): NamedRecord | null

packages/net/src/records.ts:27

RemoteMotion class

class RemoteMotion extends Component

Gives a remote copy a motion for the Animator (walk/run by the speed the owner reported).

velocity: Vector3
grounded: boolean
motionState()

packages/net/src/net.ts:28

Replicated class

class Replicated extends Component<ReplicatedParams>

Marks an entity whose state is published (when this machine owns it) or received (when it is somebody else's copy).

owner: stringthe owner's id: the player who publishes it
remote: boolean
netId: stringnetwork id

packages/net/src/net.ts:19

UnrealRecordStore class

class UnrealRecordStore implements FerretRecordStore

Adapts a store that speaks the original game's units (Unreal axes: x mirrored, y = Ferret z, z up, centimetres, location = the capsule's centre; yaw turned by 90 degrees as the level exporter turns it) to Ferret's, so Ferret and Unreal clients share records such as an NPC's state. half is half the capsule's height in metres (default 0.44, the animals' 34 x 88 cm capsule).

constructor(private inner: FerretRecordStore, private half = 0.44)
post(name: string, r: NamedRecord)
read(name: string): NamedRecord | null

packages/net/src/records.ts:86

WebSocketTransport class

class WebSocketTransport implements Transport
readonly name: "websocket"
connected: booleanfalse while the socket is down; messages sent then are dropped (state) or kept for the reconnect (events)
constructor(readonly opts: WebSocketOptions)
connect(selfId: string): Promise<void>
disconnect()
send(msg: NetMessage, reliable: boolean)
onMessage(fn: (m: NetMessage) => void)

packages/net/src/transport.ts:91

ZoneVoice class

class ZoneVoice implements Service

Zone voice: when the player enters a zone, join the voice room of that zone (through the gateway's token); leaving the zone leaves the room. No gateway or no adapter: the zones stay silent.

readonly name: "zone-voice"
current: string | null
constructor(readonly adapter: VoiceAdapter, readonly opts: { tag?: string } = {})
init(world: World)
get settled(): Promise<void>Resolves when the joins and leaves asked for so far are done (tests).
enterZone(zone: string)Join the voice room of a zone now (also for a player who is already in it when the gateway comes online, e.g. after sign-in).
dispose()

packages/net/src/gateways.ts:113

Functions

bodyField function

function bodyField(): ReplicatedField

The replicated field for a body: add it to a Replicated entity's fields ({ body: bodyField() }). The owner publishes the snapshot; a copy turns its node to it and, if the copy has a body, gives it the velocities (so a kinematic copy carries on in the direction it was going between two updates). Position and the interpolation already come with the entity's state.

packages/net/src/bodies.ts:31

replicateBreaks function

function replicateBreaks(net: NetService, strengthOf: (world: World) => { breakNow(e: Entity, point?: [number, number, number], seed?: number): void } | undefined): void

Share breaks: when a replicated body breaks here, the others break its copy with the same seed, so the pieces and their scatter are the same (the strength capability takes the seed, breakNow(entity, point, seed)). A break of an entity that is not replicated stays on this machine.

packages/net/src/bodies.ts:58

snapshotBody function

function snapshotBody(b: BodyLike): BodySnapshot

Round and pack a body's state: millimetres, 1e-4 in a unit quaternion, one flag when it sleeps.

packages/net/src/bodies.ts:21

Interfaces

AccountGateway interface

interface AccountGateway

The game's backends, each behind an interface the game implements (§4.7): account and currency, the voice room of a zone, zone occupancy, NPC dialogue. All are optional. A missing or unreachable gateway never throws into the game: calls return their fallback and the gateway is marked down until it works again.

user(): Promise<{ id: string; name: string } | null>the signed-in player, or null for a guest
balance(): Promise<number>
change(amount: number, reason: string): Promise<number>earn (positive) or spend (negative); resolves with the new balance, rejects when the backend refuses

packages/net/src/gateways.ts:8

BodySnapshot interface

interface BodySnapshot

What a snapshot of a body holds. A body at rest sends its pose only (z): no velocities to apply, no change to send.

q: [number, number, number, number]
v?: [number, number, number]
w?: [number, number, number]
z?: 1

packages/net/src/bodies.ts:14

CamerlengoOptions interface

interface CamerlengoOptions

The Camerlengo backend (ReForce API v2): one endpoint, POST /, body { command, ...params }, answer { ".status": "ok"|"error", ... }. Commands the game uses:

  user:verify          {key, path, user, password} -> {session}      sign a player in (`key`: the app's public key, scope user:verify only)
  livekit:userToken    {session, room: "zone:<hash>"} -> {token, url, room}   the voice room of a zone, for that player
  zone:setActive       {key, room, level, cellX, cellY, active}    zone occupancy (a service key: keep it on the game server, never in a page)

fetch is injected so tests and non-browser hosts can supply their own.

url: stringthe backend, e.g. https://backend.squirrelwisdom.com/
appKey?: stringthe app's public key for user:verify (a key with that one scope; it ships in the page, so it can do nothing else)
fetch?: typeof fetch

packages/net/src/camerlengo.ts:11

DataRoom interface

interface DataRoom

The part of a LiveKit Room this needs; pass the real one (livekit-client) or a fake.

publishData(data: Uint8Array, opts: { reliable: boolean }): Promise<void> | void
onData(fn: (data: Uint8Array, from?: string) => void): () => void

packages/net/src/transport.ts:237

DialogueGateway interface

interface DialogueGateway
say(npc: string, utterance: string, context?: Record<string, unknown>): Promise<string | null>what an NPC says to the player; null when it has nothing (the game then uses its local lines)

packages/net/src/gateways.ts:26

EntityState interface

interface EntityState
id: stringnetwork id, unique in the game (the owner's id + the entity's key)
kind: stringwhich factory makes the remote copy
p: [number, number, number]position
r: numberyaw, radians
v?: [number, number, number]velocity, for the remote copy's animation
g?: boolean
f?: Record<string, unknown>custom replicated fields

packages/net/src/transport.ts:7

FerretRecordStore interface

interface FerretRecordStore

A store of records in Ferret's units: metres, feet, yaw in degrees (@ferret/transform's SharedRecord).

post(name: string, record: NamedRecord): void
read(name: string): NamedRecord | null

packages/net/src/records.ts:76

GatewayOptions interface

interface GatewayOptions
enabled?: Partial<Record<GatewayName, boolean>>each can be switched off whatever is supplied (configuration)
timeout?: numberseconds before a call counts as failed (default 5)
cooldown?: numberseconds a gateway that failed is left alone before the next try (default 15)
now?: () => number

packages/net/src/gateways.ts:40

GatewaySet interface

interface GatewaySet
account?: AccountGateway
voice?: VoiceGateway
occupancy?: OccupancyGateway
dialogue?: DialogueGateway

packages/net/src/gateways.ts:31

HttpPollOptions interface

interface HttpPollOptions
url: stringthe presence server's URL
interval?: numberseconds between polls (default 0.5)
eventRing?: numberhow many recent events a client keeps in its record for others to read (default 20)
fetch?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ ok: boolean; json(): Promise<any> }>
now?: () => number

packages/net/src/transport.ts:145

LiveKitRoomLike interface

interface LiveKitRoomLike

The part of a livekit-client Room that voice needs; pass the real class's instances or a fake.

connect(url: string, token: string): Promise<unknown>
disconnect(): unknown
localParticipant?: { setMicrophoneEnabled(on: boolean): Promise<unknown> }
on?(event: string, fn: (...args: any[]) => void): unknownlivekit-client's event emitter; used to play other players' audio
startAudio?(): Promise<unknown>

packages/net/src/camerlengo.ts:79

NamedRecord interface

interface NamedRecord

A shared record about one named thing (an NPC): the fields of the original game's presence-server record (FStudentData).

location: [number, number, number]
yaw: numberdegrees
model: string

packages/net/src/records.ts:2

NetOptions interface

interface NetOptions
id?: stringthis player's id (default: a random one)
tickRate?: numberstate messages per second (default 10)
delay?: numberremote copies are shown this far in the past, seconds: room to interpolate between two updates (default 0.15)
timeout?: numberseconds without news before a player counts as gone (default 6)
now?: () => numberreplace the clock (tests); seconds

packages/net/src/net.ts:34

OccupancyGateway interface

interface OccupancyGateway
report(occupancy: Record<string, number>): Promise<void>zone hash -> number of players there

packages/net/src/gateways.ts:21

PresenceRecordStoreOptions interface

interface PresenceRecordStoreOptions
url: stringthe presence server's URL (POST, JSON)
stale?: numberseconds a record counts as fresh without a new post or a successful read (default 3, the server's own expiry)
fetch?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ ok: boolean; json(): Promise<any> }>
now?: () => numberseconds, default Date.now() / 1000

packages/net/src/records.ts:9

ReplicatedField interface

interface ReplicatedField
get(e: Entity): unknown
set(e: Entity, v: unknown): void

packages/net/src/net.ts:4

ReplicatedParams interface

interface ReplicatedParams
kind: stringwhich factory makes this entity's copy on other players' machines
key?: stringa name that is the same on every machine for this entity (default: the entity's id)
fields?: Record<string, ReplicatedField>extra state to carry: a name -> getter and setter (a worn hat, a shown name, a held item)

packages/net/src/net.ts:9

SocketLike interface

interface SocketLike
readyState: number
send(data: string): void
close(): void
onopen: ((ev?: unknown) => void) | null
onmessage: ((ev: { data: unknown }) => void) | null
onclose: ((ev?: unknown) => void) | null
onerror: ((ev?: unknown) => void) | null

packages/net/src/transport.ts:69

Transport interface

interface Transport

A way to get messages to the other players. send goes to everyone else in the game; the transport never echoes a message back to its sender. Reliable messages (events) must arrive; unreliable ones (state) may be dropped or replaced by newer ones.

readonly name: string
connect(selfId: string): Promise<void> | void
disconnect(): void
send(msg: NetMessage, reliable: boolean): void
onMessage(fn: (msg: NetMessage) => void): () => void
update?(): voidtransports that poll or batch do their work here, called every frame

packages/net/src/transport.ts:27

VoiceAdapter interface

interface VoiceAdapter

What joins a voice room. The LiveKit client is behind this so the engine does not depend on it.

join(room: { url: string; token: string; room: string }): Promise<void>
leave(): Promise<void>

packages/net/src/gateways.ts:104

VoiceGateway interface

interface VoiceGateway
roomFor(zoneHash: string): Promise<{ url: string; token: string; room: string }>a token and URL for the LiveKit room of a zone (zoneHash, never the zone's name)

packages/net/src/gateways.ts:16

WebSocketOptions interface

interface WebSocketOptions
url: stringwss://host/path ; the room is a query parameter the relay understands
room?: string
create?: (url: string) => SocketLikereplace new WebSocket (tests)
retry?: numberseconds between reconnect attempts (default 2; 0 = no reconnect)
connectTimeout?: numberseconds to wait for the connection before giving up on this attempt (default 5)

packages/net/src/transport.ts:79

Types

EventRule type

type EventRule = { /** event payload -> plain data for the wire (entities become ids); returning undefined keeps the event local */ encode?: (payload: any) => unknown; /** wire data -> the effect here. Must not emit the same event again; if it does, the echo is blocked anyway */ apply: (world: World, data: any, from: string) => void; }

packages/net/src/net.ts:47

GatewayName type

type GatewayName = keyof GatewaySet

packages/net/src/gateways.ts:38

NetMessage type

type NetMessage = | { t: 'state'; from: string; time: number; ents: EntityState[] } | { t: 'event'; from: string; seq: number; name: string; data: unknown } | { t: 'bye'; from: string }

What goes over the wire. Transports carry these as JSON; they do not look inside.

packages/net/src/transport.ts:2