Build an infinite runtime world
Configure runtime chunks, add one content callback, move a predicted player, and place persistent blocks.
Build a procedural world whose chunks appear as players explore, retire when nobody needs them, and return with their saved entities. This tutorial uses Nebula's chunked world: a RuntimeWorld definition plus NebulaConfig.ChunkedWorld builds the chunk grid, leases chunks around every pawn, keeps the floating origin near the pawn, and raises one event pair for your game's content. It combines that with runtime containers, prediction, remote procedure calls (RPCs), and persistence.
The example uses a planar chunk grid with 64 m square ground cells in its y = 0 layer. A player can walk without a fixed world boundary and press F to place a block. Blocks are authoritative worker entities and survive chunk retirement, worker failure, and a mesh restart.
A worker is a headless Unity process that simulates entities in assigned box-shaped containers. A lease assigns a container to a worker through the control plane, the shared registry hosted by the orchestrator. Authority is responsibility for simulating an entity and producing its state. See Containers and handover for how that responsibility moves between workers.
Understand the pieces
This tutorial combines four systems:
| System | Responsibility |
|---|---|
NebulaChunkedWorld | Builds the chunk grid from your RuntimeWorld definition, leases chunks around every pawn on workers, and keeps the floating origin near the pawn on clients and near the leased centroid on workers. Installed automatically when NebulaConfig.ChunkedWorld is on. |
NebulaChunks | The one API surface your game touches: Loaded/Unloading events for content, and EnsureAt to make sure a chunk exists before spawning into it. |
| Game-owned chunk content | Creates the floor and colliders for a loaded chunk, in a Loaded handler. Nebula manages the box, the lease, and authority; your game generates the terrain. |
PersistentEntity | Saves each placed block's prefab, container, pose, and opted-in state, then restores it when that chunk returns. |
The floating origin is the chunk coordinate currently represented by Unity coordinates near (0, 0, 0). An origin shift moves registered containers, their child content, networked entities, interpolation history, and prediction history together. NebulaChunkedWorld performs these shifts for you; this tutorial does not call origin APIs directly.
Nebula calls this an infinite world because no authored extent or fixed list of chunks exists. The underlying RuntimeGrid is not mathematically infinite: each axis has signed 21-bit coordinates, from -1,048,576 through 1,048,575. At 64 m per chunk, the addressable span is about 134 million meters per axis.
Prerequisites
Start from a project that has:
- Unity 6 with the Input System package enabled;
- Nebula installed with
nebula init; - a game scene set up with Nebula > Set Up Active Scene; and
NebulaConfig.WorldManifestleft empty. A baked manifest is for authored cell scenes, while this tutorial creates its containers at runtime.
You can also start from the main tutorial, but remove its authored Container objects before following this guide.
Create an empty world definition and turn on the chunked world
In the Project window, select Create > Nebula > World Definition and name the asset InfiniteWorld.
Set these fields:
| Field | Value |
|---|---|
WorldName | InfiniteWorld |
CellSize | (64, 256, 64) |
Cells | Empty |
The empty cell list is intentional. WorldDefinition supplies the cell size; the runtime grid supplies chunk coordinates as players explore. The 256 m Y size gives a ground-level container enough vertical room for players and placed objects.
Select Resources/NebulaConfig. In the Inspector:
- Assign
InfiniteWorldto Runtime World (NebulaConfig.RuntimeWorld) and leave World Manifest (NebulaConfig.WorldManifest) empty. - Enable Chunked World (
NebulaConfig.ChunkedWorld). - Leave Chunk Planar (
NebulaConfig.ChunkPlanar) on. Chunks stay a single layer of columns aty = 0, which matches the ground world this tutorial builds.
With ChunkedWorld on and a RuntimeWorld assigned, every process — client, worker, gateway, and orchestrator — builds the same chunk grid and container bounds from configuration alone. No scene component or game-mode wiring is required to get chunks leasing, ghosting, and handing over across seams; the rest of this tutorial only adds content and gameplay.
Generate ground for each loaded chunk
Add one ChunkContent subclass. Nebula calls it once per chunk your process needs, on every role:
using Nebula;
using Nebula.World;
using UnityEngine;
public sealed class ChunkGround : ChunkContent
{
[SerializeField] private Material groundMaterial;
protected override void OnChunkLoaded(in ChunkContext chunk)
{
var ground = GameObject.CreatePrimitive(PrimitiveType.Cube);
ground.name = $"Ground {chunk.Coord}";
ground.transform.SetParent(chunk.Root, false);
var size = NebulaChunks.Grid.CellSize;
ground.transform.localPosition = new Vector3(0f, -size.y * 0.5f - 0.5f, 0f);
ground.transform.localScale = new Vector3(size.x, 1f, size.z);
var renderer = ground.GetComponent<Renderer>();
if (chunk.IsHeadless)
Object.Destroy(renderer);
else if (groundMaterial != null)
renderer.sharedMaterial = groundMaterial;
}
}Create an empty GameObject named Chunk Content in the game scene and add ChunkGround to it. Assign a material to Ground Material.
chunk.Rootis a child object created under the chunk's container the first time content is dispatched to it. Objects parented to it are destroyed automatically when the chunk retires, so aLoadedhandler that only creates content needs no matchingUnloadinghandler.chunk.IsHeadlessis true on a worker, or on any process running without a graphics device. Workers still need the ground's collider; they do not need its renderer.NebulaChunks.Grid.CellSizeis the same size you set onInfiniteWorld, read back from the active grid rather than duplicated in this script.
Replace the cube with your deterministic terrain, voxel, or content loader. Generate the same collision surface on the worker that the client presents to the player. Do not put NetworkIdentity or NetworkBehaviour components on content created under chunk.Root. Spawn interactive networked objects as registered prefabs instead, into chunk.Container.
Create the player input and controller
Create the input value that the owning client sends every tick:
using Nebula;
using UnityEngine;
public struct PlayerInput : INetworkInput
{
public Vector2 Move;
public float Yaw;
public void Serialize(NetworkWriter writer)
{
writer.WriteVector2(Move);
writer.WriteFloat(Yaw);
}
public void Deserialize(NetworkReader reader)
{
Move = reader.ReadVector2();
Yaw = reader.ReadFloat();
}
}Then create the predicted controller. Movement travels through Nebula's input and reconciliation path. On the owning client, the scene camera follows behind the predicted player. Pressing F sends a reliable ServerRpc to the authoritative worker, which validates the request and spawns the block.
using Nebula;
using UnityEngine;
using UnityEngine.InputSystem;
using Nebula.World;
public sealed class PlayerController : PredictedBehaviour<PlayerInput>
{
[SerializeField] private GameObject blockPrefab;
[SerializeField] private float speed = 6f;
[SerializeField] private float placementDistance = 2.5f;
[SerializeField] private Vector3 cameraOffset = new(0f, 2.5f, -5f);
[SerializeField] private float cameraLookHeight = 1f;
[SerializeField] private float cameraFollowSharpness = 12f;
private float yaw;
private float nextPlacementAt;
private Camera ownerCamera;
private bool cameraInitialized;
private void Update()
{
if (!IsOwner) return;
var mouse = Mouse.current;
if (mouse != null) yaw += mouse.delta.ReadValue().x * 0.08f;
var keyboard = Keyboard.current;
if (keyboard != null && keyboard.fKey.wasPressedThisFrame)
ServerRpc(RpcPlaceBlock);
}
private void LateUpdate()
{
if (!IsOwner) return;
if (ownerCamera == null) ownerCamera = Camera.main;
if (ownerCamera == null) return;
var cameraTransform = ownerCamera.transform;
var desiredPosition = transform.TransformPoint(cameraOffset);
if (!cameraInitialized)
{
cameraTransform.position = desiredPosition;
cameraInitialized = true;
}
else
{
float blend = 1f - Mathf.Exp(-cameraFollowSharpness * Time.unscaledDeltaTime);
cameraTransform.position = Vector3.Lerp(cameraTransform.position, desiredPosition, blend);
}
var lookTarget = transform.position + Vector3.up * cameraLookHeight;
cameraTransform.rotation = Quaternion.LookRotation(
lookTarget - cameraTransform.position,
Vector3.up);
}
protected override PlayerInput GatherInput()
{
var keyboard = Keyboard.current;
var move = Vector2.zero;
if (keyboard != null)
{
move.x = (keyboard.dKey.isPressed ? 1f : 0f) -
(keyboard.aKey.isPressed ? 1f : 0f);
move.y = (keyboard.wKey.isPressed ? 1f : 0f) -
(keyboard.sKey.isPressed ? 1f : 0f);
}
return new PlayerInput
{
Move = Vector2.ClampMagnitude(move, 1f),
Yaw = yaw,
};
}
protected override void Simulate(uint tick, in PlayerInput input, float deltaTime)
{
transform.rotation = Quaternion.Euler(0f, input.Yaw, 0f);
var velocity =
(transform.right * input.Move.x + transform.forward * input.Move.y) * speed;
transform.position += velocity * deltaTime;
Identity.Motion.Velocity = velocity;
}
[ServerRpc]
private void RpcPlaceBlock()
{
if (!HasAuthority || Time.unscaledTime < nextPlacementAt) return;
nextPlacementAt = Time.unscaledTime + 0.15f;
var position = transform.position + transform.forward * placementDistance;
position.x = Mathf.Round(position.x - 0.5f) + 0.5f;
position.y = Identity.Container.WorldBounds.min.y + 0.5f;
position.z = Mathf.Round(position.z - 0.5f) + 0.5f;
if (Physics.CheckBox(position, Vector3.one * 0.45f, Quaternion.identity,
Physics.AllLayers, QueryTriggerInteraction.Ignore)) return;
var worker = NebulaBootstrap.Instance.Worker;
var chunkCoord = NebulaChunks.Grid.CoordOf(position);
var chunkOffset = position - NebulaChunks.Grid.BoundsOf(chunkCoord).center;
NebulaChunks.EnsureAt(chunkCoord, container =>
{
if (container == null) return;
var spawnPosition = container.WorldBounds.center + chunkOffset;
var block = worker.SpawnPrefab(blockPrefab, spawnPosition, Quaternion.identity, container);
// New persistent entities are checkpointed automatically. SaveNow narrows the window
// between confirming the placement and its first durable checkpoint.
worker.Persistence?.SaveNow(block);
});
}
}Keep one camera in the game scene and set its tag to MainCamera. Do not add a camera to the Player prefab: every remote player copy would otherwise bring another active camera. LateUpdate runs the follow behavior only when IsOwner identifies this entity as the local player, and it follows the predicted transform after movement for the frame. Adjust Camera Offset, Camera Look Height, and Camera Follow Sharpness on the prefab to tune the view.
The client never instantiates a networked block. It only asks through ServerRpc; the worker checks authority, rate-limits the request, rejects overlaps, and calls NebulaChunks.EnsureAt so the chunk under the target position is leased (creating it if this is the first entity to reach that spot) before spawning into it. Add your game's permissions, inventory cost, reach test, and placement rules in the same handler.
NebulaChunks.EnsureAt on a worker requests the chunk's lease through the allocator and waits; on a client or gateway it only waits, because a client does not decide which chunks exist. The callback waits for container registration, which does not guarantee that this worker owns the lease. The example preserves the target as a chunk coordinate and offset while it waits, then resolves it into the current floating-origin frame before spawning. Nebula routes authority as the entity enters the container. Recheck game-specific placement rules in the callback if they may change while waiting.
Create the player and block prefabs
Create a capsule prefab named Player with these root components:
NetworkIdentityNetworkTransformPlayerController
On NetworkTransform, keep server authority, position and rotation synchronization, interpolation, and unreliable deltas enabled.
Keep the scene's Main Camera as a separate scene object with the MainCamera tag. The PlayerController finds and moves it for the local player, so the player prefab itself does not need a Camera or AudioListener.
Create a 1 m cube prefab named Block with these root components:
NetworkIdentityPersistentEntity
Leave PersistentEntity.PersistPose enabled. The block does not move after placement, so it does not need NetworkTransform; its spawn includes its complete initial pose. Leave the persistence key empty so Nebula generates a unique key on the first save.
After both prefab assets exist, connect and register them:
- Open the
Playerprefab. - On
PlayerController, drag theBlockprefab into Block Prefab. Save the prefab. - Run Nebula > Network Prefabs > Register All Network Prefabs.
- Select
Resources/NebulaConfigand expand Network Prefabs. Confirm that it contains bothPlayerandBlock, with no empty or duplicate entries. - Open each prefab and confirm that
NetworkIdentity.SceneIdis0.
Nebula assigns each registered prefab a network ID from its position in NetworkPrefabs. Every worker and client build must contain the same list in the same order.
Spawn players from the game mode
Create this game mode script:
using Nebula;
using Nebula.World;
using UnityEngine;
public sealed class InfiniteWorldGameMode : NebulaGameMode
{
[SerializeField] private GameObject playerPrefab;
public override NetworkIdentity OnSpawnPlayer(
NebulaWorker worker,
ulong clientId,
string playerName,
Container requestedContainer)
{
var container = requestedContainer;
var bounds = container.WorldBounds;
var position = new Vector3(bounds.center.x, bounds.min.y + 1f, bounds.center.z);
var player = worker.SpawnPrefab(
playerPrefab,
position,
Quaternion.identity,
container,
clientId);
player.name = $"Player {playerName} ({clientId})";
return player;
}
}Return to the game scene and connect the game mode:
- Create an empty GameObject named
Game. - Add
InfiniteWorldGameModeto it. - Drag the
Playerprefab into Player Prefab. - Save the scene.
The scene must contain exactly one enabled NebulaGameMode. NebulaChunkedWorld anchors chunk (0, 0, 0) permanently — the guaranteed spawn area — so the gateway always has a leased container to offer a joining player, even before anyone has walked there. requestedContainer in OnSpawnPlayer is one of the currently leased chunks; this example spawns directly into it rather than picking a fixed coordinate, so a new player always lands somewhere already loaded.
The dashboard World map shows every authoritative entity in the public world. It is an operator view, not the client's content radius: a block remains on the map while its container is leased, even when that block is too far away for the client-side terrain loader. Nebula continues to replicate public entities within interest range in every leased container in the same instance; use game-side rendering or level-of-detail rules if a distant active block should be hidden for other reasons.
Configure persistence and run the mesh
Keep NebulaConfig.PersistenceMode at auto. For a local mesh, the standalone orchestrator stores blocks in the SQLite database configured by mesh.database, which defaults to Library/Nebula/nebula.db.
Before building, verify the complete setup:
Resources/NebulaConfighasInfiniteWorldin Runtime World, nothing in World Manifest, Chunked World enabled, and both prefabs in Network Prefabs.- The game scene has one
NebulaBootstrap, oneChunk Contentobject withChunkGroundand its Ground Material assigned, and oneInfiniteWorldGameModewith Player Prefab assigned. - The game scene has one enabled camera tagged
MainCamera; thePlayerprefab has no camera of its own. - The
Playerprefab has Block Prefab assigned onPlayerController. - The
PlayerandBlockprefab roots each haveNetworkIdentitywithSceneIdset to0.
Run Nebula > Validate Project. A configured RuntimeWorld tells the validator that the game intentionally has no authored containers. Fix any prefab, game-mode, scene, or transform issues it reports.
From the Unity project root, build and start the mesh:
nebula build
nebula start --workers 2 --open-uiPress Play in the Editor as a client and connect to 127.0.0.1:7000. Walk with WASD, turn with the mouse, and press F to place blocks.
Use the dashboard's World map to watch chunk containers appear and move between workers. Cross a chunk edge and confirm that the player keeps moving while the overlay's container and worker values change.
Verify chunk retirement and restore
Place a block outside the permanently anchored spawn area. With the settings above, the allocator keeps a four-cell ring around every pawn and the origin anchor (NearCells + 1), so choose a chunk more than four cells from the origin. Move every player more than four cells from that chunk, then wait longer than ChunkRetireSeconds (default 30 seconds). The owning worker releases the old chunk. Nebula checkpoints and despawns the block before removing the lease.
Walk back. The runtime allocator requests the chunk again, the control plane leases it, and persistence restores the block into the same runtime container at its saved pose. The restore starts after PersistenceRestoreGraceSeconds, which defaults to 3 seconds, so wait briefly before treating a missing block as a failure.
To verify restart persistence:
nebula stop
nebula start --workers 2 --open-uiThe anchored spawn chunk returns as soon as a worker starts. Blocks elsewhere return when a player approaches and the runtime allocator requests their chunk.
Do not pass --reset-persistence during this test. That option deletes every saved entity before the mesh starts.
What Nebula handles
Once the game supplies a RuntimeWorld and one content callback, Nebula handles:
- building the chunk grid and installing it as the runtime container bounds for every role;
- stable runtime-container identity and control-plane leases, including which chunks are leased around each pawn;
- cost-balanced chunk assignment across workers;
- ghosting and authority handover across registered neighboring chunks;
- container-local network poses;
- client prediction and correction across handover;
- floating-origin updates to containers, entities, interpolation, and prediction history; and
- checkpoint and restore of opted-in persistent entities when a runtime chunk retires or changes owner.
Your game still owns terrain generation, asset streaming, placement rules, permissions, and player-facing gameplay.
When a chunk stays leased
The allocator retires a chunk only when no player is in it. That includes players riding in a vehicle's dynamic container: a pilot in a ship parked in the chunk, or a passenger in a shuttle inside that ship, keeps the chunk leased.
A fast vehicle can outrun chunk leasing. When no chunk exists yet where the vehicle is, it stays assigned to the last chunk it was in, however far away it has flown. The allocator keeps that chunk leased while the vehicle is in a cell that a worker has asked for. When the chunk under the vehicle is registered, the vehicle moves into it and the old chunk retires normally. A vehicle in space that no worker wants is unloaded with its old chunk, like any other content of a chunk nobody needs.
More than one world
Everything above builds the public world: the one a client that names no scope joins. A mesh can run several chunked worlds at once, each with its own cell size, chunks, leases and saved entities, by activating a simulation scope whose definition is a grid:
NebulaChunkedWorld.ActivateGrid(controlPlane, "world/arena#run-4812", new ChunkGridDefinition
{
CellSize = new Vector3(64, 256, 64),
Planar = true,
Anchor = Vector3Int.zero,
});Chunk (3, 0, -7) of that world is a different container from chunk (3, 0, -7) of the public world: a different lease, a different persistence record, a different seed, and no visibility between them. Your content callback serves both — ChunkContext.ScopeKey says which world a chunk belongs to — and NebulaChunks.GridFor(key), At(position, key) and EnsureAt(position, key, cb) are the scope-qualified forms of the calls used above.
Each world keeps its own floating origin
One worker can hold chunks of several worlds at once, and those worlds' coordinates have nothing to do with each other: an arena at (0, 0, 0) and a continent at (100000, 0, 100000) are both real places. Nebula therefore gives each scope its own origin frame. The worker follows a centroid per world and shifts each one on its own, moving only that world's containers, entities, interpolation and state history, so two worlds can both sit next to Unity's origin on one machine and neither loses float precision because of the other.
var frame = NebulaChunks.GridFor("world/arena#run-4812").Frame;
frame.Cell; // which cell of THIS world sits at Unity's (0,0,0)
frame.Shifted += delta => { }; // move anything of this world Nebula does not know aboutThe public world's frame is WorldOrigin, exactly as before, so a game with one world sees no change. A client still has one origin: the frame of the scope its pawn stands in. A pawn aboard a carrier, such as a ship, counts as standing in the chunk the carrier is in, at any depth of carriers: the client follows that chunk's grid, and workers lease the ring of chunks around the pawn's world position as the ship flies. NebulaChunks.GridHolding(container) returns the grid of the chunk a container stands in. Nothing about poses, prediction or interpolation differs between a scoped client and a public one.
A scope can also hold chunk grids hosted by a moving container, such as a planet flying through a star system. See Stream a planet's ground around players.
This is also why a worker never costs more because a mesh has more worlds. The assignment policy and the autoscaler weigh containers by measured load and know nothing about scopes, so a dozen quiet worlds consolidate onto one worker exactly as a dozen quiet chunks of one world would.
Current boundaries
- Runtime-grid IDs have a fixed coordinate range. Keep coordinates within
RuntimeGrid.MinCoordinateandRuntimeGrid.MaxCoordinate, in every world. Changing the packing later would break persisted chunk IDs. - Precision inside one world is still one frame. Each scope gets its own origin frame and its own physics scene, but a single very large world held by one worker has one origin: two players thousands of kilometres apart in the same world share it, and the far one loses float precision. The answer to that is more workers, not more frames.
- Procedural content is not network state. Every role that needs terrain or colliders must generate or load it from the stable chunk ID inside a
ChunkContenthandler. Network interactive objects must be registered prefabs spawned by a worker. To remember what players changed about generated objects, such as a mined rock, use chunk state. - Persistence is opt-in per entity and field. The block exists and keeps its pose because it has
PersistentEntity. Add[Persist]to anyNetworkVariablewhose value must also survive. Ordinary fields and transient entities are not restored. - One world, one frame per worker. The cost policy tends to give a worker spatially neighboring chunks of the same world. A game that expects one worker to simulate many extremely distant active regions of one world should measure the cost of repeated origin shifts; distant regions of different worlds each get their own frame and cost nothing extra.
- Interest management bounds who hears about an entity, not who simulates it. More chunks means more containers to spread across workers, but a single container is still one worker's budget. See Scaling limits and when to partition.
- Only one anchor exists by default.
NebulaChunkedWorldanchors chunk(0, 0, 0)so a joining player always has somewhere to spawn. A game that wants another permanently loaded area (a second town, a hub reachable by teleport) adds anchors toNebulaChunks.Allocator, or works with the runtime grid and allocator directly — see Runtime containers for the lower-level API this path builds on.
Interest management
Understand what bounds a client's bandwidth as the world grows, and its limits.
Runtime containers
Work with leases, retirement, assignment, and balancing hints directly.
World partition
Use authored cell scenes, or the lower-level floating-origin model without chunking.
Persistence
Choose saved variables, checkpoint timing, stores, and reset behavior.
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.
Save changes to procedural objects
Record which generated objects players changed, such as mined rocks or harvested plants, and keep that state across chunk unloads, restarts, and timers.