Runtime containers
Register containers while the mesh runs, so a world whose shape is decided by the game (a chunked landscape, player plots) is simulated across workers without a baked manifest.
Use a runtime container when the game decides where the containers are while the mesh runs. A chunked landscape that grows as players explore it, plots that appear when someone buys land, or an instance that exists while a group is inside are all runtime containers. Baked containers (authored in the scene or in a world manifest) and dynamic containers (carried by an entity) keep working alongside them.
For a chunked landscape specifically, NebulaConfig.ChunkedWorld builds and balances runtime containers for you — see Build an infinite runtime world for that path, built on the API this page describes. Use this page directly when your game's containers are not a uniform chunk grid, or you need more control than NebulaChunks gives you.
A client only learns about a runtime container that overlaps a NearCells window around one of the client's authorized foci (its pawn by default; every focus with a free camera or a policy), its current instance, or a dynamic container carrying an entity already in its interest set — not every runtime container the mesh has registered. ContainerRegistry.Runtime and the events below still enumerate every registered container on a worker or the orchestrator, which see the whole mesh regardless of any one client's interest set.
A runtime container is a static box named by a 64-bit id the game chooses. Its lease row on the control plane carries the box, so every process registers the same container from the row alone; nothing about the world's shape is baked into a build. On the wire it is ten bytes (a marker index plus the id), where a baked container is two. Baked containers are unaffected.
To create separate private copies with isolated visibility and physics, follow Create private instances. An ordinary runtime container remains in the public scope unless it carries instance metadata.
Ask for a container
On a worker, ask for a container next to the entities that need it. The request is idempotent, so call it every time an entity comes near the box:
ulong id = Chunks.IdOf(coord); // your hash of the chunk coordinate
Bounds box = Chunks.BoundsOf(coord); // in this process's frame
worker.RequestRuntimeContainer(id, box);An overload takes a balancing hint alongside the box, so a chunk that holds a boss arena can say so as it is created:
worker.RequestRuntimeContainer(id, box, new ContainerHint { Dedicated = true });The lease row is created already assigned to the requesting worker, so the container is simulated there from the first change anyone sees. If another worker asked first, its row stands and the call is a no-op. The orchestrator can move the container later.
The gateway, the orchestrator, every worker and every client mirror the lease rows into their ContainerRegistry (ContainerRegistry.Runtime), and ContainerRegistry.RuntimeRegistered fires on each process once the box is there. Load whatever content belongs in the box from that event: terrain and colliders on workers, visuals on clients.
ContainerRegistry.RuntimeRegistered += container =>
{
var coord = Chunks.CoordOf(container.RuntimeId);
LoadTerrain(coord, container.transform);
};
ContainerRegistry.RuntimeUnregistering += container => UnloadTerrain(Chunks.CoordOf(container.RuntimeId));Entities inside a runtime container are spawned, handed over and ghosted exactly like in a baked one. Adjacency is computed when the box registers, against baked and runtime containers whose boxes touch it, so the ghost band and handover across a chunk seam work without any extra setup.
Place a container inside another
A runtime container can sit inside any other container: an octant of a planet, a base inside an octant, a room inside a ship. Describe where it goes with a ContainerPlacement:
// A base inside an octant, 40 m below the octant's centre, leased on its own.
var placement = ContainerPlacement.Child(
octant.ContainerId,
new Vector3(0f, -40f, 0f), // centre in the parent's local space
new Vector3(200f, 100f, 200f), // size, axis-aligned in the parent
ContainerAuthority.Leased);
worker.RequestRuntimeContainer(baseId, placement);The box is axis-aligned in its parent's local space, so it moves and turns with the parent. Every process registers it once the parent is there; a process that gets the lease row first holds it until the parent arrives.
Choose the authority:
| Authority | Effect |
|---|---|
Leased (the default for a runtime container) | The container has its own lease. The orchestrator can assign it to a different worker than its parent. |
Inherited | Whoever simulates the parent simulates this container. Its lease row only carries the box and is never assigned. When the parent moves to another worker, everything inside the child moves with it. |
A leased child of a moving parent needs a parent with a physics frame of its own. See Lease part of the inside to another worker.
A container with no parent is a root of its scope. Give a root's centre in double precision so it lands exactly, even far from the world's origin:
var root = ContainerPlacement.Root(new Double3(12_000_000.25, 0, -3_500_000.5), new Vector3(2000, 2000, 2000));
worker.RequestRuntimeContainer(planetId, root);Each process takes its floating origin off in double before it narrows the centre to a float, so a root 10,000 km from the origin is placed to well under a centimetre. The Bounds overloads of RequestRuntimeContainer still work and describe a root.
When a container retires, its inherited children retire with it. A leased child keeps its lease row and waits for the parent to come back.
Balancing hints
The orchestrator measures how busy each worker is, but it cannot know what a container means. A hint tells it what the game knows. Every field is optional and the default says nothing:
| Field | Effect |
|---|---|
CostMultiplier | Scales what the planner treats the container as costing. 1 is "as measured"; 2 gives it twice the room. The correction for a container whose occupant count is a poor proxy for its tick time. |
AffinityGroup | Containers sharing a non-empty group are dealt as a single item, so they land on one worker. |
SeamCost | 0..1. Where two neighbors both carry it, the planner is charged for cutting between them and prefers to cut elsewhere. |
Dedicated | Reserves a worker for this container before dealing the rest. A worker holding one is never chosen for retirement. |
Worked examples:
- A city hub. One cell everybody passes through. Mark it
Dedicatedand it gets a machine to itself; the rest of the map is dealt across what is left. - A battle royale. Raise
SeamCoston the cells inside the circle at the start of each phase, and the worker boundary moves out of the fight instead of running through it. - An instanced dungeon. Give the rooms of one instance a shared
AffinityGroupso a party never crosses a machine boundary walking through a door. - A chunked world with nothing special in it. Leave everything default; the measured signal is enough.
Set a hint at bake time (the World window, stored in the manifest), at runtime from game code, or from the dashboard. The runtime value wins over the baked one, and it lives on the container's control-plane row, so an orchestrator restart still sees it.
// From game code on a worker, in the same call that asks for the container.
worker.RequestRuntimeContainer(id, box, new ContainerHint { Dedicated = true });
// Or at any time, for any container.
orchestrator.SetContainerHint("cell_3_0_1", new ContainerHint { CostMultiplier = 2f, SeamCost = 0.5f });The compact form used by the dashboard and the HTTP API is x2,group=keep,seam=0.5,dedicated; an empty string clears the runtime hint and lets the baked one stand again.
If more containers ask to be Dedicated than the mesh has workers to spare (it needs one more worker than there are dedicated containers), none of them is reserved and they share like anything else. The orchestrator logs it and the dashboard shows the reason above the container list.
Retire a container
The owning worker retires a container when nobody needs it any more:
worker.ReleaseRuntimeContainer(id);Persistent entities still inside are checkpointed and despawned; anything else inside is despawned for good. The lease row is deleted and every process forgets the box. When the container is asked for again, Nebula's persistence restores the entities that were saved in it: a persisted entity's record names its container by id (rt_<id>), so the props of a chunk come back with the chunk.
Entities riding in a vehicle in the container, at any depth, leave with the vehicle. Nebula despawns them before the vehicle and saves each persistent one aboard it, so it comes back with the vehicle. See Dynamic containers.
Only the owner can release. Before retiring, read worker.RuntimeContainerIdleSeconds(id): every worker that still wants the box re-stamps its lease row while an entity of its is near it, so the row's age is the mesh-wide idle time. A clock of the owner's own would retire a chunk the neighbor's pawn is about to enter, and its props would blink out and back.
Entities inside a container that goes away are detached from it, never destroyed with it: ghosts on a worker and every remote entity on a client stay alive and pick up the next container from the following state update.
Chunk ring
With ChunkedWorld on, the worker leases a square of chunks around every pawn it simulates: NearCells plus ChunkedWorldServerRingMargin cells out (a ring of r is (2r + 1) chunks on a side, on a planar grid). NearCells is the least that holds every chunk a client's interest can reach: the exit radius plus the distance the pawn may travel while an entity is removed, rounded up to whole chunks. The default margin of 1 adds a whole ring on top. A pawn that crosses a chunk edge requests its new ring at that moment, and a chunk's lease, registration and content take a moment, so the extra ring is the time to build a chunk before interest needs it. With 256 m chunks and the default interest settings, NearCells is 1: margin 1 leases 5 × 5 chunks per roaming pawn, margin 0 leases 3 × 3.
Set ChunkedWorldServerRingMargin to 0 to lease only what interest can reach. InterestSettings.ChunkLoadLeadSeconds gives what is left for building a new chunk: the distance the new ring reaches past interest, divided by InterestMaxFocusSpeed. Validation warns, at start-up and in nebula doctor, when that is under 4 seconds. Two costs remain at 0 whatever the lead time: an entity no player owns, such as an NPC in the outermost chunk, has no neighbouring chunk to ghost into or hand over to unless another pawn leased one, and a pawn faster than InterestMaxFocusSpeed outruns the lead. A scoped grid takes the same ring unless its ChunkGridDefinition.Ring is set.
A pawn that oscillates across a chunk edge does not churn the ring. The chunks it leaves stay wanted for ChunkRetireSeconds (and while any client or ghost stands in them) before the worker releases them, so stepping back and forth re-requests nothing.
Assignment policy
Runtime containers are dealt to workers by the orchestrator's assignment policy (NebulaConfig.AssignmentPolicy, or -nebula-assignment):
| Policy | Behaviour |
|---|---|
baked | The opt-out for games that want count-based sticky dealing: baked containers evenly by count, sticky to their owner. Runtime containers stay with the worker that asked for them and only move when it is gone. |
cost | Every container costs what its occupants cost (NebulaConfig.CostWeights: a leased box, a player, a bot, a server-driven entity). Containers are ordered along a Morton curve of their centres and cut into one contiguous run of roughly equal cost per worker. Nothing moves while the most loaded worker is within CostRebalanceThreshold of the average. |
auto | The default: cost, for baked and runtime worlds alike. |
Occupancy comes from the telemetry workers already post to the dashboard once a second. A game may install its own policy by assigning NebulaOrchestrator.Policy; the input it receives is the baked and runtime containers, the eligible workers, the leases and the per-container occupancy.
The cost policy also refuses to re-deal when the move would buy less than ScaleMinGain of a tick on the busiest worker: a handover costs entity transfers and a visible seam, so it has to be worth making.
NebulaConfig.AutoScale is on by default and keeps the worker count between MinWorkers and MaxWorkers from the tick time workers report, checking every change against a dry run of this policy first. See Autoscale. Launching goes through the configured worker host, so a Hetzner mesh gets a new machine.
Tooling
The dashboard's state lists runtime containers with the baked ones and shows the policy in use. The World map draws their boxes. POST /api/containers/ensure with {"id": 5, "center": [x, y, z], "size": [x, y, z]} creates a container from outside the game, and POST /api/containers/remove with {"id": 5} retires one. Add "parent": "rt_4" to place the new container inside another, with center local to it, and "authority" or "frame" as in a placement (see the HTTP API).
Limits
- A root box is axis-aligned in its scope's coordinates, and a child box in its parent's. With a partitioned world's floating origin active, roots are brought into each process's frame and shifted with it; children move with their parents.
- The lease table gained columns for the box. Publish the control-plane module again after upgrading (
nebula startdoes;nebula deploy --reset-control-planefor a cloud mesh). - A world with no baked containers has nowhere to put the first player until a worker asks for a container. Allocate the chunks around the spawn point from
NebulaGameMode.OnWorkerStarted.
Physics frames
Give a ship, station, or planet a physics scene of its own in which it stands still, so what is inside simulates in the container's own coordinates and can be simulated by a different worker than the hull.
Build an infinite runtime world
Configure runtime chunks, add one content callback, move a predicted player, and place persistent blocks.