Pitfalls

Overview

Critical gotchas that every Fusion 3 SDK integration must handle.
Each pitfall has caused real bugs in production integrations.

Summary

# Pitfall Consequence Prevention
1 Writing into ObjectTail Corrupted interest/destroyed/send-rate flags Allocate user_words + ExtraTailWords, write only to [0, user_words)
2 Iteration order mismatch Silent data corruption Single property layout, identical iteration everywhere
3 Header inclusion order Compile error (Dictionary collision) SDK headers before engine headers
4 String handle leaks Heap exhaustion Free old handle before allocating new
5 StringHeap in spawn data Invalid handle references on remote Raw bytes for initial strings, StringHeap for ongoing sync
6 Frame loop order Stale data, extra latency, assertion failure Service() -> UpdateFrameEnd() -> UpdateFrameBegin(dt)
7 Multi-threaded access Data races, crashes All SDK calls on one thread
8 CRT linkage mismatch Heap corruption on Windows Link with /MD (dynamic CRT)
9 alreadyPopulated mishandled Zeroed or stale state Check flag, handle both directions
10 Array capacity overflow Data corruption Fixed capacity at creation time, clamp writes
11 Sub-object string leaks Root heap exhaustion Explicitly free child strings on destroy
12 Missing AddSubObject Child never replicated CreateSubObject -> write data -> AddSubObject
13 SubscriptionBag lifetime Use-after-free Unsubscribe before destroying client, or RAII order
14 Unchecked Task<Result<T>> Assertion failure / UB Always check IsOk() before GetValue()
15 Out-of-order multi-map operations Ghost objects, lost map state Diff OnMapChange tables; gate MapChange/MapAdd/MapRemove on master
16 Predicted-input ordering Stale replays after server reset Subscribe to OnPredictionReset; let SendUserRpc assign Sequence
17 FusionCAPI event-queue lifetime Dangling blob pointer / memory growth Poll every frame, copy bytes you keep, then event_queue_flush
18 MSVC runtime mismatch (_mt vs _md) Heap corruption at allocation boundary Pick one runtime; build with the matching preset

Pitfall 1: Writing into the ObjectTail

The last 18 words (Object::ExtraTailWords = sizeof(ObjectTail) / 4) of every object's Words buffer are reserved for internal use.
The ObjectTail contains the interest key, destruction flag, send rate and required objects count.
Writing user data into this region corrupts the object's internal state.

C++

// ObjectTail layout (72 bytes = 18 words at the end of Words)
#pragma pack(push, 4)
struct ObjectTail {
    uint32_t Reserved[8];           // offset +0   (32 bytes / 8 words)
    int32_t  RequiredObjectsCount;  // offset +32
    uint64_t InterestKey;           // offset +36  (8 bytes = 2 words)
    int32_t  Destroyed;             // offset +44
    int32_t  RoomSendRate;          // offset +48
    PlayerId PredictingPlayer;      // offset +52  (uint16_t)
    uint32_t RejectedSequence;      // offset +56
    uint32_t InputSequence;         // offset +60
    uint32_t InputTime;             // offset +64
    int32_t  Dummy;                 // offset +68
};
#pragma pack(pop)
static_assert(sizeof(ObjectTail) == 72);

C++

// WRONG: user data overflows into tail
size_t totalWords = userWordCount; // forgot tail!
auto* obj = client->CreateObject(totalWords, type, header, headerLen, scene, ownerMode);
obj->Words.Ptr[userWordCount - 1] = myValue; // may overlap tail

C++

// CORRECT: always add ExtraTailWords
size_t totalWords = userWordCount + FusionCore::Object::ExtraTailWords;
auto* obj = client->CreateObject(totalWords, type, header, headerLen, scene, ownerMode);
// Safe range: obj->Words.Ptr[0] through obj->Words.Ptr[userWordCount - 1]
// DANGER:    obj->Words.Ptr[userWordCount] onwards is ObjectTail

