Add root .gitignore for Unity Library/Temp/Logs, IDE folders, and node_modules. Include Assets, Needle TypeScript (MenuController, asset picker, WebXR), and project configuration. Made-with: Cursor
412 lines
17 KiB
Markdown
412 lines
17 KiB
Markdown
# Needle Engine — Networking Reference
|
||
|
||
## Table of Contents
|
||
- [Core: context.connection](#core-thiscontextconnection)
|
||
- [Persistent vs ephemeral messages](#persistent-vs-ephemeral-messages-guid)
|
||
- [SyncedRoom](#syncedroom-convenience-component)
|
||
- [@syncField](#syncfield-auto-sync-fields)
|
||
- [SyncedTransform](#syncedtransform-sync-positionrotation)
|
||
- [PlayerSync + PlayerState](#playersync--playerstate-player-avatar-management)
|
||
- [Voip and ScreenCapture](#voice--video-voip-and-screencapture)
|
||
|
||
---
|
||
|
||
> Some APIs documented here (e.g. Voip volume/speaking detection, PlayerSync.setupFrom with Object3D) may require the latest pre-release version of `@needle-tools/engine`.
|
||
|
||
Needle Engine networking is layered. The lowest level is `this.context.connection` (WebSocket rooms + messages). Higher-level components build on it.
|
||
|
||
## Core: `this.context.connection`
|
||
```ts
|
||
// Connect and join a room — no SyncedRoom needed
|
||
this.context.connection.connect();
|
||
this.context.connection.joinRoom("my-room");
|
||
|
||
// Room state
|
||
this.context.connection.isConnected // boolean
|
||
this.context.connection.isInRoom // boolean
|
||
this.context.connection.connectionId // this client's ID
|
||
this.context.connection.usersInRoom() // all user IDs in current room
|
||
|
||
// Send and receive custom messages
|
||
this.context.connection.send("my-event", { score: 10, name: "Alice" });
|
||
this.context.connection.beginListen("my-event", (msg: { score: number; name: string }) => {
|
||
console.log(msg.score, msg.name);
|
||
});
|
||
this.context.connection.stopListen("my-event", handler); // always clean up in onDisable/onDestroy
|
||
|
||
// Room lifecycle events (import { RoomEvents } from "@needle-tools/engine")
|
||
this.context.connection.beginListen(RoomEvents.JoinedRoom, () => { ... });
|
||
this.context.connection.beginListen(RoomEvents.LeftRoom, () => { ... });
|
||
this.context.connection.beginListen(RoomEvents.UserJoinedRoom, (evt) => {
|
||
console.log("User joined:", evt.userId); // evt: { userId: string }
|
||
});
|
||
this.context.connection.beginListen(RoomEvents.UserLeftRoom, (evt) => {
|
||
console.log("User left:", evt.userId); // evt: { userId: string }
|
||
});
|
||
this.context.connection.beginListen(RoomEvents.RoomStateSent, () => { ... }); // all persisted state received
|
||
|
||
// Always check connection state before sending:
|
||
if (this.context.connection.isInRoom) {
|
||
this.context.connection.send("my-event", { data: 42 });
|
||
}
|
||
```
|
||
|
||
**Important:** `send()` broadcasts to all users in the room **except yourself** — you won't receive your own messages. Custom messages do NOT automatically include a sender ID. If you need to identify who sent a message (e.g. to map data to a specific player), include `connectionId` yourself:
|
||
```ts
|
||
this.context.connection.send("player-score", {
|
||
senderId: this.context.connection.connectionId,
|
||
score: 42,
|
||
});
|
||
|
||
this.context.connection.beginListen("player-score", (msg) => {
|
||
// msg.senderId tells you which player sent this
|
||
playerScores.set(msg.senderId, msg.score);
|
||
});
|
||
```
|
||
|
||
`userId` is only available in room lifecycle events (`UserJoinedRoom`, `UserLeftRoom`), not in custom messages.
|
||
|
||
---
|
||
|
||
## Networked Instantiation and Destruction
|
||
|
||
For spawning objects that should appear on all clients, use `syncInstantiate` / `instantiateSynced` instead of manually sending custom events.
|
||
|
||
```ts
|
||
import { instantiate, syncInstantiate, syncDestroy, registerPrefabProvider } from "@needle-tools/engine";
|
||
|
||
// Local clone (only on this client)
|
||
const clone = instantiate(prefabObject, { parent: this.gameObject });
|
||
|
||
// Networked spawn (appears on all connected clients)
|
||
const networked = syncInstantiate(prefabObject, {
|
||
parent: this.gameObject,
|
||
position: [x, y, z],
|
||
deleteOnDisconnect: true, // removed when the spawning user disconnects
|
||
});
|
||
|
||
// Persistent networked spawn (survives disconnects, replayed to late joiners)
|
||
const persistent = syncInstantiate(prefabObject, {
|
||
parent: this.gameObject,
|
||
deleteOnDisconnect: false,
|
||
});
|
||
|
||
// Via AssetReference
|
||
const synced = await myAssetRef.instantiateSynced({
|
||
parent: this.gameObject,
|
||
deleteOnDisconnect: false,
|
||
});
|
||
|
||
// Networked destroy (removed on all clients)
|
||
syncDestroy(obj, this.context.connection, true);
|
||
|
||
// Listen for remote syncInstantiate events (get references to objects spawned by other users)
|
||
import { onSyncInstantiate } from "@needle-tools/engine";
|
||
const unsub = onSyncInstantiate((instance, model) => {
|
||
console.log("Remote object created:", instance.name, model.originalGuid);
|
||
});
|
||
// later: unsub();
|
||
```
|
||
|
||
### Runtime prefabs with `registerPrefabProvider`
|
||
For runtime-created prefabs (not loaded from GLB), **every client** must register the prefab in their setup code — not just the client that calls `syncInstantiate`. This is because late joiners receive state replay and need to resolve the prefab by guid locally.
|
||
|
||
```ts
|
||
import { registerPrefabProvider, ObjectUtils, syncInstantiate } from "@needle-tools/engine";
|
||
|
||
// ALL clients run this setup code:
|
||
const cookiePrefab = ObjectUtils.createPrimitive("Cube", { color: 0xff8c00 });
|
||
cookiePrefab.guid = "cookie-prefab";
|
||
registerPrefabProvider("cookie-prefab", async () => cookiePrefab);
|
||
|
||
// Only the first player calls syncInstantiate — late joiners get state replay
|
||
syncInstantiate(cookiePrefab, { parent: ctx.scene, deleteOnDisconnect: false });
|
||
```
|
||
|
||
Note: `syncInstantiate` auto-registers the prefab on the calling client, but **remote clients and late joiners** still need the explicit `registerPrefabProvider` call in their setup code.
|
||
|
||
### PlayerSync with runtime-created avatars (no GLB)
|
||
Since Needle Engine 5.0.1, `PlayerSync.setupFrom` accepts an Object3D directly — no GLB URL needed:
|
||
```ts
|
||
import { PlayerSync, PlayerState, ObjectUtils } from "@needle-tools/engine";
|
||
|
||
// Create your avatar template
|
||
const avatarPrefab = ObjectUtils.createPrimitive("Sphere", { color: 0x4488ff });
|
||
|
||
// Pass the Object3D directly — PlayerState is added automatically
|
||
const ps = await PlayerSync.setupFrom(avatarPrefab, { guid: "player-avatar" });
|
||
ctx.scene.add(ps.gameObject);
|
||
|
||
// onPlayerSpawned fires for both local and remote players
|
||
ps.onPlayerSpawned?.addEventListener((playerObj) => {
|
||
// playerObj is the spawned avatar Object3D
|
||
// Use PlayerState.isLocalPlayer(playerObj) to check if it's yours
|
||
});
|
||
```
|
||
|
||
For older versions, use `registerPrefabProvider` with a URL key:
|
||
```ts
|
||
import { PlayerSync, PlayerState, registerPrefabProvider, ObjectUtils, GameObject } from "@needle-tools/engine";
|
||
|
||
const avatarPrefab = ObjectUtils.createPrimitive("Sphere", { color: 0x4488ff });
|
||
GameObject.addComponent(avatarPrefab, PlayerState);
|
||
|
||
const avatarKey = "runtime://player-avatar";
|
||
registerPrefabProvider(avatarKey, async () => avatarPrefab);
|
||
const ps = await PlayerSync.setupFrom(avatarKey);
|
||
ctx.scene.add(ps.gameObject);
|
||
```
|
||
|
||
### World-building pattern (first player seeds, late joiners receive)
|
||
```ts
|
||
let shouldBuildWorld = false;
|
||
|
||
connection.beginListen(RoomEvents.JoinedRoom, () => {
|
||
const inRoom = connection.usersInRoom();
|
||
shouldBuildWorld = inRoom.length === 1; // I'm the only one here
|
||
});
|
||
|
||
connection.beginListen(RoomEvents.RoomStateSent, () => {
|
||
// State replay complete — only build if we're first AND no objects exist yet
|
||
if (!shouldBuildWorld) return;
|
||
|
||
for (let i = 0; i < 100; i++) {
|
||
syncInstantiate(cookiePrefab, {
|
||
parent: ctx.scene,
|
||
position: [x, 0, z],
|
||
deleteOnDisconnect: false, // persists for late joiners
|
||
});
|
||
}
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Persistent vs ephemeral messages (guid)
|
||
When a message's `data` contains a `guid` field, the server stores it as room state. New users joining later receive all stored state via `RoomStateSent`. Messages without a `guid` are fire-and-forget — only currently connected users see them.
|
||
|
||
```ts
|
||
// Ephemeral — only users currently in the room receive this
|
||
this.context.connection.send("chat", { text: "hello", sender: "Alice" });
|
||
|
||
// Persistent — server stores this by guid; late joiners get it automatically
|
||
this.context.connection.send("object-color", { guid: this.guid, color: "#ff0000" });
|
||
|
||
// Read cached state for a guid (received from server or local sends)
|
||
const state = this.context.connection.tryGetState(this.guid);
|
||
|
||
// Delete persisted state (removes from server so new joiners won't get it)
|
||
this.context.connection.sendDeleteRemoteState(this.guid);
|
||
|
||
// Delete ALL room state (use with caution)
|
||
this.context.connection.sendDeleteRemoteStateAll();
|
||
```
|
||
|
||
Any JSON message with a `guid` can also include these optional fields:
|
||
```ts
|
||
this.context.connection.send("my-state", {
|
||
guid: this.guid, // persists on server
|
||
dontSave: false, // set true to prevent server storage (ephemeral but with guid for identity)
|
||
deleteOnDisconnect: true, // auto-delete when sender disconnects
|
||
// ...your data
|
||
});
|
||
```
|
||
|
||
This is how `@syncField()` and `SyncedTransform` work under the hood — they send messages with the component's `guid`, so state persists for late joiners. Understanding this lets you build custom networking that also persists correctly.
|
||
|
||
---
|
||
|
||
## SyncedRoom (convenience component)
|
||
Wraps `context.connection` with auto-join, URL params, random rooms, auto-reconnect, and a join/leave menu button. Add to any object — no code needed for basic room management.
|
||
```ts
|
||
import { SyncedRoom } from "@needle-tools/engine";
|
||
|
||
// Add at runtime:
|
||
myObject.addComponent(SyncedRoom, { roomName: "my-room" });
|
||
// or join a random room:
|
||
myObject.addComponent(SyncedRoom, { joinRandomRoom: true });
|
||
// or with a prefix (useful for multiple apps on same server):
|
||
myObject.addComponent(SyncedRoom, { joinRandomRoom: true, roomPrefix: "myApp_" });
|
||
```
|
||
|
||
Key properties:
|
||
| Property | Default | Description |
|
||
|---|---|---|
|
||
| `roomName` | `""` | Room to join |
|
||
| `urlParameterName` | `"room"` | URL param for room name (`?room=xyz`) |
|
||
| `joinRandomRoom` | `undefined` | Join random room if no name set |
|
||
| `autoRejoin` | `true` | Auto-reconnect on disconnect |
|
||
| `requireRoomParameter` | `false` | Only join if URL has room param |
|
||
| `createJoinButton` | `true` | Show join/leave button in menu |
|
||
|
||
---
|
||
|
||
## @syncField (auto-sync fields)
|
||
```ts
|
||
@syncField() score: number = 0; // auto-syncs on reassignment
|
||
|
||
// With change callback:
|
||
@syncField(MyClass.prototype.onHealthChange)
|
||
health: number = 100;
|
||
|
||
private onHealthChange(newVal: number, oldVal: number) {
|
||
console.log(`Health changed: ${oldVal} → ${newVal}`);
|
||
}
|
||
|
||
// Complex types — must reassign to trigger sync:
|
||
@syncField() items: string[] = [];
|
||
this.items.push("sword");
|
||
this.items = this.items; // ← triggers sync
|
||
```
|
||
|
||
---
|
||
|
||
## SyncedTransform (sync position/rotation)
|
||
Syncs an object's position, rotation, and scale across clients. Ownership is automatic — when a user interacts (e.g. via DragControls), they take ownership. **Always prefer `SyncedTransform` over manually sending position via custom events** — it handles interpolation, ownership, and late-joiner state automatically.
|
||
|
||
```ts
|
||
import { SyncedTransform } from "@needle-tools/engine";
|
||
|
||
// Add to ANY Object3D — cameras, player objects, scene objects, anything
|
||
myObject.addComponent(SyncedTransform);
|
||
|
||
// IMPORTANT: SyncedTransform only sends updates if you have ownership.
|
||
// Request ownership before modifying the transform:
|
||
const sync = myObject.getComponent(SyncedTransform);
|
||
sync?.requestOwnership(); // fire-and-forget (ownership arrives async ~100ms)
|
||
myObject.worldPosition = newPos; // may not broadcast immediately — ownership is async
|
||
|
||
// For interactive objects (e.g. DragControls), ownership is taken automatically on interaction.
|
||
```
|
||
|
||
**Critical: add SyncedTransform to the prefab BEFORE networking, not after spawning.**
|
||
`SyncedTransform` uses its component guid to match state across clients. When added via `addComponent` independently on each client, each gets a random guid — they'll never match. Add it to the prefab before `syncInstantiate` or `PlayerSync.setupFrom` so the seeded `InstantiateIdProvider` generates matching deterministic guids on all clients.
|
||
|
||
```ts
|
||
// CORRECT — add SyncedTransform to the prefab before networking
|
||
const avatarPrefab = ObjectUtils.createPrimitive("Sphere");
|
||
avatarPrefab.guid = "player-avatar";
|
||
avatarPrefab.addComponent(SyncedTransform); // part of the prefab — guid will be deterministic
|
||
|
||
const ps = await PlayerSync.setupFrom(avatarPrefab);
|
||
ctx.scene.add(ps.gameObject);
|
||
|
||
// onPlayerSpawned only fires for the LOCAL player's avatar.
|
||
// To detect ALL players (local + remote), use PlayerState.OwnerChanged:
|
||
PlayerState.addEventListener(PlayerStateEvent.OwnerChanged, (evt) => {
|
||
const { playerState } = evt.detail;
|
||
const avatar = playerState.gameObject;
|
||
if (playerState.isLocalPlayer) {
|
||
avatar.visible = false; // hide own avatar (we see through the camera)
|
||
avatar.getComponent(SyncedTransform)?.requestOwnership();
|
||
} else {
|
||
// Remote player — color/customize their avatar
|
||
}
|
||
});
|
||
|
||
// WRONG — adding SyncedTransform after spawn gives each client a random component guid
|
||
// avatar.addComponent(SyncedTransform); // DON'T DO THIS — guids won't match
|
||
```
|
||
|
||
**Timing:** Set up `PlayerSync` (add to scene) **before** `SyncedRoom` connects. If `SyncedRoom` joins a room before `PlayerSync` is enabled, the join events fire before `PlayerSync` is listening — `onPlayerSpawned` will never be called. Either add `PlayerSync` to the scene first, or set up `SyncedRoom` after `PlayerSync` is ready.
|
||
|
||
Do NOT manually replicate position with `connection.send("player-position", { x, y, z })` — use `SyncedTransform` instead. It uses efficient binary messages (flatbuffers) rather than JSON, making it much faster for high-frequency transform updates. Custom events are for gameplay data (scores, actions, chat), not for transform replication.
|
||
|
||
---
|
||
|
||
## PlayerSync + PlayerState (player avatar management)
|
||
`PlayerSync` instantiates a prefab for each player joining a room and destroys it on leave. The prefab must have a `PlayerState` component. This is the recommended approach for multiplayer player objects and WebXR avatars.
|
||
|
||
```ts
|
||
import { PlayerSync, PlayerState } from "@needle-tools/engine";
|
||
|
||
// Runtime setup — load a GLB as the player prefab:
|
||
const ps = await PlayerSync.setupFrom("assets/avatar.glb");
|
||
scene.add(ps.gameObject);
|
||
// The GLB should have a PlayerState component. setupFrom() adds one if missing.
|
||
|
||
// Events:
|
||
ps.onPlayerSpawned // EventList<Object3D> — fires when any player instance spawns
|
||
```
|
||
|
||
**PlayerState** — attached to each spawned player instance:
|
||
```ts
|
||
// Static helpers:
|
||
PlayerState.isLocalPlayer(obj) // true if obj belongs to this client
|
||
PlayerState.all // all PlayerState instances in the scene
|
||
PlayerState.local // only local player's PlayerState instances
|
||
PlayerState.getFor(obj) // find PlayerState for an Object3D or Component
|
||
|
||
// Instance:
|
||
state.isLocalPlayer // boolean
|
||
state.owner // connection ID of the owning player
|
||
|
||
// Events:
|
||
PlayerState.addEventListener(PlayerStateEvent.OwnerChanged, (evt) => {
|
||
// evt.detail: { playerState, oldValue, newValue }
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Voice & Video: Voip and ScreenCapture
|
||
Both require an active networked room and HTTPS.
|
||
|
||
```ts
|
||
import { Voip, ScreenCapture } from "@needle-tools/engine";
|
||
|
||
// Voice chat — auto-connects when joining a room
|
||
const voip = myObject.addComponent(Voip, { autoConnect: true, createMenuButton: true });
|
||
voip.connect(); // manual start
|
||
voip.disconnect(); // manual stop
|
||
voip.setMuted(true); // mute mic
|
||
voip.volume = 0.5; // set incoming audio volume (0–1)
|
||
|
||
// Access raw audio elements for spatial audio / Web Audio API routing
|
||
const audioEl = voip.getAudioElement(userId);
|
||
if (audioEl) {
|
||
const audioCtx = new AudioContext();
|
||
const source = audioCtx.createMediaElementSource(audioEl);
|
||
const panner = audioCtx.createPanner();
|
||
source.connect(panner).connect(audioCtx.destination);
|
||
}
|
||
|
||
// Speaking detection — fires when a user starts/stops speaking
|
||
voip.onSpeakingChanged.addEventListener((evt) => {
|
||
console.log(evt.userId, evt.isSpeaking, evt.volume); // volume: 0–1
|
||
});
|
||
voip.speakingThreshold = 30; // amplitude threshold (0–255, default 30)
|
||
|
||
// Iterate all incoming streams
|
||
for (const [userId, audioEl] of voip.incomingStreams) { /* ... */ }
|
||
|
||
// Screen/camera/microphone sharing
|
||
const sc = myObject.addComponent(ScreenCapture);
|
||
sc.share({ device: "Screen" }); // "Screen", "Camera", "Microphone", "Canvas"
|
||
sc.close(); // stop sharing
|
||
// Receiving clients see the video on a VideoPlayer component on the same object
|
||
```
|
||
|
||
| Voip property | Default | Description |
|
||
|---|---|---|
|
||
| `autoConnect` | `true` | Start when joining a room |
|
||
| `runInBackground` | `true` | Stay connected when tab loses focus |
|
||
| `createMenuButton` | `true` | Show mute/unmute button in menu |
|
||
| `volume` | `1` | Incoming audio volume (0–1, applies to all streams) |
|
||
| `speakingThreshold` | `30` | Amplitude threshold for speaking detection (0–255) |
|
||
|
||
---
|
||
|
||
### Syncing Animations
|
||
For syncing Animator state across clients, see the [SyncedAnimator sample](https://github.com/needle-tools/needle-engine-samples/blob/main/package/Runtime/Networking/Scripts/Networking~/Animator/SyncedAnimator.ts) — it listens for Animator parameter changes and broadcasts them via `context.connection`.
|
||
|
||
---
|
||
|
||
## Typical multiplayer setup
|
||
1. Add `SyncedRoom` to an object (or call `context.connection.joinRoom()` manually)
|
||
2. For player avatars: add `PlayerSync` with a prefab that has `PlayerState`
|
||
3. For synced objects: add `SyncedTransform` to movable objects
|
||
4. For custom state: use `@syncField()` on component properties
|
||
5. For custom events: use `context.connection.send()` / `beginListen()`
|
||
6. For voice chat: add `Voip` — for screen sharing: add `ScreenCapture`
|