Control plane contract
Integrate with the control plane the orchestrator hosts. HTTP endpoints, the JSON document, write operations, lease transitions, and connection behavior.
Use this specification to integrate with Nebula's control plane or read it from your own tools. The control plane stores process registrations, container leases, and low-frequency game settings for one mesh. It does not carry per-tick entity state or saved entities.
The words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY describe interoperability requirements. The source of truth is Packages/com.1by3.nebula/Runtime/ControlPlane/: LocalControlPlane.cs (the state machine), ControlPlaneJson.cs (the document and the writes), ControlPlaneHost.cs (the endpoints), and RemoteControlPlane.cs (the reference client).
Compatibility and scope
The orchestrator hosts the control plane on its dashboard HTTP server. The document format has no negotiated version. Run the gateway, orchestrator, and workers from the same Nebula release.
Every write is applied on the orchestrator's main thread, one at a time, in the order received; a request's writes are applied as a group before the next request's. Reads are answered from the last published document without waiting on the main thread.
This contract excludes saved entities. The persistence store the orchestrator serves at /api/store/ is described in Persistence.
Authorization is one shared token
When the mesh has a token, every request MUST carry it in the X-Nebula-Token header and the orchestrator answers 401 otherwise. Without a token any caller that can reach the dashboard port can register processes, change leases, reset the control plane, or rewrite settings. Restrict access with deployment infrastructure. The dashboard's own endpoints never require the token.
Endpoints
| Method and path | Purpose |
|---|---|
GET /api/control-plane?since=<version>&wait=<seconds> | Read the document. Waits up to wait seconds (at most 30) for a version above since (default -1), then answers with the current document either way. |
POST /api/control-plane | Apply writes. Body {"ops":[...]}. Answers {"ok":true,"version":N} with the version after the last write, 400 with {"ok":false,"error":"..."} when the body is malformed or names an unknown op (nothing is applied then), 401 for a missing or wrong token. |
Both answer application/json. A reader SHOULD keep one GET open at a time and pass the version of the last document it received, so it is told about a change as soon as it happens and costs the orchestrator one response per ten seconds otherwise.
Scalar types and conventions
Strings are JSON strings, counters and ports are JSON numbers, and times are Unix milliseconds. Vectors are arrays [x, y, z]. Numbers are written at full precision; a reader MUST accept a 64-bit counter and MAY lose precision above 2^53.
The control plane treats process IDs, container IDs, addresses, statuses, states, and setting values as opaque strings. It does not enforce nonempty IDs, address syntax, worker-index uniqueness, worker existence, lease-state membership, or referential integrity.
Nebula's runtime applies these conventions:
- An empty
workerIdmeans that a container is unassigned. - Static container IDs come from the authored or baked manifest.
- A carried dynamic container ID contains
#and normally ends with its carrier entity's network ID. - A runtime container ID is
rt_followed by an invariant-cultureu64decimal value. - Only leases with
hasBounds = truedefine a runtime-container box. A root box (noparent) is in absolute coordinates; a child box is in its parent container's local space.
The document
document is an identity assigned when the control plane starts empty or is reset. Restoring the document from storage preserves its identity. When the identity changes, workers request missing assignments for containers they still own; removals within the same document remain in effect. A client MUST treat a missing document field as an empty identity. See Restart and restore a mesh.
{
"version": 42,
"document": "3f1c0e7a9b6d4c2e8a5f7b1d9c3e6a40",
"now": 1789430400000,
"workers": [ { "workerId": "w1", "workerIndex": 1, "address": "10.0.1.3", "port": 7101, "status": "ready", "lastHeartbeat": 1789430399500, "tickCount": 120000, "tickMs": 1.25, "entityCount": 40, "authoritativeCount": 30, "ghostCount": 10, "playerCount": 2, "botCount": 0, "serverDrivenCount": 28 } ],
"leases": [ { "containerId": "cell-1", "workerId": "w1", "epoch": 3, "state": "active", "updatedAt": 1789430300000, "hasBounds": false },
{ "containerId": "rt_7", "workerId": "w1", "epoch": 1, "state": "active", "updatedAt": 1789430350000, "hasBounds": true, "center": [100, 0, 20], "size": [8, 8, 8] } ],
"gateways": [ { "gatewayId": "gw1", "incarnation": 2864434397, "address": "203.0.113.9", "port": 7000, "lastHeartbeat": 1789430399800, "drainRequested": false,
"pendingJoins": 0, "activeClients": 2, "joiningClients": 0, "reconnectingClients": 0,
"packetsIn": 120.5, "packetsOut": 480.2, "bytesIn": 9800, "bytesOut": 61000, "workerBytesIn": 70000, "workerBytesOut": 8000,
"cpu": 0.12, "memoryBytes": 91000000, "loopLagMs": 1.5, "workerConnections": 1, "ready": true, "draining": false } ],
"settings": { "npcs": "12" }
}| Field | Contract |
|---|---|
version | Grows by one on every change. Never reset, not even by ResetControlPlane. A reader compares it to decide whether anything changed. |
now | The orchestrator's clock when the response was written. A reader SHOULD estimate control-plane time as now plus its own elapsed time since the response. The host may write now twice; the last value is the fresh one. |
workers
| Field | Contract |
|---|---|
workerId | Process identifier; unique among live workers. |
workerIndex | Dense index assigned by the orchestrator. The runtime narrows it to 16 bits for network IDs and protocol messages. |
address, port | Host or IP address and UDP port advertised to gateways and peer workers. |
status | Runtime status, normally starting or ready. |
lastHeartbeat | Time of registration or the newest heartbeat. |
tickCount, tickMs | The worker's simulation tick counter and reported time per tick in milliseconds. |
residentBytes, nativeAllocatedBytes, nativeReservedBytes, managedBytes, gcCount | Memory of the worker process, sampled at heartbeat rate: working set (RSS), Unity native allocated and reserved, the managed heap in use, and gen-0 collections since start. Additive: a worker that predates them reads 0. |
frameAvgMs, frameP90Ms, frameMaxMs, frameSections, profileSections | Wall time per Unity frame over the worker's last profile window (average, 90th percentile, worst; milliseconds), the frame split and the profiler sections as name=ms pairs separated by spaces (milliseconds per frame and per tick). /api/state publishes the two string fields as maps from name to milliseconds. Additive: a worker that predates them reads 0 and empty. |
entityCount, authoritativeCount, ghostCount | All resident entities, the authoritative ones, and the ghosts. |
playerCount, botCount, serverDrivenCount | Authoritative entities owned by human clients, by bot clients, and by nobody. |
leases
| Field | Contract |
|---|---|
containerId | Opaque to the control plane. |
workerId | Assigned worker; empty when unassigned. |
epoch | Monotonic assignment generation. Only assignment and pin writes increment it. |
state | Lease state. Any supplied string is stored. |
updatedAt | Newest write that touched or changed the lease. |
hasBounds, center, size | Whether center and size define a runtime-container box. Absent when hasBounds is false. center is written at full double precision. |
parent | Optional. The ID of the container this runtime container sits in. When present, center is in the parent's local space and the box is axis-aligned there; when absent, the box is a root of its scope and center is absolute. |
authority | Optional. leased or inherited; absent means the default. An inherited runtime row is in state inherited, has no workerId, and is never assigned. A carried container's row with leased is assigned by the orchestrator instead of following its carrier. |
frame, interest | Optional. frame is true when the container has a physics frame of its own; interest is 1 when its contents are bucketed in regions of the frame (OwnRegions). |
instance | Optional Base64-encoded InstanceContainerInfo from the wire specification. Includes the private scope, content resource, and outward observation bounds. |
The lease epoch is not an entity authority epoch. The wire protocol represents this lease epoch as a u64 in ContainerOwnership, while per-entity messages carry a separate u32 epoch.
gateways
| Field | Contract |
|---|---|
gatewayId | Process identifier; unique among the gateways of a mesh. The standalone gateway reads it from -nebula-gateway-id (default gw1). |
incarnation | A random 32-bit number drawn when the gateway process started. It is the high half of every session ID the gateway issues. 0 on a row written by a gateway that does not report one. |
address, port | The address and UDP port clients use. |
lastHeartbeat | Time of registration or the newest heartbeat. |
drainRequested | Somebody asked the gateway to drain (SetGatewayDraining). The gateway acts on the flag when it sees it on its own row and confirms with draining. A re-registration with a new incarnation clears the flag. |
pendingJoins | Welcomed clients still waiting for somewhere to spawn. The orchestrator reads it as demand: a pending join at zero workers wakes the mesh, and no scale-in goes below one worker while it is nonzero. |
activeClients, joiningClients, reconnectingClients | Welcomed clients with a pawn; links being authenticated or placed; sessions whose link dropped within the reclaim grace. |
packetsIn, packetsOut, bytesIn, bytesOut | Client-facing traffic per second over the last heartbeat interval. |
workerBytesIn, workerBytesOut | Worker-facing traffic per second over the same interval. |
cpu | Process CPU over the interval as a fraction of one core (2.0 = two cores busy). |
memoryBytes | Working set in bytes. |
loopLagMs | Longest gap between two loops beyond the expected loop period, in milliseconds, over the interval. |
workerConnections | Worker links that completed the handshake. |
ready | Registered, connected to the control plane, not draining, and accepting clients. |
draining | The gateway is refusing new clients and asking the ones it has to reconnect elsewhere. |
The stats fields are flat on the row. A missing stats field reads as 0 or false.
settings
Settings are a flat object of string keys and string values; the control plane attaches no meaning to either.
Writes
A request body is {"ops":[op, op, ...]}. Each op is an object with an "op" name and the arguments below. Times are set by the host from its own clock; a writer never supplies them.
op | Arguments | Effect |
|---|---|---|
RegisterWorker | workerId, workerIndex, address, port | Insert or update; status starting, fresh heartbeat. Counters are kept on update. |
HeartbeatWorker | workerId, status, tickCount, tickMs, entityCount, authoritativeCount, ghostCount, playerCount, botCount, serverDrivenCount, residentBytes, nativeAllocatedBytes, nativeReservedBytes, managedBytes, gcCount, frameAvgMs, frameP90Ms, frameMaxMs, frameSections, profileSections | Update status, heartbeat, and every counter. No-op for an unknown id. |
UnregisterWorker | workerId | Remove the worker; every lease it names becomes unassigned and orphaned. |
RegisterGateway | gatewayId, address, port, incarnation | Insert or update with a fresh heartbeat. A new incarnation clears a stale drainRequested. |
HeartbeatGateway | gatewayId and the stats fields of the gateway row (pendingJoins through draining) | Refresh the heartbeat and replace the stats. No-op for an unknown id. |
UnregisterGateway | gatewayId | Remove the gateway. |
SetGatewayDraining | gatewayId, draining | Set or clear drainRequested. The orchestrator exposes it as POST /api/gateways/drain. |
HeartbeatOrchestrator | orchestratorId, desiredWorkers | Accepted; the host is the orchestrator and keeps nothing. |
SetSetting | key, value | Insert or update. No-op when unchanged. |
EnsureContainer | containerId | Insert an unassigned orphaned lease at epoch 0 if missing. |
EnsureRuntimeContainer | containerId, workerId, center, size, optional instance, parent, authority, frame, interest | Insert a bounded lease if missing: owned and active at epoch 1 when workerId is nonempty, else unassigned and orphaned at epoch 0. An inherited row is always unassigned in state inherited. instance uses the same Base64 metadata encoding as the lease row. The optional fields have the meanings of the lease-row fields of the same name. Existing rows remain unchanged. |
TouchContainer | containerId | Refresh updatedAt. |
AssignContainer | containerId, workerId | See the transition table. |
PinContainer | containerId, workerId | See the transition table. |
SetLeaseState | containerId, state | Overwrite the state. |
ReleaseContainer | containerId | Unassign, orphaned. |
RemoveContainer | containerId | Delete the lease. |
ResetControlPlane | none | Delete every worker, lease, gateway, and setting. |
A missing argument reads as "" or 0. A number MAY be sent as a JSON string. Vectors MUST be arrays of three numbers.
Lease states and transitions
Nebula defines these state strings:
| State | Ownership meaning | Current producer |
|---|---|---|
assigning | Non-owning reserved state. | Reserved; the current orchestrator does not write it. |
active | The named worker owns the container. | Assignment writes. |
draining | The named worker still owns the container while draining. | Reserved; current retirement keeps a lease active until reassignment. |
orphaned | No worker owns the container. | Ensure, release, and worker removal. |
pinned | The named worker owns a carried dynamic container independently of its carrier. | PinContainer. |
The runtime treats active, draining, and pinned as owning states. The control plane does not enforce the state/worker combinations in this table.
The implemented transition rules are:
| Operation | Existing condition | Result | Epoch |
|---|---|---|---|
| Ensure static | Missing | Unassigned, orphaned, no bounds | 0 |
| Ensure runtime | Missing, empty worker | Unassigned, orphaned, supplied bounds | 0 |
| Ensure runtime | Missing, nonempty worker | Supplied worker, active, supplied bounds | 1 |
| Ensure (either) | Present | No change | Unchanged |
| Assign | Missing | Supplied worker, active | 1 |
| Assign | Same worker and active | No change | Unchanged |
| Assign | pinned | No change | Unchanged |
| Assign | Any other existing lease | Supplied worker, active | Incremented |
| Pin | Missing | No change | Not applicable |
| Pin | Existing lease not already pinned to that worker | Supplied worker, pinned | Incremented |
| Release | Existing lease | Unassigned, orphaned | Unchanged |
| Unregister owner | Lease names removed worker | Unassigned, orphaned | Unchanged |
| Set state or touch | Existing lease | As described by the write | Unchanged |
| Remove | Existing lease | Lease deleted | Not applicable |
AssignContainer and PinContainer are the only epoch-incrementing operations. Consumers MUST therefore consider the state and worker ID as well as the epoch. Release and unregister operations invalidate ownership without burning an epoch.
Worker statuses and liveness
Nebula defines starting, ready, draining, and dead. Registration writes starting; a running worker normally heartbeats ready. draining and dead are reserved in the current runtime. Any status string is accepted.
Liveness is a consumer policy, not a control-plane constraint:
alive = status != "dead" && control_plane_now - lastHeartbeat <= worker_timeoutworker_timeout defaults to 5 seconds. The orchestrator unregisters a worker after this test fails; that write orphans its leases. It unregisters a gateway whose heartbeat is older than three times worker_timeout, so the fleet list names the gateways that exist rather than every one that ever registered. The control plane does not expire entries on its own.
Subscription and failure behavior
RemoteControlPlane keeps one read open with wait=10, parses each answer off the main thread, and raises one Changed callback from the next main-thread Tick() when the version changed. An answer with an unchanged version only refreshes its clock.
IControlPlane.Now on a mirror is the now of the newest document plus local monotonic elapsed time. It is an estimate used for heartbeat ages, not an authoritative simulation clock. On the host it is the orchestrator's clock.
When the orchestrator is unreachable, a mirror keeps its last topology and reports IsConnected = false fifteen seconds after its last successful read. Writes are queued, up to 10000, and posted in order once the orchestrator answers; a write is dropped only when the orchestrator rejects the request with 400 or 401. Workers continue simulating from the last leases they received. During an outage, the orchestrator cannot observe liveness, assign containers, or start replacements.
The host saves the document to its storage a second after a change and restores it at startup when asked (-nebula-reset false). A storage failure is logged and retried; the in-memory document stays authoritative meanwhile.
Operational invariants
An integrating process SHOULD preserve these runtime invariants even though the control plane does not enforce them:
- Give every live worker a unique ID and an index that fits in
u16. - Keep one orchestrator responsible for assignments; it is the only host.
- Assign a lease only to a registered, live worker.
- Use only the defined lease states and pair
orphanedwith an empty worker ID. - Treat
EnsureRuntimeContaineras first-writer-wins. Use a separate game-level authority when concurrent callers could propose different bounds. - Keep per-tick entity state on direct service links described by the wire protocol.
Wire protocol specification
Implement or inspect Nebula protocol version 30, including byte encodings, message layouts, delivery classes, and compatibility rules.
Entity location contract
Name where an entity durably is with a scope key, a container id, and a container-local pose that stay the same across handover, restarts, and a persistence round trip.