Rule: When allocating an object, request user_words + Object::ExtraTailWords total words.
Only write to indices 0 through user_words - 1.


Pitfall 2: Words Buffer Iteration Order Mismatch

Properties in the Words buffer are serialized sequentially at fixed offsets with no type markers or length prefixes.
The iteration order during sync_outbound() (writing) must exactly match sync_inbound() (reading) and spawn data serialization.

If you add, remove or reorder properties and the sender/receiver disagree on layout, every property after the change point reads garbage data silently -- there are no runtime checks.

C++

// WRONG: different order in write vs read
void SyncOutbound(int32_t* words) {
    words[0] = health;                        // int32
    words[1] = float_to_word(position_x);     // float
}
void SyncInbound(const int32_t* words) {
    float px = word_to_float(words[0]);       // reads health as float!
    int hp = words[1];                        // reads position as int!
}

C++

// CORRECT: same order in both
void SyncOutbound(int32_t* words) {
    words[0] = float_to_word(position_x);
    words[1] = health;
}
void SyncInbound(const int32_t* words) {
    float px = word_to_float(words[0]);
    int hp = words[1];
}

Rule: Define the property layout once (e.g., in a configuration resource) and iterate it identically in all serialization paths: sync_outbound, sync_inbound, spawn data write, spawn data read and word count computation.


Pitfall 3: SDK Header Inclusion Order

The Fusion SDK pulls in Photon headers that define a Dictionary type.
This collides with engine types (e.g., godot::Dictionary, TMap typedefs).
SDK headers must be included before engine headers in every translation unit.

C++

// CORRECT: SDK headers first
#include "Client.h"     // Fusion SDK
#include "Types.h"      // Fusion SDK
#include <my_engine/node.hpp>  // Engine

C++

// WRONG: Engine headers first -- Dictionary name collision
#include <my_engine/node.hpp>  // Engine
#include "Client.h"     // Fusion SDK -- COMPILE ERROR

Rule: Every .cpp file that uses both Fusion SDK and engine types must include SDK headers before engine headers.
Consider using a precompiled header or a single include-order header to enforce this.


Pitfall 4: String Handle Leaks

NetworkedStringHeap is a fixed-size heap.
Every call to Object::AddString() allocates space.
If you update a string property without first calling Object::FreeString() on the old handle, the old allocation is never reclaimed.

C++

// WRONG: leaks the previous string
FusionCore::StringHandle handle = obj->AddString(toU8(new_value));
words[offset] = handle.id;
words[offset + 1] = handle.generation;

C++

// CORRECT: free old handle before allocating new
FusionCore::StringHandle old_handle;
old_handle.id = static_cast<uint32_t>(words[offset]);
old_handle.generation = static_cast<uint32_t>(words[offset + 1]);
if (old_handle.id != 0) {
    obj->FreeString(old_handle);
}
FusionCore::StringHandle new_handle = obj->AddString(toU8(new_value));
words[offset] = new_handle.id;
words[offset + 1] = new_handle.generation;

Rule: Always free the old string handle before allocating a new one.
Use id == 0 to detect uninitialized/empty handles.


Pitfall 5: Spawn Data Cannot Use StringHeap

When an object is first created, its NetworkedStringHeap does not exist yet on remote clients.
Spawn data (the header parameter passed to CreateObject() / CreateMapObject(), stored as Object::EngineBlob) is serialized into a raw byte buffer, not the Words buffer.

If you write StringHandle values into spawn data, remote clients will receive handle IDs that reference a heap that has not been populated yet, resulting in StringMessage::NotALiveEntry or StringMessage::OutOfRange errors.

C++

// WRONG: StringHandle in spawn data
FusionCore::StringHandle h = obj->AddString(toU8("player_name"));
spawnWords[0] = h.id;        // Remote sees invalid handle
spawnWords[1] = h.generation;

C++

