Scene Management

Overview

Fusion's scene management coordinates level/map state across all connected clients. The master client controls which maps are active, and the SDK ensures every client converges on the same map table and registers its scene objects with consistent state.

The model used by the SDK is multi-map: a session is a collection of independently addressable maps rather than a single linear sequence of scene transitions. Each map has its own opaque payload (typically a UTF-8 path to the loaded scene) and is identified by a 16-bit Map index allocated by the master client.

Multi-Map Session Model

A Fusion session holds an unordered_map<Map, Data> keyed by Map index. The master client can:

  • Replace the active map set with Client::MapChange(data) — clears existing maps and seeds the table with a new entry.
  • Add another map alongside the existing ones with Client::MapAdd(data).
  • Remove a specific map with Client::MapRemove(map).

Every networked object lives in exactly one map (ObjectId::Map). Objects whose map is removed are destroyed with DestroyModes::MapChange.

Map = 0 is the global map, used for objects that should outlive any explicit map (e.g., persistent player profiles, lobby UI). Use CreateGlobalInstanceObject to place objects there. The client itself does not special-case index 0 — it is the server that keeps Map 0 in the table across every MapChange, which is why objects living there survive map transitions.

MapChange / MapAdd / MapRemove

Only the master client can mutate the map table. The guard is internal: called on a non-master client, MapChange and MapAdd do nothing and return 0, and MapRemove does nothing — no RPC is sent and no error surfaces.

C++

Map  Client::MapChange(const RealtimeCore::Common::CharType *data, size_t dataLength);
Map  Client::MapAdd(const RealtimeCore::Common::CharType *data, size_t dataLength);
void Client::MapRemove(Map map);
bool Client::MapIsValid(Map map) const;
const std::unordered_map<Map, Data> &Client::GetMaps() const;

dataLength is mandatory and taken literally — the payload is arbitrary bytes and may contain interior 0x00 (e.g. a 16-byte GUID), so the SDK never derives the length via strlen.

Method Purpose
MapChange(data) Replace the active map set with a single new map carrying data. Returns the newly allocated Map index.
MapAdd(data) Allocate an additional Map alongside any existing ones. Returns the new index.
MapRemove(map) Remove a map. All objects with Id.Map == map are destroyed with DestroyModes::MapChange.
MapIsValid(map) Returns true if the index is currently in the map table.
GetMaps() Returns the full map table for inspection.

MapChange and MapAdd broadcast internal RPCs (RPC_INTERNAL_MAP_CHANGE, RPC_INTERNAL_MAP_ADD) so every client's table converges. MapRemove uses RPC_INTERNAL_MAP_REMOVE.

OnMapChange Broadcaster

All clients (including the master) react to map-table updates through:

C++

RealtimeCore::Common::Broadcaster<
    void(const std::unordered_map<Map, Data> &maps, bool initial)
> OnMapChange;

The broadcaster fires once inside Client::Start() (with initial = true) carrying the map table that was seeded from the room's fusion_map_data property, and then again whenever the server broadcasts a table update. A received update whose keys and payload bytes are identical to the current table is treated as a no-op and does not fire OnMapChange — listeners only see real transitions. Objects whose map index is missing from the received table are destroyed with DestroyModes::MapChange before the broadcast.

The integration layer should:

  1. Diff the new map table against the previously known set.
  2. For removed maps: destroy any local engine state representing those maps.
  3. For added maps: load the corresponding scene/level, then register its scene objects.
  4. Pause and resume state updates around any unload/load that takes time.

StateUpdatesPause / StateUpdatesResume

During map transitions, state updates should be paused so the client does not receive object updates for a map that is being unloaded:

C++

void Client::StateUpdatesPause();
void Client::StateUpdatesResume();

StateUpdatesPause asks the server to stop sending state updates to this client (the direction paused is server → client; the local client keeps working). StateUpdatesResume re-enables them and ignores server time corrections for one second so the clock does not snap after a long load.

Typical Transition Flow

C++

