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 — it is implicit and always present, used for objects that should outlive any explicit map (e.g., persistent player profiles, lobby UI). Use CreateGlobalInstanceObject to place objects there.

MapChange / MapAdd / MapRemove

Only the master client should call these methods. The integration layer is responsible for guarding the call site (e.g., if (fusionClient->IsMasterClient()) { ... }).

C++

Map  Client::MapChange(const PhotonCommon::CharType *data);
Map  Client::MapAdd(const PhotonCommon::CharType *data);
void Client::MapRemove(Map map);
bool Client::MapIsValid(Map map) const;
const std::unordered_map<Map, Data> &Client::GetMaps() const;
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_InternalMapChange, RPC_InternalMapAdd) so every client's table converges. MapRemove uses RPC_InternalMapRemove.

OnMapChange Broadcaster

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

C++

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

The broadcaster fires once on 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 master mutates the table.

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 SDK does not process object updates for a map that is being unloaded:

C++

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

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 state replication is 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 PhotonCommon::CharType *header, size_t headerLength,
    Map map, uint32_t id, ObjectOwnerModes ownerMode,
    uint32_t engineFlags, int32_t requiredObjectsCount
);

Deterministic Object IDs

All clients loading the same map must produce the same object ID for the same entity.
A common approach:

C++

uint32_t objectId = static_cast<uint32_t>(
    FusionCore::CRC64(rootNodeName, std::strlen(rootNodeName))
);

Because scene files define fixed node names, the hash is deterministic across all clients. The SDK pairs the (map, id) tuple to derive the final ObjectId.

Registration Flow

C++

for (auto *node : scene_synchronized_nodes) {
    bool alreadyPopulated = false;

    auto *obj = fusionClient->CreateMapObject(
        alreadyPopulated,
        wordCount + FusionCore::Object::ExtraTailWords,
        typeRef,
        header, headerLength,
        currentMap,
        deterministicObjectId,
        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

Client Role alreadyPopulated Data Flow
Master client (first to register) false Engine defaults --[write]--> Words buffer --[replicate]--> Network
Late joiner / other clients 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.

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++

PhotonCommon::Broadcaster<void(ObjectId id)> OnDestroyedMapActor;

This fires for each pre-destroyed map object during the join process.
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 matches the removed map are destroyed with DestroyModes::MapChange.
  • Objects living in Map = 0 (the global map) are unaffected.
  • 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 PhotonCommon::CharType *header, size_t headerLength,
    Map map, uint32_t id, 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. They follow the same alreadyPopulated bidirectional pattern as map objects.

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.
  3. The integration layer loads each map's scene.
  4. OnDestroyedMapActor fires for any pre-destroyed map objects.
  5. The integration calls CreateMapObject() for each synchronized object in each loaded scene.
  6. Every CreateMapObject call returns alreadyPopulated = true (the master already populated them).
  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 Server rejects the internal RPC; 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