// CORRECT: write empty handle at spawn, sync real string next frame
spawnWords[0] = 0;  // empty handle
spawnWords[1] = 0;
// In sync_outbound() on the next frame, AddString() works because
// the heap is now established on both sides.

Rule: For initial string values, serialize them directly into the header byte buffer as raw bytes (length-prefixed or null-terminated).
Only use AddString() / StringHandle for ongoing state sync after the object is fully created.


Pitfall 6: Frame Loop Order

The frame loop must execute in this exact order every frame: Service() -> UpdateFrameEnd() -> UpdateFrameBegin(dt).

  • Service() -- processes network I/O (send queued packets, receive incoming packets)
  • UpdateFrameEnd() -- finishes the current frame (queues outbound state/RPCs)
  • UpdateFrameBegin(dt) -- starts the next frame (applies received state, fires callbacks)

C++

// WRONG: Begin before End
g_realtime->Service();
g_client->UpdateFrameBegin(dt);  // processes nothing, callbacks fire with stale data
g_client->UpdateFrameEnd();      // SDK assertion or _expectingEnd flag error

C++

// WRONG: Service after End
g_client->UpdateFrameEnd();      // queues packets but nothing to send yet
g_realtime->Service();           // sends packets one frame late
g_client->UpdateFrameBegin(dt);

C++

// CORRECT
g_realtime->Service();
SyncOutbound();
g_client->UpdateFrameEnd();
g_client->UpdateFrameBegin(dt);
SyncInbound();

Rule: Call all three methods every frame in the exact order: Service(), UpdateFrameEnd(), UpdateFrameBegin(dt).
Insert sync_outbound before End and sync_inbound after Begin.


Pitfall 7: Single-Threaded Constraint

The entire Fusion SDK is single-threaded.
All API calls -- Client methods, Object field access, RealtimeClient operations, string heap operations -- must happen on the same thread that runs the frame loop.

There are no mutexes or thread-safety guarantees.
Calling SDK methods from a background thread causes data races, corrupted state and crashes.

C++

// WRONG: accessing from a worker thread
std::thread worker([&]() {
    auto* obj = client->FindObject(id);  // data race!
    memcpy(&obj->Words.Ptr[0], &value, 4);  // corrupted state!
});

C++

// CORRECT: all SDK access on the main/frame thread
void OnMainThread() {
    auto* obj = client->FindObject(id);
    memcpy(&obj->Words.Ptr[0], &value, 4);
}

Rule: Run the frame loop and all SDK interactions on the engine's main thread (or a single dedicated networking thread if your engine supports it).


Pitfall 8: CRT Linkage Mismatch (Windows)

The Fusion SDK libraries are compiled with /MD (dynamic C runtime).
If your integration links with /MT (static C runtime), you get heap corruption -- objects allocated in one CRT are freed in another.

Symptoms include random crashes in delete[], free() or std::vector destructors, often in Data::Free() or BufferT::~BufferT().

C++

# WRONG: static CRT
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded")         # /MT

C++

# CORRECT: dynamic CRT to match the SDK
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreadedDLL")       # /MD

Rule: On Windows, always link with /MD (dynamic CRT) to match the SDK.
In CMake, set CMAKE_MSVC_RUNTIME_LIBRARY to MultiThreadedDLL (Release) or MultiThreadedDebugDLL (Debug).


Pitfall 9: alreadyPopulated Flag for Scene Objects

CreateMapObject() returns a bool& alreadyPopulated out-parameter that determines the initial data flow direction.

C++

// WRONG: ignoring alreadyPopulated
bool alreadyPopulated = false;
auto* obj = client->CreateMapObject(alreadyPopulated, words, type,
                                       nullptr, 0, seq, id, ownerMode);
SerializeToWords(obj);  // Always writes -- overwrites network state if populated!

C++

// CORRECT: handle both directions
bool alreadyPopulated = false;
auto* obj = client->CreateMapObject(alreadyPopulated, words, type,
                                       nullptr, 0, seq, id, ownerMode);
