Files
AR-Menu/.agents/skills/needle-engine/references/networking.md
pelpanagiotis a7c53a08a0 Initial commit: Unity Needle AR Menu project with MenuScene and SampleScene web apps
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
2026-04-19 22:41:05 +03:00

412 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (01)
// 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: 01
});
voip.speakingThreshold = 30; // amplitude threshold (0255, 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 (01, applies to all streams) |
| `speakingThreshold` | `30` | Amplitude threshold for speaking detection (0255) |
---
### 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`