This page is for an agent writing game code that uses PsData (Claude Code, Codex, Cursor,
Copilot, …). An agent working on the plugin itself reads AGENTS.md instead.
PsData suits agents: the model is declared in plain C++ headers, every field is one greppable line,
and behaviour is configured by short meta tags rather than editor state. The catch is that several
mistakes compile cleanly and then either do nothing or stop the process with Fatal at run time.
The rules below are the ones that cost real debugging time.
To brief an agent in your project, add this to its CLAUDE.md / AGENTS.md:
Game data uses the PsData plugin (reactive data model, Plugins/PsData).
Before declaring or changing data classes, read
Plugins/PsData/docs/ai-guide.md and Plugins/PsData/docs/model.md.-
Declare model members with the macros, never
UPROPERTY. A data class isUCLASS() class UFooData : public UPsData { GENERATED_BODY() ... }withDPROP,DARRAY,DMAP,DLINK; include"PsDataAPI.h"and the class's.generated.h. AUPROPERTYon a data class is invisible to serialization, events, links and the Blueprint nodes. -
Use the accessor syntax. Read with
*Data->Field, write withData->Field = Value. Collections go through->:Data->Items->Add(X),Data->Map->Remove(Key),Data->Map[TEXT("key")],for (const auto& Pair : Data->Map). There is no mutable reference to the underlying container; every write goes through the proxy so it can broadcast. -
No
Eventmeta, no event.Data->Field.Bind(...)on a field declared withoutDMETA(Event)compiles and never fires. AddDMETA(Event)on the line directly above theDPROP, or bind withEDataBindFlags::IgnoreFieldMeta. AddBubblesonly when an ancestor needs to hear it.StrictandEventcannot be combined. -
UPsDataEvent::Changedis deferred and per object. It fires once, after the current code returns, on the object whose own field changed — not on its parents. For a synchronous reaction to one field, bind that field. -
Map keys are
[A-Za-z0-9_-]only. A key with a dot, space, slash or colon isFatalin development builds the moment the map is set. Never use display names, emails or composite ids as keys without mapping them to safe strings first. -
Links need a root and a resolvable key.
LinkByX.Get()walks fromGetRoot(): the owner must be attached under aUPsDataRoot, and a link withoutDMETA(Nullable)whose key is empty or absent isFatalon access. Mark optional linksNullableand null-check the result. After loading data, runRoot->Validation()— it reports every broken link without crashing. -
Build the model after PsData is ready. Reflection compiles at
PostEngineInit, and in a packaged gameUGameInstance::Initand game-instance subsystems run before that. Create model objects insideFDataDelegates::PostDataModuleInitPromise.Bind(...), which runs at once if PsData is already initialised. Code that works in PIE and fails in a packaged build is the symptom of getting this wrong. -
One parent per data object. Adding an object that already has a parent is
Fatal. Move it by removing it first, or addOriginal->Copy<UFooData>(). -
Unbind what you bind. Keep every
FPsDataBindin anFPsDataBindCollectionmember and callUnbind()inEndPlay/Deinitialize/NativeDestruct. A lambda capturingthisthat outlives its actor is a crash waiting for the next change. -
Keep the root alive. Data objects are
UObjects. Hold the root in aUPROPERTY()of a subsystem or game instance; children are kept alive by the tree. -
Defaults are not serialized. A field still at its default value is left out of JSON and binary. When another system needs every field (a backend contract, a diff tool), set
Serializer.bWriteDefaults = true. -
Renaming a field is a wire change. Saved games, server payloads and DataTables use field names as keys. Keep the old key with
DMETA(Alias = OldName)when renaming, and expect the Blueprint nodes for that field to need replacing. -
Game thread only. No PsData call from a worker thread;
DataSerializeAsyncis the one supported way to move serialization work off the game thread. -
Set defaults in
InitProperties(), not in a constructor. It runs on creation and on everyReset(), including the reset that precedes a non-patchDataDeserialize.
- It compiles is not evidence: rules 3, 5, 6 and 7 all compile.
- Write an automation test next to your model, shaped like the plugin's own
(
Plugins/PsData/Tests/PsDataTests): build a small tree, bind, mutate, assert counts, round-trip through a serializer, compareGetHash().PsData.Events.Deferredshows how to flush the deferredChangedevent inside a test (FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread)). - Run
Root->Validation()after loading data and fail loudly on any line it returns. - Inspect a class at run time with the console command
PsData.TypeInfo UFooData: its fields, links and every path in the tree that leads to it. - Grep before you add.
grep -rn "DPROP(.*, Health)" Source/finds every declaration; the event name is always<Field>Changedunless the meta saysEvent = Name.
| Need | Page |
|---|---|
| Macro, meta tag or type reference | model.md |
| Which event fires when | events.md |
| Saving, loading, network payloads, DataTables, hashes | serialization.md |
| What Blueprints can do with the model | blueprints.md |
| A shape that already works in production | cookbook.md |