if (!alreadyPopulated) {
    // First time: write engine defaults INTO the Words buffer
    SerializeToWords(obj);
    obj->SetSendUpdates(true);
    obj->SetHasValidData();
} else {
    // Rejoining: Words buffer has network state -- apply to engine
    DeserializeFromWords(obj);
    obj->SetHasValidData();
}

Getting this backwards means:

  • !alreadyPopulated but you read from Words: engine gets zeroed-out defaults
  • alreadyPopulated but you write to Words: you overwrite network state with stale engine defaults

Rule: Always check alreadyPopulated and handle both directions.
The first client to create the scene object sees false; late-joining clients see true.


Pitfall 10: Array Capacity Is Immutable After Creation

Array properties reserve a fixed number of words at object creation time: 1 + (max_capacity * element_words).
This word count is baked into the object's Words buffer size and cannot change after creation.

C++

// WRONG: writing beyond allocated capacity
int maxCapacity = 4;
int totalWords = 1 + maxCapacity; // 5 words reserved for array

// Later, trying to store 10 elements:
words[0] = 10;  // count = 10
for (int i = 0; i < 10; i++) {
    words[1 + i] = values[i];  // elements 4-9 overflow into other properties!
}

C++

// CORRECT: clamp to capacity
words[0] = std::min(elementCount, maxCapacity);
int count = words[0];
for (int i = 0; i < count; i++) {
    words[1 + i] = values[i];
}

Rule: Determine the maximum array capacity at design time.
Set it in the replication configuration before spawning.
The runtime array can be smaller than capacity (tracked by the count word) but never larger.


Pitfall 11: Sub-Object StringHeap Delegation

ObjectChild does not have its own NetworkedStringHeap.
String operations on a child object delegate to the root's heap.

This means all children share the root's string heap capacity, string handles are root-scoped (not child-scoped) and freeing a child does NOT automatically free its strings from the root's heap.

C++

// WRONG: destroying child without freeing strings
client->DestroySubObjectLocal(child);  // strings leaked in root heap!

C++

// CORRECT: free child strings before destruction
for (int stringOffset : childStringOffsets) {
    FusionCore::StringHandle h;
    h.id = static_cast<uint32_t>(child->Words.Ptr[stringOffset]);
    h.generation = static_cast<uint32_t>(child->Words.Ptr[stringOffset + 1]);
    if (h.id != 0) {
        child->FreeString(h);
    }
}
client->DestroySubObjectLocal(child);

Rule: When destroying a sub-object, explicitly free all its string handles from the root's heap first.
Track which handles belong to which child.


Pitfall 12: CreateSubObject + AddSubObject Two-Step

Creating a sub-object requires two separate API calls.

C++

// WRONG: AddSubObject before writing data
auto* child = client->CreateSubObject(parentId, words, type, header, headerLen,
                                       targetHash, childId, specialFlags);
client->AddSubObject(parent, child);  // sends empty Words to remotes!
memcpy(child->Words.Ptr, data, size);  // too late, already replicated

C++

// WRONG: forgetting AddSubObject
auto* child = client->CreateSubObject(parentId, words, type, header, headerLen,
                                       targetHash, childId, specialFlags);
memcpy(child->Words.Ptr, data, size);
// child exists locally but is never replicated!

C++

// CORRECT: create -> write data -> register
auto* child = client->CreateSubObject(parentId, words, type, header, headerLen,
                                       targetHash, childId, specialFlags);
memcpy(child->Words.Ptr, data, userWordCount * sizeof(int32_t));
child->SetHasValidData();
client->AddSubObject(parent, child);

Rule: Always follow the sequence: CreateSubObject -> write spawn data -> AddSubObject.
The child's Words buffer must be populated before AddSubObject triggers replication.


Pitfall 13: SubscriptionBag Lifetime

