Tutorial
From an empty Unity project to a four-container mesh you can walk across, with a predicted player and a cross-worker hit.
Follow this tutorial to build a small multiplayer test scene. You will split a room into four containers, create a movable player capsule, and add a raycast weapon. Nebula will send each hit to the worker that controls the target player.
You need the CLI installed, nebula setup run once, and Unity 6.
Install Nebula into a Unity project
Create a Unity project (any 3D template), then from a terminal inside it:
nebula initThe command copies the Nebula package into Packages/com.1by3.nebula, creates Assets/Resources/NebulaConfig.asset, and creates nebula.json in the project root. To choose a local Nebula checkout as the source, pass its path to --source. See nebula init for all source options.
Open the project in Unity once so it imports the assets and resolves the package.
Author containers
Create a scene called Arena and add it to Build Settings. Build a 40 × 40 m floor with a few walls, then create four empty objects under a Containers parent, one per quadrant, each with a Container component:
| Object | Position | ContainerId | Size | Center |
|---|---|---|---|---|
| quadrant-NE | (10, 0, 10) | quadrant-NE | (20, 10, 20) | (0, 4, 0) |
| quadrant-NW | (-10, 0, 10) | quadrant-NW | (20, 10, 20) | (0, 4, 0) |
| quadrant-SE | (10, 0, -10) | quadrant-SE | (20, 10, 20) | (0, 4, 0) |
| quadrant-SW | (-10, 0, -10) | quadrant-SW | (20, 10, 20) | (0, 4, 0) |
GameObject > Nebula > Container creates one with a unique id, and Nebula > Containers > Split Selected Container (2x2) tiles a selected box into four, so one 40 × 40 m container split once gives exactly this table.
A container is a box volume with its own local coordinate space. Nebula places entities inside the container's transform and sends their positions relative to it.
Give every container a unique, stable ID. The control plane uses this ID to record the worker assignment. The Scene view gizmo shows the container volume.
You do not need to create separate boundary objects. Nebula uses GhostBandMargin to create remote copies near a boundary. It uses HandoverHysteresis to stop an entity near the boundary from moving back and forth between workers. See Containers and handover.
Add the bootstrap
With Arena open, run Nebula > Set Up Active Scene. The command creates a Nebula object and adds NebulaBootstrap, NebulaDebugOverlay, and NebulaTitleScreen. It also selects Arena as the game and boot scene. If the scene has no containers, the command adds a starter set around the level.
The overlay shows the network tick, round-trip time (RTT), container, and worker while the game runs.
The title screen asks the player for a gateway address and a display name, then connects. Nebula does not show a connection screen on its own. If you remove NebulaTitleScreen, a client started without connection arguments waits until your code calls NebulaClient.ConnectTo. Keep the title screen for this tutorial. See Connecting clients to build your own.
If you already have a Nebula object, check that it has all three components. To add the title screen by hand, select the object and choose Add Component > NebulaTitleScreen.
The setup only fills in what is missing, so it is safe to run again at any point. Run it after the next steps too: it adds your game mode to the scene and registers your network prefabs.
The bootstrap is the entry point of every process. It reads the role from -nebula-role (or EditorRole when you press Play), loads NebulaConfig.GameScene, and starts the matching services. A process is a worker or a client, never both. To play in the Editor without a build, let a second Editor process be the worker. See Iterate without building.
Write the input and the player
The input struct is what the owning client sends every tick and what the worker simulates. It must serialize itself:
using Nebula;
using UnityEngine;
public struct PlayerInput : INetworkInput
{
public const byte FireButton = 1;
public Vector2 Move;
public float Yaw;
public byte Buttons;
public bool Fire => (Buttons & FireButton) != 0;
public void Serialize(NetworkWriter w) { w.WriteVector2(Move); w.WriteFloat(Yaw); w.WriteByte(Buttons); }
public void Deserialize(NetworkReader r) { Move = r.ReadVector2(); Yaw = r.ReadFloat(); Buttons = r.ReadByte(); }
}Create the player as a PredictedBehaviour<PlayerInput>. Nebula calls GatherInput on the client that owns the player. It calls Simulate on the controlling worker and on the owning client. The client may call Simulate again when it corrects a prediction, so keep the method deterministic and do not trigger one-time effects inside it.
using Nebula;
using UnityEngine;
using UnityEngine.InputSystem;
public sealed class PlayerController : PredictedBehaviour<PlayerInput>
{
public float Speed = 6f;
public float Damage = 25f;
public NetworkVariable<float> Health = new NetworkVariable<float>(100f);
private float _yaw;
private bool _firePressed;
private byte _previousButtons;
private void Update()
{
if (!IsOwner) return;
var mouse = Mouse.current;
if (mouse != null) { _yaw += mouse.delta.ReadValue().x * 0.08f; if (mouse.leftButton.wasPressedThisFrame) _firePressed = true; }
}
protected override PlayerInput GatherInput()
{
var k = Keyboard.current;
var input = new PlayerInput { Yaw = _yaw };
if (k != null)
{
float x = (k.dKey.isPressed ? 1f : 0f) - (k.aKey.isPressed ? 1f : 0f);
float y = (k.wKey.isPressed ? 1f : 0f) - (k.sKey.isPressed ? 1f : 0f);
input.Move = Vector2.ClampMagnitude(new Vector2(x, y), 1f);
}
if (_firePressed) input.Buttons |= PlayerInput.FireButton;
_firePressed = false;
return input;
}
protected override void Simulate(uint tick, in PlayerInput input, float dt)
{
transform.rotation = Quaternion.Euler(0f, input.Yaw, 0f);
var velocity = (transform.forward * input.Move.y + transform.right * input.Move.x) * Speed;
transform.position += velocity * dt;
Identity.Motion.Velocity = velocity;
bool firePressed = input.Fire && (_previousButtons & PlayerInput.FireButton) == 0;
_previousButtons = input.Buttons;
if (firePressed && HasAuthority) ServerFire();
}
private void ServerFire()
{
var origin = transform.position + Vector3.up * 1.6f;
if (Physics.Raycast(origin, transform.forward, out var hit, 100f) &&
hit.collider.GetComponentInParent<PlayerController>() is { } victim && victim != this)
{
// Runs on the worker that controls the victim. Nebula routes the call when this worker holds only a ghost.
victim.AuthorityRpc(victim.TakeDamage, Damage);
}
}
[AuthorityRpc]
private void TakeDamage(float amount)
{
Health.Value = Mathf.Max(0f, Health.Value - amount);
if (Health.Value <= 0f) Health.Value = 100f; // instant respawn, for the tutorial
}
}This example uses three Nebula features that matter during a handover:
HasAuthorityis true only on the worker that controls the entity. This check prevents a ghost on a nearby worker from firing the weapon.Identity.Motion.Velocitysupplies velocity for prediction state and handover. The rootNetworkTransformalso sends it for remote extrapolation whenSyncVelocityis enabled.[AuthorityRpc]runsTakeDamageon the worker that controls the target. The call works when the target is local or represented by a ghost.
See Prediction, RPCs, and NetworkBehaviour and NetworkVariable for details.
Make the player prefab
Create a capsule and add NetworkIdentity, NetworkTransform, and PlayerController on its root. Adding a new predicted controller also adds its required NetworkTransform automatically; check that the component is present on existing prefabs too.
On NetworkTransform, keep Authority = Server, all position and rotation axes enabled, and SyncVelocity enabled. Leave scale synchronization disabled because this player's scale does not change. Keep Interpolate and UseUnreliableDeltas enabled. Do not add NetworkRigidbody to this predicted controller; that component is for worker-simulated physics props.
Save the capsule as a prefab, then delete it from the scene: the game mode spawns players, so the scene must not contain one. Add the prefab to NebulaConfig.NetworkPrefabs. Its list position becomes the numeric prefab ID sent in network messages.
Keep SceneId at 0 on the prefab
Open the prefab asset and check that NetworkIdentity.SceneId is 0. If you saved the scene while the capsule was in it, the Editor gave the capsule a scene entity ID. Dragging the capsule into a prefab copies that ID into the prefab asset. Nebula ignores the ID on spawned copies and logs a warning, and Nebula > Validate Project reports it. Set the field to 0 in the prefab asset to clear both.
To register all prefabs whose root has a NetworkIdentity, run Nebula > Network Prefabs > Register All Network Prefabs. The command appends new prefabs without changing existing IDs.
NetworkTransform drives movement on remote clients and ghost workers through the batched transform stream. The runtime supplies its interpolation buffer; do not add RemoteInterpolator yourself. The owning client uses prediction and reconciliation instead of this remote interpolation.
A static networked object only needs NetworkIdentity and its gameplay behavior. For example, a fixed door whose copies animate from a replicated open/closed value does not need NetworkTransform. Its initial placement, container changes, and scale survive scene unloading and reloading. Add transform synchronization when copies need ongoing position, rotation, or scale updates. See NetworkTransform.
Add a game mode
The game mode creates a player when the gateway asks a worker to accept a client. Add exactly one game mode to an empty Game object in the scene, then assign the player prefab.
When your project contains only one game mode class, Nebula > Set Up Active Scene adds the Game object for you. It does not fill in the component's fields: select Game and drag the player prefab into PlayerPrefab, then save the scene. If you leave the field empty, every spawn request fails on the worker with an ArgumentNullException in NetworkPrefabs.IdOf, and the client stays on "in game, waiting for a pawn".
using Nebula;
using UnityEngine;
public sealed class TutorialGameMode : NebulaGameMode
{
public GameObject PlayerPrefab;
public override NetworkIdentity OnSpawnPlayer(NebulaWorker worker, ulong clientId, string playerName, Container container)
{
var position = NebulaContainerHelpers.RandomPointIn(container, margin: 2f, y: 0f);
var identity = NetworkPrefabs.Instantiate(NetworkPrefabs.IdOf(PlayerPrefab), position, Quaternion.identity, container.transform);
identity.name = $"Player {playerName} ({clientId})";
worker.Spawn(identity, container, clientId);
return identity;
}
}The gateway selects a container whose worker is connected. It then asks that worker to spawn the player. worker.Spawn creates the entity ID, gives the worker authority, and begins replication. See Spawning.
Build and run the mesh
Nebula > Validate Project checks the setup first: the boot and game scenes in the build settings, the game mode, duplicate container ids, prefabs missing from NetworkPrefabs, and network prefabs that kept a SceneId. It also reports missing or disabled transforms on predicted entities, physics bodies, and moving containers, and incompatible authority settings. Each problem is logged with its object, so clicking the Console line selects it.
nebula build
nebula start --open-uiThe first command builds the Unity player. You can leave the project open in the Editor while it runs. The second command starts the orchestrator, which hosts the control plane and starts the gateway and four workers. It also opens the dashboard at http://localhost:7080/.
Press Play in the Editor. On the title screen that NebulaTitleScreen shows, enter the local gateway address 127.0.0.1:7000 and a name, then select Connect. If no title screen appears, add NebulaTitleScreen to the Nebula object as described in Add the bootstrap.
Walk across the middle of the room. The overlay shows the container and worker index changing, and its handover count increases. The dashboard also updates the container assignment and entity counts.
To get a second player without a second machine:
Builds\Win64\Nebula.exe -nebula-role client -nebula-name otherTo automate a client, implement bot input in your predicted behavior and start the mesh with nebula start --bots 2. Each bot runs a complete headless client process.
With two clients connected, move one player while watching from the other. Check that movement remains visible when the player crosses a worker boundary. If only the owning client moves, verify that the player prefab has an enabled root NetworkTransform with position synchronization enabled. Stop moving and check that the remote player settles at the same position.
Break something
On the dashboard, select Kill for the worker that owns your container. Nebula reassigns the worker's containers and starts a replacement worker. Because Nebula does not persist transient entities, your player entity is removed with the stopped worker and spawned again on another worker.
Next, select Remove for a worker. The orchestrator first drains the worker by moving its containers and entities to other workers. It stops the worker after the drain finishes.
nebula status
nebula logs w1 --follow
nebula stop