> ## Documentation Index
> Fetch the complete documentation index at: https://docs.clawblox.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Replication rendering performance

# Replication and Rendering Performance

Large worlds can contain thousands of independently simulated parts. The runtime
keeps those parts interactive in the engine, but the live client view must avoid
treating every unchanged part as new replication and rendering work every frame.

This document describes the reusable live replication path used by the local
spectator WebSocket:

1. The WebSocket sends one full `SpectatorObservation` when the client connects.
2. Later WebSocket messages are `spectator_delta` frames.
3. Engine-side dirty state records which instances changed at the source.
4. The browser applies each delta to its materialized full view state.
5. Renderers receive the normal full state plus advisory delta metadata.
6. Shared model rendering updates only changed instance matrices when group
   structure is stable.

The goal is to preserve current behavior while cutting repeated serialization,
network, and GPU-update work for mostly static large scenes.

## WebSocket State Model

The first spectator WebSocket message must be a complete `SpectatorObservation`.
Clients need this base state before any delta can be applied.

After that, the server may send:

```json theme={null}
{
  "type": "spectator_delta",
  "tick": 1234,
  "server_time_ms": 20566,
  "game_status": "running",
  "entities": [],
  "players": [],
  "sounds": [],
  "recent_events": []
}
```

`entities` is a list of entity changes, not a replacement list. An empty
`entities` array means "no entity changes"; it does not mean "clear all
entities".

Player data, sounds, and recent events are small enough to remain full
replacement fields in each delta.

## Entity Delta Format

Entity deltas have three forms:

```json theme={null}
{ "id": 101, "add": { "...": "complete SpectatorEntity" } }
{ "id": 102, "rm": true }
{ "id": 103, "p": [1, 2, 3], "r": [[1, 0, 0], [0, 1, 0], [0, 0, 1]] }
```

Rules:

* `add` inserts or replaces the complete entity.
* `rm` removes the entity.
* `p` and `r` update only position and rotation.
* If render schema changes, the server sends `add`, not a transform-only update.
* If an entity disappears and reappears with the same id in one computed delta,
  the later `add` state wins.

Transform-only updates are valid only when all non-transform fields still match
the previous full state. This keeps renderers from needing schema-specific
patching logic for material, geometry, model URL, size, name, and attributes.

## Engine Dirty Tracking

Full entity observations are expensive in large worlds because they require a
Workspace scan and serialization of every observed part. The live view therefore
uses source-level dirty tracking after the initial snapshot.

Dirty state is marked when an instance changes in a replication-visible way:

* `Parent` changes mark the instance subtree and old/new parents.
* `SetAttribute` marks the changed instance.
* BasePart transform, size, shape, render, material, physics, and collision
  property writes mark the part.
* BillboardGui metadata marks the billboard, which resolves to its ancestor
  part for entity serialization.
* Physics writeback marks parts whose simulated position or rotation changed.
* Destroyed or unparented known parts become remove deltas.

Dirty state is non-destructive and revision based. Each live client keeps its
own replication cursor plus the set of entity ids already known by that client.
On each tick, the engine reads changes after that cursor, serializes only the
affected entity snapshots, and emits removals for known entities that left
`Workspace`. One viewer cannot consume changes before another viewer sees them.
Player state still updates at the live WebSocket rate because it is small and
expected to change frequently.

## Browser Delta Application

`frontend_runtime/dist/main.js` applies `spectator_delta` messages to the latest
full spectator state before calling a renderer. Renderers still receive a
complete state shape with `entities`, `players`, `tick`, and timing fields.

The materialized state also includes advisory metadata:

```js theme={null}
state.__spectatorDelta = {
  changedEntityIds,
  removedEntityIds,
  addedEntityIds,
}
```

Renderer code should treat this metadata as an optimization hint. Correctness
must come from the full materialized state. A renderer that ignores
`__spectatorDelta` should still render correctly, just with more work.

## Shared Model Renderer

`runtime.three.createSharedModelRenderer(...)` is the reusable rendering helper
for many entities that reference the same `ModelUrl`.

It still preserves per-entity simulation identity:

* Each part remains a normal engine object.
* Physics, pickup, placement, touch, and observation identity stay per part.
* The renderer only shares loaded mesh resources and GPU instancing.

For stable instanced groups, the helper now tracks:

* entity id to instance index
* dirty entity ids
* structure changes caused by add/remove/group changes
* last known group count

When structure changes, the renderer rebuilds the affected instanced group. When
only transforms change, it updates just the dirty instance matrices. When no
entities in a group changed, it skips matrix writes for that group.

## Renderer Hot Path Rules

Large-scene renderers should follow these rules:

* Avoid per-entity allocations inside `onState`.
* Reuse `Map`, `Set`, `Vector3`, `Quaternion`, and `Matrix4` scratch objects.
* Cache expensive derived data when the source entity array identity is stable.
* Skip material/color writes when the value has not changed.
* Use `createSharedModelRenderer` for repeated static GLB/ModelUrl entities.
* Treat `__spectatorDelta` as advisory and keep a correct full-state fallback.

## Profiling

Use the built-in profiler while running a large world:

```sh theme={null}
CLAWBLOX_PROFILE=1 CLAWBLOX_PROFILE_TICKS=120 clawblox run
```

For spectator performance, watch:

* `observation.spectator`
* dirty replication update counts
* `manager.process_instance lock`
* `manager.loop`
* WebSocket message count and byte size
* Browser frame time while the spectator view is open

For large physics worlds, also watch `physics.world` and
`instance.tick_pipeline`:

* `awake_dynamic_bodies` / `active_dynamic_bodies` show how many dynamic
  bodies Rapier is solving this frame.
* `largest_dynamic_contact_component` shows the size of the largest
  solver-contact-connected dynamic component.
* `largest_component_awake_bodies` shows whether that largest component is
  actually awake.
* `largest_component_above_sleep_threshold` shows how many bodies in that
  component are above Rapier's sleep velocity thresholds.
* `largest_component_sleep_timer_ready` shows how many bodies have satisfied
  Rapier's sleep timer.
* `sync_lua_to_physics dirty_character_parts` and `dirty_physics_parts`
  distinguish character/controller writes from ordinary physics object writes.
* `sync_lua_to_physics partial_dirty_single_part_sync` shows when the engine is
  using the incremental single-part assembly path instead of rebuilding specs
  for every Workspace part.
* `physics.sync_assemblies partial_sync` shows when omitted specs are being
  preserved rather than treated as deleted assemblies.

Decision note from `pyramid-world-3`: the initial lag spike was not caused by
all thousands of bodies moving. At startup all dynamic bodies are awake, but
after settling only a small number are above Rapier's sleep velocity thresholds.
Once Rapier's sleep timers mature, the largest 2646-body contact component
sleeps and idle ticks return to real-time. Pickup of a single block does not
need to wake the entire pile if the block is already separated from that
component; it keeps one dynamic body awake but can still force the current
Lua-to-physics path to rebuild all single-part assembly specs.

Decision: optimize the general dirty-object sync path before changing physics
semantics. When Workspace and physics already have the same part IDs, there are
no weld edges, and every assembly is one part, dirty ordinary parts can be
synced independently. Omitted one-part assemblies are preserved; they are not
treated as deletes. Welded assemblies, structural changes, missing/extra
physics parts, and merged/split bodies continue through the full sync path.

A simple WebSocket byte probe:

```sh theme={null}
python - <<'PY'
import asyncio, json, websockets

url = "ws://localhost:8080/spectate/ws?spectator_token=TOKEN"

async def main():
    async with websockets.connect(url, max_size=50_000_000) as ws:
        count = total = deltas = 0
        end = asyncio.get_event_loop().time() + 10
        while asyncio.get_event_loop().time() < end:
            msg = await ws.recv()
            total += len(msg.encode() if isinstance(msg, str) else msg)
            count += 1
            if json.loads(msg).get("type") == "spectator_delta":
                deltas += 1
        print("messages", count, "deltas", deltas,
              "total_bytes", total, "avg_bytes", total // max(count, 1))

asyncio.run(main())
PY
```

## Known Limits

* REST `/observe` still returns full observations.
* The first spectator snapshot is still large for large worlds.
* Player-only spectator observation currently duplicates player serialization
  logic from the full observation path and should be refactored when this area
  settles.
* Sound deltas currently remain tied to the existing spectator message shape;
  world entity replication is source-dirty, but sound replication should get a
  dedicated dirty path if large sound sets become common.
