Nebula

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 pathPurpose
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-planeApply 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 workerId means 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-culture u64 decimal value.
  • Only leases with hasBounds = true define a runtime-container box. A root box (no parent) 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" }
}
FieldContract
versionGrows by one on every change. Never reset, not even by ResetControlPlane. A reader compares it to decide whether anything changed.
nowThe 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

FieldContract
workerIdProcess identifier; unique among live workers.
workerIndexDense index assigned by the orchestrator. The runtime narrows it to 16 bits for network IDs and protocol messages.
address, portHost or IP address and UDP port advertised to gateways and peer workers.
statusRuntime status, normally starting or ready.
lastHeartbeatTime of registration or the newest heartbeat.
tickCount, tickMsThe worker's simulation tick counter and reported time per tick in milliseconds.
residentBytes, nativeAllocatedBytes, nativeReservedBytes, managedBytes, gcCountMemory 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, profileSectionsWall 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, ghostCountAll resident entities, the authoritative ones, and the ghosts.
playerCount, botCount, serverDrivenCountAuthoritative entities owned by human clients, by bot clients, and by nobody.

leases

FieldContract
containerIdOpaque to the control plane.
workerIdAssigned worker; empty when unassigned.
epochMonotonic assignment generation. Only assignment and pin writes increment it.
stateLease state. Any supplied string is stored.
updatedAtNewest write that touched or changed the lease.
hasBounds, center, sizeWhether center and size define a runtime-container box. Absent when hasBounds is false. center is written at full double precision.
parentOptional. 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.
authorityOptional. 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, interestOptional. 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).
instanceOptional 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

FieldContract
gatewayIdProcess identifier; unique among the gateways of a mesh. The standalone gateway reads it from -nebula-gateway-id (default gw1).
incarnationA 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, portThe address and UDP port clients use.
lastHeartbeatTime of registration or the newest heartbeat.
drainRequestedSomebody 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.
pendingJoinsWelcomed 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, reconnectingClientsWelcomed clients with a pawn; links being authenticated or placed; sessions whose link dropped within the reclaim grace.
packetsIn, packetsOut, bytesIn, bytesOutClient-facing traffic per second over the last heartbeat interval.
workerBytesIn, workerBytesOutWorker-facing traffic per second over the same interval.
cpuProcess CPU over the interval as a fraction of one core (2.0 = two cores busy).
memoryBytesWorking set in bytes.
loopLagMsLongest gap between two loops beyond the expected loop period, in milliseconds, over the interval.
workerConnectionsWorker links that completed the handshake.
readyRegistered, connected to the control plane, not draining, and accepting clients.
drainingThe 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.

opArgumentsEffect
RegisterWorkerworkerId, workerIndex, address, portInsert or update; status starting, fresh heartbeat. Counters are kept on update.
HeartbeatWorkerworkerId, status, tickCount, tickMs, entityCount, authoritativeCount, ghostCount, playerCount, botCount, serverDrivenCount, residentBytes, nativeAllocatedBytes, nativeReservedBytes, managedBytes, gcCount, frameAvgMs, frameP90Ms, frameMaxMs, frameSections, profileSectionsUpdate status, heartbeat, and every counter. No-op for an unknown id.
UnregisterWorkerworkerIdRemove the worker; every lease it names becomes unassigned and orphaned.
RegisterGatewaygatewayId, address, port, incarnationInsert or update with a fresh heartbeat. A new incarnation clears a stale drainRequested.
HeartbeatGatewaygatewayId and the stats fields of the gateway row (pendingJoins through draining)Refresh the heartbeat and replace the stats. No-op for an unknown id.
UnregisterGatewaygatewayIdRemove the gateway.
SetGatewayDraininggatewayId, drainingSet or clear drainRequested. The orchestrator exposes it as POST /api/gateways/drain.
HeartbeatOrchestratororchestratorId, desiredWorkersAccepted; the host is the orchestrator and keeps nothing.
SetSettingkey, valueInsert or update. No-op when unchanged.
EnsureContainercontainerIdInsert an unassigned orphaned lease at epoch 0 if missing.
EnsureRuntimeContainercontainerId, workerId, center, size, optional instance, parent, authority, frame, interestInsert 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.
TouchContainercontainerIdRefresh updatedAt.
AssignContainercontainerId, workerIdSee the transition table.
PinContainercontainerId, workerIdSee the transition table.
SetLeaseStatecontainerId, stateOverwrite the state.
ReleaseContainercontainerIdUnassign, orphaned.
RemoveContainercontainerIdDelete the lease.
ResetControlPlanenoneDelete 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:

StateOwnership meaningCurrent producer
assigningNon-owning reserved state.Reserved; the current orchestrator does not write it.
activeThe named worker owns the container.Assignment writes.
drainingThe named worker still owns the container while draining.Reserved; current retirement keeps a lease active until reassignment.
orphanedNo worker owns the container.Ensure, release, and worker removal.
pinnedThe 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:

OperationExisting conditionResultEpoch
Ensure staticMissingUnassigned, orphaned, no bounds0
Ensure runtimeMissing, empty workerUnassigned, orphaned, supplied bounds0
Ensure runtimeMissing, nonempty workerSupplied worker, active, supplied bounds1
Ensure (either)PresentNo changeUnchanged
AssignMissingSupplied worker, active1
AssignSame worker and activeNo changeUnchanged
AssignpinnedNo changeUnchanged
AssignAny other existing leaseSupplied worker, activeIncremented
PinMissingNo changeNot applicable
PinExisting lease not already pinned to that workerSupplied worker, pinnedIncremented
ReleaseExisting leaseUnassigned, orphanedUnchanged
Unregister ownerLease names removed workerUnassigned, orphanedUnchanged
Set state or touchExisting leaseAs described by the writeUnchanged
RemoveExisting leaseLease deletedNot 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_timeout

worker_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 orphaned with an empty worker ID.
  • Treat EnsureRuntimeContainer as 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.

On this page