The SubscriptionBag holds ScopedSubscription objects that automatically unsubscribe when destroyed.
If the bag is destroyed while the Client (and its broadcasters) is still alive, the subscriptions are cleanly removed.
But if the bag outlives the broadcaster (or the broadcaster is destroyed first), you get a use-after-free.

C++

// WRONG: bag destroyed after client
{
    FusionCore::Client* client = new FusionCore::Client(*realtime);
    PhotonCommon::SubscriptionBag subs;
    subs += client->OnObjectReady.Subscribe([](auto*) { /*...*/ });

    delete client;  // Broadcaster destroyed
}
// ~SubscriptionBag tries to unsubscribe from dead broadcaster: UAF!

C++

// CORRECT: unsubscribe before destroying client
{
    FusionCore::Client* client = new FusionCore::Client(*realtime);
    PhotonCommon::SubscriptionBag subs;
    subs += client->OnObjectReady.Subscribe([](auto*) { /*...*/ });

    subs.UnsubscribeAll();  // Clean unsubscribe while broadcaster alive
    delete client;
}

C++

// ALSO CORRECT: bag destroyed before client (RAII order)
{
    FusionCore::Client client(*realtime);  // constructed first
    PhotonCommon::SubscriptionBag subs;    // constructed second
    subs += client.OnObjectReady.Subscribe([](auto*) { /*...*/ });
    // ~subs runs first (reverse construction order), client still alive
    // ~client runs second
}

Rule: Either call UnsubscribeAll() explicitly before destroying the client, or ensure the SubscriptionBag is destroyed before the client via RAII ordering (declare the bag after the client so it is destroyed first).


Pitfall 14: Task<>> Error Handling

The Task<Result<T>> pattern used by RealtimeClient async operations requires checking IsOk() before accessing the value.
Calling GetValue() on an error result triggers an assertion failure (debug) or undefined behavior (release).

C++

// WRONG: unchecked GetValue
auto result = co_await realtime->JoinOrCreateRoom(u8"room");
auto room = result.GetValue();  // assertion failure if join failed!

C++

// WRONG: checking result as bool then ignoring error path
auto result = co_await realtime->JoinOrCreateRoom(u8"room");
if (result) {
    auto room = result.GetValue();
}
// ...but what happens on failure? Silently continues with no room.

C++

// CORRECT: check IsOk/IsErr, handle both paths
auto result = co_await realtime->JoinOrCreateRoom(u8"room");
if (result.IsErr()) {
    auto& error = result.GetError();
    printf("Join failed: code=%d msg=%s\n",
           static_cast<int>(error.code),
           reinterpret_cast<const char*>(error.message.c_str()));
    co_return Result<void>::Err(error);
}
auto room = result.GetValue();  // safe: IsOk() is true

For the polling pattern, always check IsReady() before Get() and check the result.

C++

// CORRECT: polling pattern
if (task.IsReady()) {
    auto result = task.Get();
    if (result.IsOk()) {
        // proceed
    } else {
        auto& error = result.GetError();
        printf("Error: %d\n", static_cast<int>(error.code));
    }
}

Rule: Always check IsOk() or IsErr() on a Result<T> before calling GetValue() or GetError().
Handle both success and failure paths explicitly.
Use ValueOr(defaultValue) when a default is acceptable.

Pitfall 15: Out-of-order multi-map operations

The session is now multi-map: MapChange(data), MapAdd(data), MapRemove(map) mutate an unordered_map<Map, Data> rather than incrementing a single scene counter. Calling them out of order — or assuming OnMapChange always means "everything just changed" — leads to ghost objects and lost map state.

C++

// WRONG: assumes OnMapChange means "the single scene was replaced"
subs += client->OnMapChange.Subscribe(
    [](const std::unordered_map<FusionCore::Map, FusionCore::Data>& maps, bool initial) {
        unload_everything();          // wrong on MapAdd!
        load_first_map(maps);
    }
);

