Multiplayer and sharing
Opal supports fixed-host multiplayer. The host player's browser runs gameplay; guests receive the current world and subsequent changes. A room server connects the players. Saving and publishing projects are separate from multiplayer rooms.
Try Crystal Crew
Open Settings → Multiplayer → Play Crystal Crew. This small co-op demo uses Opal's actual player and native Health components.
- Choose Opal server or Cloudflare and check the server URL.
- Click Host a room, then Open second tab to join as a guest.
- Tap crystals or press 1–6 in either tab. Each crystal takes four hits.
- Collect all six, then let the host press R inside the game.
Use Copy invite to invite another device. Both the game URL and room server must be reachable from that device; localhost points to the device itself. Keep the host tab open. Host departure stops the round for guests.
The demo's Download editable project link includes the complete game. Reload an updated editor, then use Open Project to import the new .opal file. Previously downloaded copies contain the older scene-only example.
- Script Studio → CrewCrystal: hit permission and per-hit visual feedback and crystal labels.
- Script Studio → CrewRound: progress, victory and the host-authorized NewRound action.
- Arrange → Components: Health, Health Bar, Motion and the generic Network Input binding.
- UI: title, progress, instructions and crystal labels.
- Input: the
restart_roundaction.
Press Play to try it locally without a room. Import the same project in another editor and use Settings → Multiplayer to host or join a room. The project also runs in a normal exported player; it requires no demo-specific authorization callback.
Choose where rooms run
Settings → Multiplayer stores separate Opal and Cloudflare URLs and your name in this browser. Settings are locked during a connection. An invitation connects to its specified backend; leaving restores your selected configuration. There is no automatic fallback between backends.
- Opal server: uses Opal's room service. Rooms are temporary; they end when the session ends.
- Cloudflare: enter a room Worker endpoint if one is listed in Settings. Cloudflare's account plan and limits apply.
Host and join in the editor
Open Settings → Multiplayer to host a room, copy an invitation, join using a full invitation link, or leave. Guests load a disposable Play world. Stop restores their authored scene; remote state is not saved over the project.
Joining requires matching local script sources. Share the same project/build with players before testing. A room code alone does not replace the secret invitation.
Author gameplay commands
Mark a public synchronous void OpalScript method with [NetworkAction] to allow room members to request it on an object. Only the host executes the method. The method owns gameplay permission checks and mutations:
[NetworkAction]
public void Hit() {
Health health = GetComponent<Health>();
if (health.Hp <= 0) Network.Reject("already-collected");
if (!Network.TryCooldown("crystal-hit", 0.15)) Network.Reject("cooldown");
health.TakeDamage(1);
}Attach Network Input to the same object. Choose the script component and Hit from its Network action picker, then enable taps and/or choose a key. A method with parameters adds typed argument controls in declaration order. Input binding grants no gameplay permission beyond requesting that explicitly exposed method. UI consumption, typing, repeat keys and cancelled drags take precedence. Guest gameplay scripts and ordinary Flow remain disabled.
From host/local OpalScript, use Network.Request(Hit) to request a named action on the same component. Parameters are checked at compile time: Network.Request(Buy, "potion", 2) requires a matching exposed method such as public void Buy(string item, int count). In Flow, the exposed method's action routes through this same request path. Outside a room it executes locally. Network.Request queues a request and returns void; it does not imply acceptance.
During synchronous action execution:
Network.SenderId()returns the transport-authenticated player id (localoffline).Network.SenderIsHost()identifies the requester as host (true offline).Network.TryCooldown(key, seconds)consumes a per-player cooldown shared across objects in the current world. Keys have 1–64 characters; durations are 0.05–3600 seconds.Network.Reject(reason)ends the action with a refusal reason (1–128 characters).
These calls are unavailable outside request execution, including later async continuations. Check rules before changing state: actions do not roll back mutations if a later statement refuses or faults. The engine still synchronizes committed world changes. Crystal Crew's NewRound uses SenderIsHost to refuse guest resets.
Actions accept at most eight int, float, bool or string parameters. The host checks exact arity and types, finite numbers, safe integers and bounded strings; objects, entity handles, arbitrary methods, private methods, lifecycle hooks and async action signatures are not accepted. Existing room sequencing and request journals prevent duplicate execution and stale-world requests.
Settings → Multiplayer → Recent script actions shows the latest requests, requesting players, target objects and refusal reasons. Script Studio supplies attribute/service completion and typed request errors; enabled gameplay tracing also records action outcomes. Current actions use method names as their network identity, so update Network Input bindings when renaming one.
Author owned player objects
Attach Network Owner to every object controlled by one player. Its Owner Only field defaults to on, so only the assigned player can request a [NetworkAction] on that object. ownerId is runtime authority data and is not editable in the Inspector. The host assigns it with Network.SetOwner(entity, playerId). Network.OwnerId(entity) reads the assignment; both ownership calls require a live Entity capability, so expired or cross-world references are refused. Network.LocalPlayerId() takes no Entity and returns the current client's player id (local in ordinary offline Play).
The host alone receives void OnPlayerJoined(string playerId) and void OnPlayerLeft(string playerId). These engine lifecycle hooks run for membership changes and offline Play's local player. Flow and remote requests cannot call them. A common host pattern spawns a player prefab in OnPlayerJoined, then assigns it:
class DoorLobby : Component {
[Property] Asset<blueprint> PlayerPrefab;
void OnPlayerJoined(string playerId) {
Entity actor = Scene.Spawn(PlayerPrefab, new Vector2(120, 300));
Network.SetOwner(actor, playerId);
actor.GetComponent<DoorWalker>().PlayerId = playerId;
}
}Use [Sync] for script values that guests must read explicitly. A shared Door can keep the Entity parameter on an ordinary host-side helper, while the owned player's scalar-only action locates and invokes that helper:
class Door : Component {
[Sync] public bool Open = false;
public void Toggle(Entity actor) {
float dx = actor.Position.X - Transform.Position.X;
float dy = actor.Position.Y - Transform.Position.Y;
if (dx * dx + dy * dy > 12100) return;
Open = !Open;
}
}class DoorWalker : Component {
[Sync] public string PlayerId = "";
[NetworkAction]
public void Interact() {
foreach (Entity actor in Scene.FindWith<Door>()) {
float dx = actor.Position.X - Transform.Position.X;
float dy = actor.Position.Y - Transform.Position.Y;
if (dx * dx + dy * dy <= 12100) {
actor.GetComponent<Door>().Toggle(Self);
return;
}
}
Network.Reject("too-far-from-door");
}
}[Sync] accepts only int, float, bool, and string, and cannot be combined with [Property]. Integers must be safe, floats finite, and strings at most 1,024 characters. Incoming snapshots require every declared synced field with its exact type; missing, unknown, duplicate, disabled, deleted, and opted-out (networkSync: off) targets are rejected before any field changes. Snapshots are bounded to 4,096 component entries, 16,384 fields, and 1 MiB of string data. Changing a synced value changes live room state only; it never rewrites the authored field default or project save.
An owned component can declare void LocalInput(float dt). Opal calls it each rendered frame after sampling the local action map, including on a guest whose ordinary gameplay scripts are disabled. The compiler limits this hook to local values, bounded if/loop control flow, Input.Axis, Input.Vector, Input.Held, Input.Pressed, Input.Released, and typed Network.Request or Network.SendInput calls. It cannot read or write component fields, transforms, GameState, UI, components, or entities; call helpers; or start asynchronous work.
void LocalInput(float dt) {
float x = Input.Axis("move_x");
float y = Input.Axis("move_y");
Network.SendInput(Move, x, y);
if (Input.Pressed("interact")) Network.Request(Interact);
}
[NetworkAction]
public void Move(float x, float y) {
// The host applies movement and enforces its speed and range rules.
}Network.Request represents a discrete action and is sent immediately. Network.SendInput represents the latest continuous input for the same typed [NetworkAction]; sends for one object/action stream are coalesced to at most once per 80 ms. The host still validates ownership, exact scalar types and bounds, and the action must clamp or reject gameplay ranges. Continuous input is not a reliable event log, and SendInput does not automatically clear the last value. The host action must record receipt time, and its Update must stop movement after an authored deadman interval; Door Crew uses 0.4 seconds. Calling LocalInput every frame preserves one-frame Pressed edges, which should use Network.Request.
The downloadable Door Crew template demonstrates the complete pattern. Launch it from Settings → Multiplayer → Play Door Crew, or open Door Crew. The host spawns and assigns one character per member, each client samples only its owned character, movement uses SendInput, interaction uses Request, and the shared door publishes its state with [Sync].
The older Network Hit component remains available as a native Health convenience, but Crystal Crew no longer needs it. Advanced embedding can use networkSession.requestCommand('script.action', entityId, { component, action, values }). Native Health/Inventory commands still require Network Hit or an explicit network.authorize policy; that callback governs native commands, while exposed script actions govern their own rules.
Guests receive live object poses, public component fields, GameState, UI and world structure without re-running host gameplay. Private script continuations, transient audio/particle effects, prediction, rollback and active host migration are outside this implementation. Host departure freezes guests.