Skip to content

Networking (multiplayer)

How to make things happen for every player, not just the one who triggered them. SocialScape worlds are server-authoritative multiplayer: the server is the source of truth, clients follow. NexusScript gives you three tools, simplest first:

  1. Synced variables — shared values (a score, a timer, a door's open/closed flag) the server keeps and pushes to everyone. Reach for this first.
  2. RPCs — run a method on the server (authoritative work) or on all clients (show an effect).
  3. Ownership — decide who is allowed to drive a particular networked object.

Plus a no-code option: mark an interactable/trigger/NPC action "Networked" in the inspector and its effects replicate to everyone — no script needed.

Networking is LIVE-ONLY — it does not run in the offline tester

The SDK Test Player / World Tester runs your world offline: no server, no other players. Anything networked will not fire there — RPCs, ownership, synced variables, OnPlayerJoined / OnPlayerLeft, and any interactable/trigger/NPC entry marked Networked.

Offline, Network.IsServer and Network.IsClient are both false. So give every networked action a local branch — treat "no server" as "I'm allowed to act locally" — or the feature is dead in the tester and you can't test your logic. The door example below is built exactly this way. Do the real multiplayer test on the live platform.

Where does my code run? — roles

Every peer runs your script. Branch on where you are:

if (Network.IsServer)  { /* authoritative logic — runs once, on the server */ }
if (Network.IsClient)  { /* visual/local — runs on each player's machine */ }

IsServer / IsClient are read as properties — no parentheses. Rule of thumb: decide things on the server, show things on the client.

1. Session variables (start here)

The easiest way to share state: the server holds the value and pushes every change to all clients automatically. These are scoped, server‑authoritative shared values.

Session variables vs. [Synced] fields

This section is about World/Player session variables — server‑authoritative shared state (a world score, a player's coins). For replicating a script's own fields on a specific object (a pet's mood, a vehicle's gauges, a held prop), use [Synced] fields instead — owner‑ or server‑authoritative, manual or continuous, with change callbacks.

World-scope — one value for the whole instance, everyone sees the same:

World.SetNumber("score", 0);
World.Add("score", 10);              // atomic +10 on the server (safe even if two players score at once)
float s = World.GetNumber("score");  // read anywhere

World.SetString("state", "open");
World.SetBool("doorOpen", true);
// also: GetString / GetBool  (each Get* takes an optional default: World.GetNumber("score", 0))

Player-scope — a per-player value; each player has their own, visible only to them:

Player.Add("coins", 5);              // the CURRENT player's coins
int c = (int)Player.GetNumber("coins");
Player.SetString("team", "red");     // per-player text value
string team = Player.GetString("team");
// same set as World: GetNumber/SetNumber/Add/GetString/SetString/GetBool/SetBool

Declare a variable first (name + type + default)

A synced variable only works if it's declared once in your scene. On any SSInteractable or SSWorldTrigger there's a Variables list — add an entry with the name (score), scope (World or Player), type (Number / String / Bool), and a default. Undeclared names are ignored (reads return the default/empty, writes no-op). Declaring is what gives the variable its type and starting value.

React to a change (no code): OnVariableChanged

On an SSWorldTrigger, add an OnVariableChanged entry and set Watch Variable to the name (leave empty to watch all). It fires on every client whenever that variable changes — wire its actions or a NexusScript method to update UI, play a sound, open a door. This is the synced, everyone-sees-it way to respond to state.

OnVariableChanged is an inspector trigger, not a script callback

In a pure script you react to a synced variable by reading it (in Start/Update), as the door example does — there's no void OnVariableChanged method to override. OnPlayerJoined / OnPlayerLeft are script-callable; OnVariableChanged is inspector-only.

Fine print

Synced values are session-only (reset when the instance restarts), rate-limited (~30 writes/sec per player), and a client may only write its own Player-scope vars. World-scope vars any client can write — the server applies and re-broadcasts. For validated / anti-cheat changes, route the write through a ServerRpc (below) instead.

2. RPCs — run a method on the server or on all clients

[Synced] and the RPC attributes need a SECOND using

NexusBehaviour comes from NexusVM.Unity, but [Synced], [ServerRpc] and [ClientRpc] come from SocialScape.SDK. The starter script in Basics only has the first, so the moment you add an attribute you get:

error CS0246: The type or namespace name 'SyncedAttribute' could not be found
error CS0246: The type or namespace name 'ServerRpcAttribute' could not be found

Not a broken install — a missing using. Every networked script starts:

using UnityEngine;
using NexusVM.Unity;      // NexusBehaviour, Player, Network, World, Controls, …
using SocialScape.SDK;    // [Synced], [ServerRpc], [ClientRpc], Sync / Authority / Interp

(Sync.Continuous, Authority.Server and Interp.Smooth are in SocialScape.SDK too, so the same using covers the attribute arguments.)

Still CS0246 after adding the using? Look for an .asmdef

Both SDK assemblies are auto-referenced, so a script in a normal folder just works. But a script inside a folder that contains its own Assembly Definition (.asmdef) is in a separate assembly, and auto-referencing does not apply to it — no using can fix that on its own. Third-party assets ship these routinely, so this is common when porting someone else's scripts.

Two ways out:

  • Select the .asmdef and add NexusVM.Unity.Runtime and SocialScape.SDK to its Assembly Definition References, or
  • move the script into a folder with no .asmdef above it, where both are referenced automatically.

Use RPCs when you need logic, not just a value: validate something authoritatively, or trigger an effect everyone must see. Two directions, marked with an attribute on the method:

  • [ServerRpc] — runs on the server. Call it from a client with Network.SendServerRpc.
  • [ClientRpc] — runs on every client. Call it from the server with Network.SendClientRpc.

The first argument to Send*Rpc is the method name (a string); the rest are the payload.

class BigRedButton : NexusBehaviour
{
    public ParticleSystem boom;

    void OnUsed()                             // runs on the client who used the button (the reserved callback)
    {
        Network.SendServerRpc("Pressed");     // → ask the server
    }

    [ServerRpc]
    void Pressed()                            // runs on the SERVER (authoritative)
    {
        // validate / do the real work here, then tell everyone to show it:
        Network.SendClientRpc("PlayBoom");    // → every client
    }

    [ClientRpc]
    void PlayBoom()                           // runs on EVERY client
    {
        if (boom != null) boom.Play();
    }
}

The standard round trip: client → SendServerRpc → server validates → SendClientRpc → all clients react.

Who called me? Network.GetSenderId()

Inside a [ServerRpc], Network.GetSenderId() returns the player id of the caller (a string; empty outside an RPC). Use it to attribute the action, look up their data, or check permissions:

[ServerRpc]
void Claim()
{
    string who = Network.GetSenderId();
    World.SetString("owner", who);
}

What you can send — RPC arguments

Arguments are checked against a fixed list of supported types. Anything outside it is refused when you send, with a message naming the type — it is never quietly turned into null and delivered.

Kind Types
Numbers int, long, float, double, byte, sbyte, short, ushort, uint, ulong, decimal
Simple bool, char, string, any enum
Unity values Vector2, Vector3, Vector4, Quaternion, Color, Color32, Rect
Collections arrays, List<T>, Dictionary<K,V> — of anything above, mixed types allowed
Objects GameObject / Transformonly if the object is networked (see below)
Nothing null is a valid argument
[ServerRpc]
void Paint(Vector3 at, Color tint, int strength) { /* … */ }

Network.SendServerRpc("Paint", transform.position, Color.red, 3);

Collections work, and they are the way to send a batch. A stroke of points, a table of scores, a set of positions — one RPC, one round trip:

List<Vector3> points = new List<Vector3>();
points.Add(new Vector3(0, 1, 0));
points.Add(new Vector3(0, 2, 0));

Network.SendServerRpc("AddStroke", points);

[ServerRpc]
void AddStroke(List<Vector3> points)
{
    // runs on the server with all the points intact
}

Sending a GameObject or Transform

Only a networked object can cross — one the network already knows about, with its own identity. A plain scene object has nothing the other side could look up, so sending it is refused with:

GameObject 'Crate' can't be sent through an RPC because it isn't a networked object

Send something the far side can resolve instead — a name, an index into a list you both have, or the networked object the plain one belongs to.

Three type lists, not one — RPC arguments, [Synced] fields, and cross-script calls { #three-type-lists }

These are three separate systems with three separate type lists. The third is the one people miss, because "passing a List to other code" sounds like a single operation whether the other code is on the far side of the network or just on the next GameObject. It isn't.

[Synced] field RPC argument Cross-script call arg
Numbers, bool, char, string
Vector2/3/4, Quaternion, Color
enum, Color32, Rect, decimal
arrays, List<T>, Dictionary<K,V>
your own class instances
Bounds, Matrix4x4, delegates
networked GameObject / Transform

[Synced] — a field with an unsupported type is flagged in the inspector's Networking Budget, reported by name at runtime, and stays local instead of replicating. See Synced Variables.

RPC — an unsupported argument is refused when you send, with a message naming the type. It is never quietly delivered as null.

Cross-script — an unsupported argument logs a warning and arrives as null. This is the loosest of the three, so it is the one to design around: an array or collection cannot cross a script boundary at all, because it lives in the calling script's own storage, which the receiving script cannot read. Send the parts instead — a Begin() call followed by repeated Append(x), or share the data through a component both scripts can reach. See Talking to other scripts.

Keep payloads small

Every RPC is size- and rate-limited: 25 KB and ≤10,000 elements per call. That is roomy for real work — a Vector3[16] is 48 floats — but it is not a file transfer. If you are approaching the cap, send a delta rather than the whole state, or move the state into a [Synced] field and let it replicate on its own.

When an argument can't be sent

A refused argument raises an error on the sending side, at the moment you send — so the failure lands in your code, on your line, not somewhere on another machine minutes later. The messages name the problem directly:

Message Cause
RPC arguments of type X can't be sent over the network An unsupported type. Convert it to something on the list above.
a List of 'X' can't be sent over the network An element type that isn't supported.
GameObject 'N' can't be sent … it isn't a networked object See the warning above.
an RPC argument refers to an object that has been destroyed The object was destroyed before the send. Check it still exists first.

On the receiving side, an argument that can't be read stops the call and reports back to you rather than running your method with a missing value:

Message Cause
an argument couldn't be read: … The payload didn't survive the trip — usually a type the two sides disagree on. Report it.
it was sent N argument(s) but declares M The sender and the receiver are running different versions of the script. Re-upload the world so both sides match.

Direction is enforced

  • SendServerRpc only works on a client; SendClientRpc only on the server.
  • A method must carry the matching [ServerRpc] / [ClientRpc] attribute, or the send is rejected.

Broadcast vs. one player

SendClientRpc(method, ...args) runs the [ClientRpc] on every client. To reach one player, use Network.SendClientRpcToTarget(playerId, method, ...args) — server → that player only:

[ServerRpc]
void RequestSecret()
{
    string who = Network.GetSenderId();                    // the player who called this ServerRpc
    Network.SendClientRpcToTarget(who, "ShowSecret", 42);  // reply to ONLY that player
}

[ClientRpc]
void ShowSecret(int code) { /* runs only on the targeted player's client */ }

Get a specific player, then target them

SendClientRpcToTarget needs a target player id (a string). Server-side you get one from:

  • the callerNetwork.GetSenderId() inside a [ServerRpc] (as above);
  • everyoneNetworking.GetPlayers() returns a string[] of every connected player's id;
  • an objectPlayer.IdOf(obj) resolves a GameObject (a collider you touched, a raycast hit) to its player's id, or "" if it isn't a player.

With an id you can read the player — Player.GetPosition(id), Player.GetUsername(id) — and reply to exactly them. If the target isn't connected, the message is simply dropped — it is not sent to anyone else.

// The server picks the player nearest this object and buzzes ONLY them.
[ServerRpc]
void BuzzNearest()
{
    Vector3 here = transform.position;
    string[] ids = Networking.GetPlayers();                 // every connected player's id
    string nearest = "";
    float best = 999999f;
    for (int i = 0; i < ids.Length; i++)
    {
        string id = ids[i];
        float d = Vector3.Distance(here, Player.GetPosition(id));
        if (d < best) { best = d; nearest = id; }
    }
    if (nearest != "")
        Network.SendClientRpcToTarget(nearest, "Buzz");     // → that one player
}

[ClientRpc]
void Buzz() { /* runs only on the nearest player's client */ }

Note

Network.LocalPlayerId isn't wired — it always returns an empty string. Identify players by id (above) instead, or reach the local player through Networking.LocalPlayer.

Staying inside the RPC budget

Your script gets 500 RPCs/s (burst 200 per 100 ms), and the whole world shares 2,500/s — generous numbers, but there is one behaviour to design around:

An RPC over budget is dropped, not delayed

Nothing buffers it, nothing sends it later, and the call gives you no failure value — you only get a console warning. So a burst that overruns the budget doesn't arrive late, it never arrives, and the receiving client is left with a gap it cannot detect. Pace anything bursty yourself; the three patterns below cover almost every case.

1. Pace a catch-up burst. The classic case: a player joins late and you replay accumulated state to them. Sending it in one frame is exactly what overruns the burst window. Spread it over time instead:

System.Collections.IEnumerator SendHistoryTo(string playerId)
{
    for (int i = 0; i < strokes.Count; i++)
    {
        Network.SendClientRpcToTarget(playerId, "AddStroke", strokes[i]);
        if (i % 10 == 9) yield return new WaitForSeconds(0.1f);   // 10 per 100 ms — inside the burst window
    }
}

yield return null is not enough — one per frame is ~60/s+ on a fast machine and stacks up against the burst window. Gate on time, as above.

2. Send waypoints, interpolate locally. Never send a moving value every frame. Send it a few times a second and let each client smooth between updates — the motion looks better than per-frame RPCs and costs ~1/10th the budget:

float nextSend;
Vector3 targetPos;                    // last value received

protected override void Update()
{
    if (Network.IsOwner(gameObject) && Time.time >= nextSend)
    {
        nextSend = Time.time + 0.1f;                       // 10/s is plenty
        Network.SendClientRpc("MoveTo", transform.position);
    }
    // every client, every frame: glide toward the last value we were told
    transform.position = Vector3.Lerp(transform.position, targetPos, Time.deltaTime * 10f);
}

[ClientRpc] void MoveTo(Vector3 p) { targetPos = p; }

Use Quaternion.Slerp the same way for rotation. For a value that simply is the object's state rather than an event, a [Synced] variable is usually better still — it replicates for you, with no RPC budget involved at all.

3. Batch instead of repeating. Ten RPCs carrying one value each cost ten times what one RPC carrying ten values costs. If you find yourself calling the same RPC in a loop, send the batch.

Putting it together — a networked door (that also works offline)

A complete, idiomatic script: one door that opens/closes for everyone, catches up late joiners, and still works in the offline SDK tester so you can test the logic before you ever go live. The pattern to copy is the OnUsed branch — if there's a server, go through it; if not, act locally.

// NetworkedDoor.cs — put this on an SSInteractable, and declare a World-scope Bool variable "doorOpen".
class NetworkedDoor : NexusBehaviour
{
    public Animator animator;      // an "Open" bool parameter swings the door
    bool _open;                    // THIS machine's view of the door — the source of truth offline

    void OnUsed()                  // runs on the machine of the player who used the door (reserved callback — not "OnUse")
    {
        bool open = !_open;
        if (Network.IsClient)                        // LIVE: let the server drive it for everyone
            Network.SendServerRpc("RequestSet", open);
        else                                         // OFFLINE tester (no server): act locally so it's testable
            Set(open);
    }

    [ServerRpc]
    void RequestSet(bool open)     // SERVER: authoritative — remember the state, then tell every client
    {
        World.SetBool("doorOpen", open);             // synced var → a player who joins later gets it for free
        Network.SendClientRpc("ApplySet", open);
    }

    [ClientRpc]
    void ApplySet(bool open)       // every client reflects the change
    {
        Set(open);
    }

    void Start()                   // a late joiner adopts the current shared state (false in the offline tester)
    {
        Set(World.GetBool("doorOpen"));
    }

    void Set(bool open)            // the one place that actually moves the door
    {
        _open = open;
        if (animator != null) animator.SetBool("Open", open);
    }
}

Why it's built this way:

  • Offline the whole round-trip is skippedIsServer/IsClient are both false, so OnUsed takes the else branch and moves the door directly. That's the only path that runs in the tester, and it's enough to verify your logic. Always give a networked action a local branch like this.
  • Live, the server is the source of truth — the client asks (SendServerRpc), the server records the state and broadcasts (SendClientRpc), and every client (including the one who used it) reflects it in ApplySet.
  • Late joiners catch up for free — the server wrote the flag to a synced variable, so a player who joins later reads the current value in Start and the door is already in the right position.

Even simpler, no script

Write the flag from anywhere and let a no-code SSWorldTrigger ▸ OnVariableChanged (Watch Variable = doorOpen) play the door animation — same result, split between script and inspector.

When an RPC seems to do nothing

An RPC that never runs is almost invisible from your script: SendServerRpc returns nothing, throws nothing, and the log looks clean — because on your machine the send really did succeed. Whether the method then ran on the server (or on the other clients) is decided elsewhere, and until recently the only record of a failure lived in a server log you can't reach.

Now the server tells you. If a ServerRpc you sent does not run, the server sends a diagnostic back and your console prints:

[NexusVM] Your ServerRpc 'DoThing' did not run on the server — <reason>.

The reasons, and what each means:

Reason What to do
the method isn't marked [ServerRpc] Add the attribute. A plain method is never RPC-callable.
it's declared [ClientRpc] but was called as the other kind Call it with the matching send (SendServerRpc[ServerRpc]).
the target object wasn't found on the server The object's identity didn't resolve there yet — usually a spawn/timing race at join, or the object isn't networked. Send once the object exists.
the target script wasn't initialized yet A join race — the receiver hadn't spawned/compiled. Safe to retry.
it threw while running on the server Your method faulted server-side; the message includes the error. Fix the logic.

A method that doesn't return on every path

This one is worth knowing because the compiler does not catch it yet. If a method with a return type can reach its end without returning — an if with no else, an early exit — it fails at runtime:

Method 'Score' is declared to return int but returned no value.
Every path through a method with a return type must return one.
int Score(int hits)
{
    if (hits > 0) return hits * 10;
    // ← nothing returned when hits <= 0
}

Give every path a return:

int Score(int hits)
{
    if (hits > 0) return hits * 10;
    return 0;
}

Before this was reported properly, a missing return produced a value of "nothing" that travelled silently until some later line did arithmetic on it — and the error appeared there, pointing at code that was perfectly fine. If you have ever chased a nonsensical type error, this was often the cause.

If you get NO diagnostic and still see nothing happen, the send itself may not be reaching the server, or the ClientRpc it fans out didn't reach the other clients. Isolate it with the smallest possible round trip — a one-argument ServerRpc that just logs, and a ClientRpc that logs back. If that works and your real one doesn't, the difference is your payload or your object identity, not the platform. If even the tiny probe is silent, that's a platform issue: capture the repro and report it (see the SDK's REPORTING_A_PLATFORM_BUG.md).

3. Ownership — who may drive a networked object

For a networked object (one with a network identity — e.g. a networked SSInteractable), ownership decides who has authority over it. Ownership is server-assigned: a client requests, the server grants and tells everyone.

if (Network.IsOwner(gameObject))               // do I own this object?
    Network.SendServerRpc("DoThing");

Network.RequestOwnership(gameObject);           // ask the server to make me the owner
string ownerId = Network.GetOwner(gameObject);  // current owner's player id (a string)

IsOwner, GetOwner, RequestOwnership, and SetOwner all take the object as the first argument (usually gameObject). Network.SetOwner(gameObject, playerId) is server-only. [ServerRpc(RequireOwnership = true)] records intent, but the runtime does not enforce it for you — check Network.IsOwner(gameObject) yourself at the top of the RPC before acting on it.

4. Finding players

int n = Networking.GetPlayerCount();
string[] ids = Networking.GetPlayers();        // each player's session id (string)
var p  = Networking.GetPlayerById(ids[0]);     // handle by id, or GetPlayer(index)

The id from Networking.GetPlayers() and Player.IdOf(obj) is a per‑session id — it identifies a player while they're connected and changes if they reconnect. Use it to target or read a player this session: it feeds the Player APIPlayer.GetPosition(id), Player.GetUsername(id), Player.IdOf(someObject) (→ that object's id, or "" if it isn't a player).

For anything durable — saved data, a persistent key, an allow‑list — use Player.GetUserId(id), the player's stable account id (the same across sessions). No‑code allow‑lists use the Player GUID condition on Triggers / Interactables (the player's stable account GUID, copied from the SocialScape website). See Player API ▸ Identity for the full model.

Note

Your script runs on each player's own client (not on a central server), so Networking.LocalPlayer resolves to that client's player — live and in the offline tester. But inside a [ServerRpc], which runs on the server, there is no local player: identify the caller with Network.GetSenderId(), or enumerate Networking.GetPlayers().

5. No-code networking (the "Networked" checkbox)

You often don't need a script at all:

  • SSInteractable — tick Networked on the component (to replicate grab/carry) or on an individual event (to broadcast that event's effects). A non-networked event stays local to whoever triggered it.
  • SSWorldTrigger — mark an entry Networked to replicate its effects. See World Triggers.
  • NPC actions — tick Networked on an action for things players must see (an animation, a light, a VFX). Networked NPC actions can also catch up late joiners, so someone who joins after a light turned red still sees it.

Under the hood these do what an RPC would — the server relays the effect to the other clients — but you wire them in the inspector.

6. The shared clock

Network.GetServerTime() returns the server's UTC as Unix seconds — the same value on the server and on every client, with sub‑frame precision (clients re‑sync from the connection heartbeat every couple of seconds). Per‑client Time.time values differ and drift; the shared clock is what round timers, cooldowns, synchronized music and "event starts at :00" are built on.

The pattern is always the same: the authority stores a start time once; every peer computes elapsed locally. No per-tick sync traffic.

[Synced(Authority.Server)] public double roundStart = 0;   // server-owned — un-spoofable

[ServerRpc]
public void StartRound()
{
    roundStart = Network.GetServerTime();
    Network.RequestSerialization();
}

protected override void Update()
{
    if (roundStart <= 0) return;                              // no round yet
    double elapsed = Network.GetServerTime() - roundStart;    // agrees on every screen
    timerLabel.text = $"{90.0 - elapsed:F1}s";
}

Network.GetUtcNow() is the same clock under its calendar name — use it for daily events or cooldown timestamps you store in your own backend. Offline / in the editor tester both calls fall back to this machine's UTC, so the code runs identically — it's just not server‑agreed until you're in a live world.

Quick reference

Goal Use
Share a value with everyone World.SetNumber/SetString/SetBool/Add (+ declare it)
A per-player value Player.SetNumber/… (+ declare it, Player scope)
React to a change everywhere SSWorldTrigger ▸ OnVariableChanged
Do authoritative logic on a click [ServerRpc] + Network.SendServerRpc("Method", …)
Show an effect on every client [ClientRpc] + Network.SendClientRpc("Method", …) (from the server)
Know who called a ServerRpc Network.GetSenderId() (returns a string id)
Branch by where code runs Network.IsServer / IsClient (properties)
Authority over an object Network.IsOwner/RequestOwnership/GetOwner(gameObject)
List / find players Networking.GetPlayers/GetPlayerCount/GetPlayerById
A clock every player agrees on Network.GetServerTime() (Unix seconds; store a start, compute elapsed)
Stable account key (durable/allow-list) Player.GetUserId(id) (session id = per-connection)
No script inspector Networked checkbox on the interactable / trigger / NPC action

Remember: decide on the server, show on the client; declare synced variables before using them; give every networked action an offline branch; and run the real multiplayer test on the live platform — the offline tester can't run any of this.

Next