// CORRECT: diff the new table against the previously known set
subs += client->OnMapChange.Subscribe(
    [&knownMaps](const std::unordered_map<FusionCore::Map, FusionCore::Data>& maps,
                 bool initial) {
        for (const auto& [id, data] : knownMaps) {
            if (maps.find(id) == maps.end()) unload_local_map(id);
        }
        for (const auto& [id, data] : maps) {
            if (knownMaps.find(id) == knownMaps.end()) load_local_map(id, data);
        }
        knownMaps = maps;
    }
);

Rule: treat OnMapChange as a diff event, not a "scene reload" signal. Only the master client should call MapChange / MapAdd / MapRemove; gate the calls with IsMasterClient().

Pitfall 16: Predicted-input ordering and OnPredictionReset

For ObjectOwnerModes::PlayerPredicted objects under SimulationMode::Authority, the SDK pumps inputs through RPC_InternalInput. The server may reject sequences that arrive out of order, fire OnPredictionReset, and re-apply from the latest authoritative state. Integrations that assume Rpc::Sequence is monotonic on the wire — or that ignore the reset — will replay stale inputs.

C++

// WRONG: never replays after a reset
root->QueueInput(dt, payload);
root->ExecuteInputs(dt);

// CORRECT: clear the local replay queue when the server resets
subs += client->OnPredictionReset.Subscribe(
    [](FusionCore::ObjectRoot* root) {
        clear_local_input_replay(root);
    }
);

Rule: subscribe to both OnPredictionOverride (authoritative state diverged from prediction) and OnPredictionReset (server rejected an input sequence) on every predicted root. Always allocate Rpc::Sequence via SendUserRpc rather than setting it manually — the SDK rewrites it in place.

Pitfall 17: FusionCAPI event-queue cadence and blob lifetime

When integrating via the flat C ABI (FusionCAPI), events arrive through a polled queue. The blob buffer returned by event_queue_blob is owned by the queue and invalidated by the next event_queue_poll or event_queue_flush call. Holding the pointer across frames or accumulating events without flushing leads to crashes or unbounded memory growth.

c

// WRONG: holds blob across frames
const uint8_t *blob = event_queue_blob(q, &len);
store_for_later(blob, len);          // dangling after next poll

// CORRECT: copy out, then flush
const uint8_t *blob = event_queue_blob(q, &len);
copy_into_owned_buffer(blob, len);
event_queue_flush(q);

Rule: in the FusionCAPI path, poll the event queue every frame, copy any blob bytes you need to keep, and call event_queue_flush(q) before the next poll. The queue is cheap; never let it accumulate across multiple frames.

Pitfall 18: MSVC runtime mismatch (_mt vs _md)

Builds now embed the MSVC runtime variant in the output filename: fusion_windows_x64_release_mt.lib (Static MultiThreaded) vs fusion_windows_x64_release_md.lib (DLL MultiThreadedDLL). The CMake cache flag FUSION_MSVC_RUNTIME_LIBRARY controls which prebuilt PhotonRealtime variant under FusionCore/deps/realtime/lib/<platform>/<arch>/ is linked; the wrong-runtime variants are filtered out automatically.

If the integration is built with one runtime but linked against the SDK's other-runtime variant, the result is heap corruption at the first allocation that crosses the boundary — Fusion allocates with one CRT and the integration frees with the other.

bash

# Standalone Windows, /MT runtime:
cmake --preset windows-x64-standalone-mt -S FusionCore
cmake --build --preset windows-standalone-mt-release

# Standalone Windows, /MD runtime:
cmake --preset windows-x64-standalone-md -S FusionCore
cmake --build --preset windows-standalone-md-release

Rule: pick MultiThreaded (/MT) or MultiThreadedDLL (/MD) for your integration and use the matching preset. Do not mix runtime variants. Engine integrations on Windows almost always want /MD so the integration, the SDK and the engine all share the runtime DLL.

Back to top