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:
- 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 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.Mapis no longer present in the received map table are destroyed withDestroyModes::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:
Client::Start()readsfusion_map_datafrom the joined room and applies the table.OnMapChange(maps, initial=true)fires with the full map table (still insideStart()).- The integration layer loads each map's scene.
- The integration calls
CreateMapObject()for each synchronized object in each loaded scene. OnDestroyedMapActorfires asynchronously for any pre-destroyed map objects once the server validates the version handshake.CreateMapObjectreturnsalreadyPopulated = truefor objects whose replicated state already arrived; objects registered before their state arrives are populated by the incoming updates.- 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 |
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
- Overview
- Multi-Map Session Model
- MapChange / MapAdd / MapRemove
- OnMapChange Broadcaster
- StateUpdatesPause / StateUpdatesResume
- Map Object Registration
- OnDestroyedMapActor
- Object Lifetime per Map
- Global Instance Objects
- Seeding Map State Through Room Properties
- Late Joiners
- Spawned Objects and Maps
- Common Mistakes
- Related