@ferret/net
Replication over pluggable transports, remote avatars, gateways to a game's backend, the Camerlengo link.
Classes
CamerlengoClient class
class CamerlengoClientconstructor(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 Errorconstructor(readonly command: string, readonly code: string, reason: string) |
packages/net/src/camerlengo.ts:19
CamerlengoOccupancy class
class CamerlengoOccupancy implements OccupancyGatewayZone 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 VoiceGatewayThe 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 ServiceHolds 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): boolean | Is 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 TransportPresence-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: boolean | false 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 Transportreadonly 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 VoiceAdapterJoins 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 MemoryHublatency: number | artificial one-way delay in ms (0 = delivered at once) |
loss: number | drop 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 Transportreadonly 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 ServiceReplication: 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): boolean | True 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): this | How 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): Replicated | Publish an entity this machine owns. |
replicateEvent(name: string, rule: EventRule): this | Share a world event: when it happens here, others get it; when it comes from others, apply does it here. |
send(name: string, data: unknown): void | A message of your own to everyone else (reliable). |
on(name: string, fn: (data: any, from: string) => void): () => void | Listen 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 ServiceReports 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 PresenceRecordStoreNamed 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: boolean | true 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 ComponentGives 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: string | the owner's id: the player who publishes it |
remote: boolean | |
netId: string | network id |
packages/net/src/net.ts:19
UnrealRecordStore class
class UnrealRecordStore implements FerretRecordStoreAdapts 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 Transportreadonly name: "websocket" | |
connected: boolean | false 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 ServiceZone 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(): ReplicatedFieldThe 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): voidShare 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): BodySnapshotRound 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 AccountGatewayThe 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 BodySnapshotWhat 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 CamerlengoOptionsThe 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: string | the backend, e.g. https://backend.squirrelwisdom.com/ |
appKey?: string | the 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 DataRoomThe 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 DialogueGatewaysay(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 EntityStateid: string | network id, unique in the game (the owner's id + the entity's key) |
kind: string | which factory makes the remote copy |
p: [number, number, number] | position |
r: number | yaw, 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 FerretRecordStoreA 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 GatewayOptionsenabled?: Partial<Record<GatewayName, boolean>> | each can be switched off whatever is supplied (configuration) |
timeout?: number | seconds before a call counts as failed (default 5) |
cooldown?: number | seconds a gateway that failed is left alone before the next try (default 15) |
now?: () => number |
packages/net/src/gateways.ts:40
GatewaySet interface
interface GatewaySetaccount?: AccountGateway | |
voice?: VoiceGateway | |
occupancy?: OccupancyGateway | |
dialogue?: DialogueGateway |
packages/net/src/gateways.ts:31
HttpPollOptions interface
interface HttpPollOptionsurl: string | the presence server's URL |
interval?: number | seconds between polls (default 0.5) |
eventRing?: number | how 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 LiveKitRoomLikeThe 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): unknown | livekit-client's event emitter; used to play other players' audio |
startAudio?(): Promise<unknown> |
packages/net/src/camerlengo.ts:79
NamedRecord interface
interface NamedRecordA shared record about one named thing (an NPC): the fields of the original game's presence-server record (FStudentData).
location: [number, number, number] | |
yaw: number | degrees |
model: string |
packages/net/src/records.ts:2
NetOptions interface
interface NetOptionsid?: string | this player's id (default: a random one) |
tickRate?: number | state messages per second (default 10) |
delay?: number | remote copies are shown this far in the past, seconds: room to interpolate between two updates (default 0.15) |
timeout?: number | seconds without news before a player counts as gone (default 6) |
now?: () => number | replace the clock (tests); seconds |
packages/net/src/net.ts:34
OccupancyGateway interface
interface OccupancyGatewayreport(occupancy: Record<string, number>): Promise<void> | zone hash -> number of players there |
packages/net/src/gateways.ts:21
PresenceRecordStoreOptions interface
interface PresenceRecordStoreOptionsurl: string | the presence server's URL (POST, JSON) |
stale?: number | seconds 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?: () => number | seconds, default Date.now() / 1000 |
packages/net/src/records.ts:9
ReplicatedField interface
interface ReplicatedFieldpackages/net/src/net.ts:4
ReplicatedParams interface
interface ReplicatedParamskind: string | which factory makes this entity's copy on other players' machines |
key?: string | a 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 SocketLikereadyState: 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 TransportA 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?(): void | transports that poll or batch do their work here, called every frame |
packages/net/src/transport.ts:27
VoiceAdapter interface
interface VoiceAdapterWhat 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 VoiceGatewayroomFor(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 WebSocketOptionsurl: string | wss://host/path ; the room is a query parameter the relay understands |
room?: string | |
create?: (url: string) => SocketLike | replace new WebSocket (tests) |
retry?: number | seconds between reconnect attempts (default 2; 0 = no reconnect) |
connectTimeout?: number | seconds 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 GatewaySetpackages/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