| name | scene-runtime-and-crdt |
| description | Scene runtime internals — V8/ClearScript engine, CRDT protocol, JS module system, scene lifecycle, and threading. Use when implementing or modifying scene runtime modules (require/wrapper/API pattern), working with CRDTProtocol or CRDTWorldSynchronizer, handling MultiThreadSync for ECS access, debugging scene state transitions, or implementing new JS API modules. |
| user-invocable | false |
Scene Runtime & CRDT
Sources
docs/scene-runtime.md -- SDK7 execution via ClearScript/V8, CRDT bridge, JS modules
docs/architecture-overview.md -- Worlds, threading, ECS-async marriage
Scene Lifecycle State Machine
File: Explorer/Assets/DCL/Infrastructure/SceneRunner/Scene/SceneState.cs
public enum SceneState : byte
{
NotStarted,
Running,
EngineError,
EcsError,
JavaScriptError,
Disposing,
Disposed,
}
Transitions:
NotStarted -> Running via SceneFacade.StartUpdateLoopAsync (calls SetRunning)
Running -> EngineError / EcsError / JavaScriptError via SceneExceptionsHandler when tolerance exceeded
Running -> Disposing -> Disposed via SceneFacade.DisposeAsync
ISceneStateProvider exposes Atomic<SceneState> State, TickNumber, IsCurrent, and EngineStartInfo. The Atomic<T> wrapper provides thread-safe reads/writes since state is mutated from background threads.
IsNotRunningState() returns true for Disposing, Disposed, JavaScriptError, or EngineError -- used as a guard to break the update loop.
Threading Model (CRITICAL)
See docs/scene-runtime.md -- "the scene lifecycle is thread agnostic and executes in a separate thread."
No thread affinity. Each scene runs on the thread pool. After every await, the continuation may resume on a different thread. You cannot assume thread consistency.
MultiThreadSync -- Mutex for ECS Access
File: Explorer/Assets/DCL/PerformanceAndDiagnostics/Optimization/Multithreading/MultiThreadSync.cs
Arch ECS is not thread-safe. All ECS reads and writes must be serialized through MultiThreadSync:
using MultiThreadSync.Scope mutex = multiThreadSync.GetScope(syncOwner);
GetScope(Owner) blocks until the queue grants access (10s timeout)
Owner is a named ManualResetEventSlim-based waiter -- create one per logical caller
- The scope is a
readonly struct implementing IDisposable -- release is automatic
SyncedGroup -- Automatic System Synchronization
File: Explorer/Assets/DCL/Infrastructure/ECS/Groups/SyncedGroup.cs
Systems in scene worlds use SyncedGroup subclasses (SyncedSimulationSystemGroup, etc.) that guard Update/BeforeUpdate/AfterUpdate behind a SceneState.Running check. This prevents systems from running during disposal:
public override void Update(in float t, bool throttle)
{
if (sceneStateProvider.State != SceneState.Running)
return;
UpdateInternal(in t, throttle);
}
When Explicit Mutex Is Needed
SyncedGroup handles synchronization for the normal ECS update loop -- no manual action needed
- Use
MultiThreadSync.GetScope() explicitly for ECS access outside the update loop (e.g., async flows in LoadSystemBase, EngineAPIImplementation.ApplySyncCommandBuffer)
Module Implementation Pattern
See docs/scene-runtime.md -- "How to Implement a Module"
Each JS API module follows a 4-file pattern:
1. Interface
Defines the C# contract. Example: IEngineApi
2. Implementation
Core logic, thread-agnostic. Example: EngineAPIImplementation -- receives CRDT bytes, deserializes, reconciles, syncs to ECS, serializes outgoing messages.
3. JsApiWrapper
Bridges JS calls to C#. Extends JsApiWrapper<TApi> which holds the API impl and a CancellationTokenSource for disposal. The wrapper catches exceptions and routes them through ISceneExceptionsHandler:
public class EngineApiWrapper : JsApiWrapper<IEngineApi>
{
public PoolableByteArray CrdtSendToRenderer(ITypedArray<byte> data)
{
if (disposeCts.IsCancellationRequested)
return PoolableByteArray.EMPTY;
try
{
instancePoolsProvider.RenewCrdtRawDataPoolFromScriptArray(data, ref lastInput);
return api.CrdtSendToRenderer(lastInput.Memory);
}
catch (Exception e)
{
if (!disposeCts.IsCancellationRequested)
exceptionsHandler.OnEngineException(e);
return PoolableByteArray.EMPTY;
}
}
}
4. JS Module + Registration
- JS module in
Explorer/Assets/StreamingAssets/Js/Modules/EngineApi.js
- Compiled modules loaded via
SceneModuleHub.LoadAndCompileJsModules
- Wrappers registered via
SceneRuntimeImpl.Register<T>(name, wrapper) and added as host objects
- Full module wiring done in
SceneFactory.CreateSceneAsync which calls sceneRuntime.RegisterAll(...)
CRDT Protocol Internals
File: Explorer/Assets/DCL/Infrastructure/CRDT/Protocol/CRDTProtocol.cs
LWW vs GOVS Processing
ProcessMessage dispatches by CRDTMessageType:
- LWW (
PUT_COMPONENT, DELETE_COMPONENT, AUTHORITATIVE_PUT_COMPONENT): Timestamp comparison. Higher timestamp wins. On tie, byte-level data comparison breaks it. Returns CRDTReconciliationResult with effect (ComponentAdded, ComponentModified, ComponentDeleted, NoChanges).
- GOVS (
APPEND_COMPONENT): Accumulates values in a sorted list per entity+component. Binary search prevents duplicates. Capped at 100 entries per entity-component pair.
DELETE_ENTITY: Removes all LWW and APPEND data for the entity, tracks version in deletedEntities.
Zero-Allocation Design
- State stored in
PooledDictionary / PooledList backed by ArrayPool<T>.Shared
IMemoryOwner<byte> for message data -- disposed returns buffer to pool
- Deserialization via
ReadOnlyMemory<byte> advanced forward (no copies)
ByteUtils for unsafe memory slicing (reinterpret_cast-style)
State Structure
internal struct State
{
internal readonly PooledDictionary<int, int> deletedEntities;
internal readonly PooledDictionary<int, PooledDictionary<CRDTEntity, EntityComponentData>> lwwComponents;
internal readonly PooledDictionary<int, PooledDictionary<CRDTEntity, PooledList<EntityComponentData>>> appendComponents;
internal int messagesCount;
}
ECS-CRDT Bridge
Inbound: Scene -> ECS
File: Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/EngineAPIImplementation.cs
The flow in CrdtSendToRenderer (called from background thread):
- Deserialize --
crdtDeserializer.DeserializeBatch into List<CRDTMessage>
- Reconcile -- Each message through
crdtProtocol.ProcessMessage
- Buffer --
worldSyncBuffer.SyncCRDTMessage prepares ECS mutations
- Apply --
ApplySyncCommandBuffer acquires MultiThreadSync scope, then crdtWorldSynchronizer.ApplySyncCommandBuffer writes to ECS World
- Respond --
SerializeOutgoingCRDTMessages returns buffered outgoing data
ICRDTWorldSynchronizer
File: Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/WorldSynchronizer/CrdtEcsSynchronizer.cs
GetSyncCommandBuffer() -- Rents a command buffer (semaphore-guarded, one at a time)
ApplySyncCommandBuffer(buffer) -- Applies mutations to World, releases semaphore
- Uses
SemaphoreSlim (not Mutex) because acquire/release may happen on different threads
Outbound: ECS -> Scene
Systems write to IECSToCRDTWriter (PutMessage, AppendMessage, DeleteMessage). These are collected by IOutgoingCRDTMessagesProvider and serialized back to the scene in EngineAPIImplementation.SerializeOutgoingCRDTMessages. Outgoing LWW messages are also synced into the local CRDTProtocol state via EnforceLWWState to keep timestamps correct.
Gotchas
getMutableOrNull marks the component dirty. In scene-side TypeScript (SDK), calling getMutableOrNull on a component marks it dirty in the CRDT system, which sets IsDirty = true on the Explorer side. Never use getMutableOrNull for read-only access (e.g., reading values to render a UI panel). Use getOrNull instead. Calling getMutableOrNull every frame in a UI render function causes the component to be permanently dirty, triggering full property re-application and side effects like re-triggering bursts, restarting animations, or redundant material operations.
- V8 engines are not poolable. Each scene creates a new
V8ScriptEngine; when disposed, the engine is gone. This creates GC pressure but ScriptEngine is not reusable. ClearScript handles unmanaged cleanup.
- No thread affinity. After any
await in scene code, the thread may change. Never cache Thread.CurrentThread or use thread-local storage.
- API implementations must be thread-agnostic. Shared resources must be thread-safe. This applies to all
JsApiWrapper<T> implementations.
PoolableByteArray must be disposed. It wraps a pooled byte[] with a release callback. Failing to dispose leaks the rented array. Use ReleaseAndDispose() for wrapper cleanup.
SceneExceptionsHandler has per-minute tolerance. JS errors (30/min) and engine errors (3/min) before the scene is suspended. Exceptions are tracked with sliding-window timestamps.
Atomic<SceneState> is required because state transitions happen from background threads. Always use State.Set() / State.Value(), never direct assignment.
Cross-References
- sdk-component-implementation -- Outgoing CRDT via
IECSToCRDTWriter, component registration in ComponentsContainer
- async-programming -- Cancellation token patterns,
SuppressToResultAsync for exception-free flows
- diagnostics-and-logging --
SceneExceptionsHandler tolerance, ReportHub.LogException for scene errors, ReportCategory.CRDT / CRDT_ECS_BRIDGE / JAVASCRIPT