* Write the Unreal documentation set: design, steps, ideas, decisions and specs Rewrites the design and engineering docs of the two earlier Unity projects (Adventurer Guild, Project Malleable) for an Unreal Engine 5 prototype that builds and proves three things in order: a movement controller, fighting and crafting. Neither earlier core loop is carried; their ideas are catalogued as features with what each needs. - Docs/Design.md: the human summary (pillars, fixed decisions, glossary) - Docs/Steps.md: a ladder of fifteen proofs, the first six in full - Docs/Ideas.md: salvaged ideas from both projects, none scheduled - Docs/Decisions.md: D-01..D-36, open decisions OD-01..OD-08, deferrals - Docs/Spec/: notation, Architecture, Movement, Interaction, Combat, Crafting, Networking, Telemetry, UI, written as Unreal C++ skeletons - CLAUDE.md and README.md for the repository * Add the stat block: one attribute set on every body, every influence an effect Every body (player, enemy, NPC) carries the same UStatBlockAttributeSet, and every outside influence on it is a gameplay effect: base values, buffs, debuffs, ground surfaces, carried weight, gear, being downed. Counters are tags and attributes on the receiver: immunity blocks by tag, resistance scales by attribute, cleanse removes by tag. Same status from several sources: strongest wins; different statuses multiply. - Docs/Spec/Stats.md: the block, effects and their five sources (abilities, areas, surfaces, items, the world), stacking, counters, readers, tests - Movement: the movement component's tagged speed-multiplier map is removed; speed is tuning times the MoveSpeed attribute; the ground surface trace turns mud into an effect; the gym gets mud and ice patches - Combat: the attribute set moves to Stats; enemies and kits carry FStatBlockDefaults; the funnel reads MagicResist and Immune.Damage tags; taunt and mark are statuses - Interaction: carried weight is GE_Encumbered against CarryCapacity - Crafting: class bonuses are the WorkSpeed and WorkQuality attributes; enchantments may grant a holder effect - Steps: step 5 builds the block with mud, a volume and an immunity - Decisions D-37 and D-38; design summary, CLAUDE.md, indexes, worklog
181 lines
9.2 KiB
Markdown
181 lines
9.2 KiB
Markdown
# Telemetry
|
|
|
|
Owns the seam gameplay emits events through, the envelope, the format on disk and the catalogue of events. It
|
|
does **not** own any backend, storage or analysis; there is none, and the format is chosen so one can be bolted
|
|
on later without touching the game.
|
|
|
|
Read [Architecture.md](Architecture.md) first. Step 2 in [`../Steps.md`](../Steps.md), before the character
|
|
exists, on purpose.
|
|
|
|
## Why this exists before there is anyone to measure
|
|
|
|
Two reasons, and the second is the real one.
|
|
|
|
1. **Retrofitting emit calls is expensive and lossy.** Instrumenting a finished system means re-reading it and
|
|
guessing what mattered at the time. Instrumenting as it is written costs a line.
|
|
2. **Some questions can only be answered with data that starts early.** Which camera mode people live in, whether
|
|
the anvil is fun or a chore, whether a crafted sword's damage curve is sane, whether prompts explain themselves.
|
|
Every one of those is a distribution over sessions, and the sessions that never emitted are gone.
|
|
|
|
So the schema is fixed now, the sink writes a local file, and the catalogue grows one step at a time the way the
|
|
cheats do.
|
|
|
|
## Decisions
|
|
|
|
```
|
|
[DECIDED] One subsystem, one sink interface, three sinks on day one: null (the shipping default), log (the
|
|
editor), JSON Lines to a file (playtests). The emitter never knows where events go.
|
|
```
|
|
|
|
```
|
|
[DECIDED] JSON Lines. One event per line, UTF-8, snake_case keys. Append-only, so a crash mid-write loses one line
|
|
and not the file; schema-flexible, so a new field breaks no old reader; greppable during development; and the
|
|
native food of every log and analytics pipeline that might sit downstream. Events, never aggregates: every metric is
|
|
computed from raw facts later, because aggregation choices change and raw events do not.
|
|
```
|
|
|
|
```
|
|
[DECIDED] No personal data. Player identity on the wire is the server-minted player id, hashed. No names, no
|
|
platform ids, no chat, no free text. Established now because a field is much harder to remove than to never add.
|
|
```
|
|
|
|
## Layout
|
|
|
|
```
|
|
Source/<Project>Core/Telemetry/
|
|
├── TelemetrySink.h // ITelemetrySink, FNullTelemetrySink, FLogTelemetrySink, FJsonlTelemetrySink
|
|
├── TelemetryEvent.h // FTelemetryEvent, FTelemetryEnvelope
|
|
└── TelemetryEvents.h // the event name constants: TelemetryEvents::PlayerDowned etc. Never a literal at a call site.
|
|
|
|
Source/<Project>/Core/
|
|
└── TelemetrySubsystem.h // UGameInstanceSubsystem: owns the sink, stamps the envelope, exposes Emit
|
|
```
|
|
|
|
## Types
|
|
|
|
```cpp
|
|
// Source/<Project>Core/Telemetry/TelemetryEvent.h
|
|
struct FTelemetryEvent
|
|
{
|
|
FName Name; // from TelemetryEvents, never a literal
|
|
TSharedPtr<FJsonObject> Payload; // event-specific fields; built with a small fluent helper
|
|
// The subsystem stamps the envelope; call sites fill Name and Payload only.
|
|
};
|
|
|
|
struct FTelemetryEnvelope // stamped by the subsystem onto every event
|
|
{
|
|
int32 SchemaVersion = 1;
|
|
FGuid EventId; // unique per emission, for de-duplication
|
|
FDateTime TimestampUtc;
|
|
float GameTime; // seconds since the world began
|
|
FGuid SessionId; // minted by the server, adopted by clients on join
|
|
FString PlayerId; // hashed server-minted id; empty on the server
|
|
bool bIsServer; // authoritative events versus observed ones
|
|
int32 PartySize;
|
|
FString Build; // FApp::GetBuildVersion() plus the git hash from Scripts/
|
|
FString Map;
|
|
bool bCheatsUsed; // sticky true after the first cheat this session: tainted sessions are filterable, not deleted
|
|
};
|
|
|
|
// Source/<Project>Core/Telemetry/TelemetrySink.h
|
|
class ITelemetrySink
|
|
{
|
|
public:
|
|
virtual ~ITelemetrySink() = default;
|
|
virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) = 0;
|
|
virtual void Flush() {}
|
|
};
|
|
// FNullTelemetrySink: does nothing. The shipping default until there is somewhere to send anything.
|
|
// FLogTelemetrySink: one line of JSON to the output log under LogTelemetry. Verifies a step emitted what it claims.
|
|
// FJsonlTelemetrySink: appends to Saved/Telemetry/session_<utc>_<id>.jsonl through a queue drained by a background
|
|
// task every two seconds and on Flush; the game thread only builds a small object per event. Flushed on quit and
|
|
// from the unhandled-exception handler so a crash loses at most two seconds of events.
|
|
```
|
|
|
|
```cpp
|
|
// Source/<Project>/Core/TelemetrySubsystem.h
|
|
UCLASS()
|
|
class UTelemetrySubsystem : public UGameInstanceSubsystem
|
|
{
|
|
GENERATED_BODY()
|
|
public:
|
|
void Emit(FName Name, TSharedPtr<FJsonObject> Payload = nullptr); // stamps the envelope, hands to the sink
|
|
void BeginSession(FGuid SessionId); // server mints; a client adopts the replicated id
|
|
void SetSink(TUniquePtr<ITelemetrySink> Sink); // Log in the editor, Jsonl with -telemetry, Null otherwise
|
|
void MarkCheatUsed(); // called by every bs.* console command
|
|
};
|
|
```
|
|
|
|
**There is no static `Telemetry::Log(...)` and there will not be one.** Gameplay code resolves the subsystem from
|
|
its game instance once and keeps the pointer. A static helper is a global with gameplay state in it, which
|
|
[Architecture.md](Architecture.md) bans, and it makes every emitting class untestable.
|
|
|
|
## Where the sink lives on each peer
|
|
|
|
Every peer emits its own events to its own file. The server's file is the authoritative record of what happened;
|
|
a client's file is the record of what that player saw and did. `session_id` is the same across all of them and
|
|
`is_server` tells them apart. Nothing is forwarded, because the interesting questions are per-peer by nature.
|
|
|
|
Which sink is installed: the log sink in the editor, the file sink when the executable is launched with
|
|
`-telemetry` or the console variable `telemetry.File 1` is set, the null sink otherwise. Playtests run with the file
|
|
on and the folder is collected by hand afterwards; that is the whole backend for now.
|
|
|
|
## The catalogue
|
|
|
|
Each feature spec's Telemetry section lists what that feature emits; this table is the rollup and the two must
|
|
agree. A row is added to a spec and here in the same pull request as the emit call.
|
|
|
|
| Group | Events |
|
|
| --- | --- |
|
|
| Session | `app_started`, `session_started`, `session_ended`, `player_joined`, `player_left`, `settings_changed`, `cheat_used` |
|
|
| Movement | `movement_sample`, `jump`, `land`, `camera_mode_changed` |
|
|
| Interaction | `prop_interacted`, `interaction_refused`, `object_picked_up`, `object_dropped`, `object_thrown`, `object_handed_over` |
|
|
| Stats | `status_applied`, `status_blocked`, `status_removed` |
|
|
| Combat | `enemy_spawned`, `enemy_killed`, `ability_used`, `player_damaged`, `player_downed`, `player_revived`, `player_died`, `party_wiped`, `grief_action` |
|
|
| Crafting | `substance_processed`, `piece_shaped`, `piece_enchanted`, `item_assembled`, `assembly_rejected`, `activity_completed`, `characteristic_discovered`, `weapon_equipped` |
|
|
| Technical | `perf_sample`, `error_logged`, `net_correction` (count of visible movement corrections per 30 s, from `p.NetShowCorrections`) |
|
|
|
|
### The rows that carry weight
|
|
|
|
```
|
|
item_assembled family, piece_ids[], name, quality, synergy, substance_value, time_at_bench_s
|
|
weapon_equipped family, crafted, damage, quality
|
|
enemy_killed enemy, killer_class, weapon, ability, time_to_kill_s
|
|
```
|
|
|
|
Together these are the crafting-to-combat loop as data: does a better-made sword kill faster, and by how much.
|
|
That is the one question the whole prototype exists to answer, and it cannot be answered from memory.
|
|
|
|
```
|
|
activity_completed activity, operators, quality, auto_play, faults, contribution_by_player
|
|
```
|
|
|
|
`quality` against `auto_play` says whether the anvil is fun or a chore: if the hold-to-work floor is where most
|
|
sessions live, the minigame is not earning its place.
|
|
|
|
```
|
|
interaction_refused prop, reason
|
|
assembly_rejected family, reason, part
|
|
```
|
|
|
|
The two cheapest possible measures of whether the game explains itself. A reason that dominates is a prompt that
|
|
does not.
|
|
|
|
## Tests
|
|
|
|
- Automation: the JSONL sink writes one valid line per event and never blocks the calling thread longer than
|
|
building the object; the envelope carries every field; an event with no payload serialises as an empty object.
|
|
- Functional: launching the gym with the log sink installed emits `app_started` and `session_started` in that
|
|
order; every event name a feature test triggers is a constant in `TelemetryEvents`.
|
|
|
|
## Open questions
|
|
|
|
- **Q1. Sampling.** `movement_sample` and `player_damaged` are the high-frequency rows. Every 5 s and every hit
|
|
are fine at prototype scale; revisit when a file exceeds a few megabytes per session.
|
|
- **Q2. An analytics provider.** The engine has an `IAnalyticsProvider` interface that several backends implement.
|
|
When there is a backend, a fourth sink adapts to it; the emitter does not change.
|
|
- **Q3. Opt-out.** A playtest build needs a telemetry toggle in settings before strangers play it. With the
|
|
settings pass, not before.
|
|
- **Q4. Typed payloads.** A JSON object per event is convenient and unsafe. A typed struct per event name is the
|
|
later answer if typos in payload keys start costing analysis time.
|