subs += fusionClient->OnMapChange.Subscribe(
    [&knownMaps](const std::unordered_map<FusionCore::Map, FusionCore::Data> &maps,
                 bool initial)
    {
        fusionClient->StateUpdatesPause();

        // Diff: which maps left, which are new?
        for (const auto &[mapId, data] : knownMaps) {
            if (maps.find(mapId) == maps.end()) {
                unload_local_map(mapId);
            }
        }
        for (const auto &[mapId, data] : maps) {
            if (knownMaps.find(mapId) == knownMaps.end()) {
                auto path = extract_path(data);
                load_local_map(mapId, path);
                register_map_objects(mapId);
            }
        }

        knownMaps = maps;

        fusionClient->StateUpdatesResume();
    }
);

The SDK continues pumping the connection while paused (you still call Service() and the update loop). Only server-to-client state updates are suspended.

Map Object Registration

After a map's scene loads, each synchronized object in that scene must be registered with the SDK via CreateMapObject():

C++

ObjectRoot *Client::CreateMapObject(
    bool &alreadyPopulated, size_t words, const TypeRef &type,
    const RealtimeCore::Common::CharType *header, size_t headerLength,
    Map map, uint16_t origin, uint64_t hash, ObjectOwnerModes ownerMode,
    uint32_t engineFlags, int32_t requiredObjectsCount
);

Deterministic Object IDs

All clients loading the same map must produce the same (origin, hash) pair for the same entity. A common approach is a fixed origin plus a 64-bit hash of the node name:

C++

uint64_t hash = FusionCore::Crc64(rootNodeName, std::strlen(rootNodeName));

Because scene files define fixed node names, the hash is deterministic across all clients. The final ObjectId is {origin, map, hash}.

Registration Flow

C++

for (auto *node : scene_synchronized_nodes) {
    bool alreadyPopulated = false;  // must be zero-initialized — the SDK only ever writes true

    auto *obj = fusionClient->CreateMapObject(
        alreadyPopulated,
        wordCount + FusionCore::Object::EXTRA_TAIL_WORDS,
        typeRef,
        header, headerLength,
        currentMap,
        /* origin */ 0,
        deterministicHash,
        ownerMode,
        /* engineFlags */ 0,
        /* requiredObjectsCount */ 0
    );

    if (alreadyPopulated) {
        // Network data exists: deserialize Words -> engine state
        read_from_words(obj, node);
    } else {
        // First client: serialize engine defaults -> Words
        write_to_words(obj, node);
    }

    obj->SetSendUpdates(true);
    obj->SetHasValidData();
}

The alreadyPopulated Bidirectional Flow

alreadyPopulated is an in/out flag with a strict contract: the SDK only ever writes true — when a root with the same {origin, map, hash} ObjectId already exists locally, it returns that object and sets the flag. Otherwise the flag is left untouched, so it must be initialized to false before the call.

Client Role alreadyPopulated Data Flow
First client to register (typically the master) false Engine defaults --[write]--> Words buffer --[replicate]--> Network
Client whose object already arrived from the network true Network --[replicate]--> Words buffer --[read]--> Engine state

This eliminates the need for separate "spawn data" exchange for map-placed objects. The same CreateMapObject call handles both directions. Whether a late joiner sees true depends on whether the object's state packet arrived before registration; an object registered earlier is simply populated by the incoming update afterwards.

OnDestroyedMapActor

When a map object was destroyed before a late-joining client connects, that client needs to know which objects should not be instantiated:

C++

RealtimeCore::Common::Broadcaster<void(ObjectId id)> OnDestroyedMapActor;

The destroyed-actors set arrives asynchronously: the server sends it in a dedicated event only after it has validated the version handshake that Client::Start() initiates, so these callbacks fire some time after Start() returns — possibly after the integration has already begun registering map objects. The integration layer should skip instantiation or immediately destroy the engine entity for the given ObjectId.

C++

subs += fusionClient->OnDestroyedMapActor.Subscribe(
    [](FusionCore::ObjectId id) {
        // This map object was already destroyed before we joined
        mark_as_pre_destroyed(id);
    }
);

Object Lifetime per Map

Map-placed objects exist for the duration of their map. When a MapRemove (or a MapChange that drops a previously held map) occurs:

  • Objects whose Id.Map is no longer present in the received map table are destroyed with DestroyModes::MapChange.
  • Objects living in Map = 0 (the global map) survive because the server always keeps index 0 in the table.
  • Maps that remain active keep their objects intact.

Global Instance Objects

Objects that should persist across map transitions use CreateGlobalInstanceObject():

C++

