Skip to content

Latest commit

 

History

History
296 lines (232 loc) · 9.32 KB

cookbook.md

File metadata and controls

296 lines (232 loc) · 9.32 KB

Cookbook

Patterns from games that ship on PsData — most visibly DAWG, a competitive tactical shooter whose client, dedicated server and meta-server protocol all speak one PsData model. The code is reduced to the pattern and renamed (Foo), but each recipe is the shape used in production.

One root, mutation by role

The root holds every branch of the game's data. Branches that only one system may change are declared with DPROP_CONST, which hands the mutable side to a single friend class and a const view to everyone else — the compiler enforces who writes what.

UCLASS()
class UFooRootData : public UPsDataRoot
{
    GENERATED_BODY()

    /** Static data */
    DPROP_CONST(UFooConfigData*, ConfigData, class FFooConfigDataAccessor);

    /** Static data */
    DPROP_CONST(UFooWorldData*, WorldData, class FFooWorldDataAccessor);

    /** The server-authoritative profile */
    DPROP_CONST(UFooUserData*, UserData, class FFooUserDataAccessor);

    /** Match state */
    DPROP_CONST(UFooBattleData*, BattleData, class FFooBattleDataAccessor);

    /** Anyone may change settings */
    DMETA(Strict)
    DPROP(UFooSettingsData*, SettingsData);

    /** Client-only scratch state */
    DMETA(Strict)
    DPROP(UFooRuntimeData*, RuntimeData);
};

class FFooWorldDataAccessor
{
private:
    friend class UFooDataSubsystem;  // the only writer of static data

    static UFooWorldData* GetMutable(UFooRootData* RootData)
    {
        return *RootData->WorldData.Mutable;
    }
};

Everyone reads RootData->WorldData->Weapons; only UFooDataSubsystem can write through FFooWorldDataAccessor::GetMutable(RootData).

Static data from DataTables, validated at cook time

Designers own balance in DataTables; code loads every table into the WorldData branch once, when PsData is ready, and links from live state into it.

void UFooDataSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);
    FDataDelegates::PostDataModuleInitPromise.Bind(
        FSimpleDelegate::CreateUObject(this, &UFooDataSubsystem::LoadStaticData));
}

void UFooDataSubsystem::LoadStaticData()
{
    RootData = NewObject<UFooRootData>(this);
    const auto World = FFooWorldDataAccessor::GetMutable(RootData);

    FPsDataTableDeserializer Weapons(
        WeaponsTable.LoadSynchronous(), World->Weapons.Get());
    World->DataDeserialize(&Weapons, true);

    FPsDataTableDeserializer Characters(
        CharactersTable.LoadSynchronous(), World->Characters.Get());
    World->DataDeserialize(&Characters, true);

    for (const FString& Error : RootData->Validation())
    {
        UE_LOG(LogFoo, Error, TEXT("Static data: %s"), *Error);
    }
}

DAWG also runs this load, with its validation, inside the cook commandlet and reports every problem there, so a renamed row or a typo in a key is caught before a build ships rather than in a match.

Settings that save themselves

Settings live in the tree like everything else. A single Changed listener saves them; the content hash keeps a burst of edits — or an edit and its undo — from touching the disk.

void UFooSettingsSubsystem::Load()
{
    if (const auto SaveGame = UFooSettingsSaveGame::Load())
    {
        FPsDataFastJsonDeserializer Deserializer(SaveGame->SettingsJson);
        Settings->DataDeserialize(&Deserializer);
    }
    LastSavedHash = Settings->GetHashInt32();

    Binds.Add(Settings->Bind(UPsDataEvent::Changed,
        FPsDataDelegate::CreateUObject(this, &UFooSettingsSubsystem::OnSettingsChanged)));
    Binds.Add(Settings->MasterVolume.Bind(
        FPsDataDelegate::CreateUObject(this, &UFooSettingsSubsystem::ApplyAudio)));
}

void UFooSettingsSubsystem::OnSettingsChanged(UPsDataEvent* Event)
{
    const int32 Hash = Settings->GetHashInt32();
    if (Hash == LastSavedHash)
    {
        return;
    }
    LastSavedHash = Hash;

    FPsDataFastJsonSerializer Serializer;
    Settings->DataSerialize(&Serializer);

    const auto SaveGame = UFooSettingsSaveGame::Create();
    SaveGame->SettingsJson = Serializer.GetJsonString();
    SaveGame->Save();
}

Adding a setting is one DPROP line: it is saved, loaded, defaulted by InitProperties() and observable from that moment on.

Server-to-client snapshots in one RPC

The match state is a subtree. The server serializes it to binary and sends it as one string inside whatever transport the game already has (an RPC, a transaction, a reliable message); the client deserializes into its own copy of the subtree, and every client-side listener fires for what changed.

