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:
- Diff the new map table against the previously known set.
- For removed maps: destroy any local engine state representing those maps.
- For added maps: load the corresponding scene/level, then register its scene objects.
- 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.Mapmatches the removed map are destroyed withDestroyModes::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:
Client::Start()readsfusion_map_datafrom the joined room and applies the table.OnMapChange(maps, initial=true)fires with the full map table.- The integration layer loads each map's scene.
OnDestroyedMapActorfires for any pre-destroyed map objects.- The integration calls
CreateMapObject()for each synchronized object in each loaded scene. - Every
CreateMapObjectcall returnsalreadyPopulated = true(the master already populated them). - 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 |
Related
- 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