ObjectRoot *Client::CreateGlobalInstanceObject(
    bool &alreadyPopulated, size_t words, const TypeRef &type,
    const RealtimeCore::Common::CharType *header, size_t headerLength,
    Map map, uint64_t hash, ObjectOwnerModes ownerMode,
    uint32_t engineFlags, int32_t requiredObjectsCount = 0
);

Pass Map = 0 for true session-global objects, or any map index to scope the global instance to that map. The resulting ObjectId is {0, map, hash}. They follow the same alreadyPopulated bidirectional pattern as map objects, including the must-zero-initialize rule.

Seeding Map State Through Room Properties

The initial map table is not built by any client at runtime — it is seeded into the room as a custom property when the room is created, and Client::Start() reads it back. Three well-known property keys are recognized by the Fusion plugin (all declared in FusionRoomProperties.h):

Key constant Room property Content
FusionRoomProperties::CONFIG fusion_config JSON string with overrides on top of FusionConfigDefaults::JSON. Ignored if the server's AllowConfigOverride is false.
FusionRoomProperties::MAP_DATA fusion_map_data Base64-encoded map state in FusionMapStateBuilder format.
FusionRoomProperties::SDK_VERSION fusion_sdk_version Base64-encoded 20-byte packed SdkVersion. Optional — if absent, the room locks to the first valid client's version.

The plugin seeds fusion_config and fusion_map_data in its OnCreateGame handler, and Client::Start() requires both to be present on the joined room — it returns false (with an error log) if either is missing or malformed. To seed a room with a specific initial map state, build the payload with FusionMapStateBuilder and place it into the room's custom properties at creation time:

C++

using namespace FusionCore;

// One map (index 1) whose payload is the scene path, plus the global map (0).
std::map<Map, std::vector<uint8_t>> maps;
maps[Map{0}] = {};
maps[Map{1}] = {scenePath.begin(), scenePath.end()};

const std::string encoded =
    FusionMapStateBuilder::BuildBase64(maps, /*mapCounter*/ Map{1});

// Or seed an empty state (just the global map): FusionMapStateBuilder::EmptyBase64()

roomOptions.CustomProperties[PHOTON_STR("fusion_map_data")] =
    RealtimeCore::Common::StringType(encoded.begin(), encoded.end());

FusionMapStateBuilder::Build / Empty produce the raw bytes; BuildBase64 / EmptyBase64 produce the base64 string the plugin expects on the wire. The mapCounter argument must be at least the largest map index in the table, since new indexes are allocated above it. The full reference for these helpers lives in Types API.

Late Joiners

When a client joins mid-session, it receives the current map table through the room's fusion_map_data property:

  1. Client::Start() reads fusion_map_data from the joined room and applies the table.
  2. OnMapChange(maps, initial=true) fires with the full map table (still inside Start()).
  3. The integration layer loads each map's scene.
  4. The integration calls CreateMapObject() for each synchronized object in each loaded scene.
  5. OnDestroyedMapActor fires asynchronously for any pre-destroyed map objects once the server validates the version handshake.
  6. CreateMapObject returns alreadyPopulated = true for objects whose replicated state already arrived; objects registered before their state arrives are populated by the incoming updates.
  7. The integration deserializes the existing network state into its local scene.

Spawned Objects and Maps

Dynamic objects (created via Client::CreateObject(...)) also carry a Map parameter. This associates them with a specific map, so the SDK can clean them up when the map is removed. Objects created in Map = 0 are global and persist until explicitly destroyed.

Common Mistakes

Mistake Symptom
Not pausing state updates during a map transition Stale state applied to objects in the wrong map
Non-deterministic map object IDs Objects mismatch between clients
Forgetting to call StateUpdatesResume() No replication after map load completes
Not handling OnDestroyedMapActor Ghost objects for late joiners
Calling MapChange/MapAdd/MapRemove from a non-master client Silent local no-op — MapChange/MapAdd return 0 and the map table does not change
Treating OnMapChange as a single-scene event Diff logic missed — old map state lingers or new map never loads
  • Object Creation -- Map-placed vs global vs dynamic objects
  • RPCs -- Internal RPCs used for the map table broadcast
  • Architecture -- Object lifecycle and destruction modes
  • Time -- When map callbacks fire in the frame loop
Back to top