// server
const auto Output = MakeShared<FPsDataBufferOutputStream>();
FPsDataBinarySerializer Serializer(Output);
Room->DataSerialize(&Serializer);
ClientSyncRoom(FBase64::Encode(Output->GetBuffer()));

// client
void AFooPlayerController::ClientSyncRoom_Implementation(const FString& Snapshot)
{
    TArray<uint8> Bytes;
    FBase64::Decode(Snapshot, Bytes);

    FPsDataBinaryDeserializer Deserializer(MakeShared<FPsDataBufferInputStream>(Bytes));
    BattleData->Room->DataDeserialize(&Deserializer);
}

For continuous replication of a subtree instead of explicit snapshots, see UPsNetworkData in serialization.md.

A backend protocol made of data classes

Requests and responses to the meta-server are PsData classes too. One generic helper turns any of them into JSON and back, so a new endpoint is a pair of class declarations.

template <typename T>
TSharedPtr<FJsonObject> ToJson(const T* Value)
{
    FPsDataJsonSerializer Serializer;
    // The backend sees every field, not just the non-default ones
    Serializer.bWriteDefaults = true;
    Value->DataSerialize(&Serializer);
    return Serializer.GetJson();
}

template <typename T>
T* FromJson(const TSharedPtr<FJsonObject>& Json)
{
    const auto Data = NewObject<T>();
    FPsDataJsonDeserializer Deserializer(Json);
    Data->DataDeserialize(&Deserializer);
    return Data;
}

Responses that update existing state go the other way: patch them into the live tree with DataDeserialize(&Deserializer, true) and let the events drive the UI.

Server notifications in DAWG carry updates of several kinds in one message. The message declares them as FPsDataJson — it does not need to know their shape — and the client deserializes each one into the class that owns it:

UCLASS()
class UFooNotifyData : public UPsData
{
    GENERATED_BODY()

public:
    DMAP(FPsDataJson, UpdateItems);    // item id -> the item as the server sees it now
    DMAP(FPsDataJson, UpdateFriends);
};

for (const auto& Pair : Notify->UpdateItems)
{
    UFooItemData* Item = UserData->Items[Pair.Key];
    FPsDataFastJsonDeserializer Deserializer(Pair.Value.GetJsonString());
    Item->DataDeserialize(&Deserializer, true);   // patch: only what changed fires events
}

FPsDataSchemaJson::Get() describes every class for code generation on the backend side.

Actors that mirror data

Gameplay actors do not own state; they point at data and follow it. Replicate only the key, look the data up in the local tree on each side, bind, and let the data's lifetime drive the actor's.

void AFooBomb::OnBombDataReady(const UFooBombData* InBombData)
{
    BombData = InBombData;
    Binds.Add(BombData->Bind(UPsDataEvent::Removed,
        FPsDataDelegate::CreateUObject(this, &AFooBomb::OnBombRemoved)));
    Binds.Add(BombData->State.Bind(
        FPsDataDelegate::CreateUObject(this, &AFooBomb::OnStateChanged)));
}

void AFooBomb::OnBombRemoved(UPsDataEvent* Event)
{
    Destroy();  // the data left the tree, so the actor goes too
}

void AFooBomb::EndPlay(const EEndPlayReason::Type Reason)
{
    Binds.Unbind();
    Super::EndPlay(Reason);
}

The same shape drives character meshes (bind the inventory's Added/Removed and the equipped skin's changed event), HUD widgets and animation instances.

Polymorphic children

Effects on a character are a map of different classes. CustomType writes the concrete class with each element, so the map round-trips through every serializer as the right subclasses.

DMETA(Event, CustomType)
DMAP(UFooEffectData*, Effects);  // holds UFooStunEffectData, UFooBurnEffectData, ...

template <class T>
TArray<const T*> GetEffects() const
{
    TArray<const T*> Result;
    for (const auto& Pair : Effects)
    {
        if (const auto Effect = Cast<T>(Pair.Value))
        {
            Result.Add(Effect);
        }
    }
    return Result;
}

Local prediction next to authoritative state

A client predicts its own weapon state while the server's version keeps arriving. Both live in the same character data; the predicted one is Hidden, so it never goes over the wire or into a save, and EventStorage gives listeners the old value to animate from.

DMETA(Event, EventStorage)
DPROP(FFooDeviceState, ServerDevice);

DMETA(Event, EventStorage, Hidden)
DPROP(FFooDeviceState, LocalDevice);

(FFooDeviceState is a custom value type — see model.md.)

Waiting for readiness

Systems start in an order you do not fully control. A promise turns "is it ready yet?" into "run this when it is", with no special case for "already happened":

// Resolved by the game instance once every subsystem has started
FPsDataSimplePromise SubmodulesReady;

SubmodulesReady.Bind(FSimpleDelegate::CreateUObject(this, &UFooHud::Subscribe));

FDataDelegates::PostDataModuleInitPromise is the first link in that chain.