Skip to content

Stratkit Client API

Typed model of the sup-server Client-API, generated from the Client-API JSON schema.

Design notes: game-state-parsing-design.md (read path), typed-request-path-design.md (request path), supid-mapper-mutation-queue-design.md (deferred mapper mutations).

Assemblies

Assembly Contents
Stratkit.ClientApi.Codec Wire-format primitives — NodeHandle, the streaming ClientApiReader and the buffered ClientApiJsonWriter. The only place the format (currently JSON) is visible. No schema, ECS or entity-identity knowledge.
Stratkit.SupId Entity identity — SupId, SupIdMapper, SupIdMapperDelta, the SupIdMapperReadOnly gate and the deferred mutation queue — plus the generated SupType enum.
Stratkit.ClientApi The generated read model — views, enums, unions, path constants, the path table, type ids and the typed Add…Handler / Add…IdProvider registration methods — plus the hand-written handler contract (IDataHandler<TView>, IIdProvider<TView>, IDeserializationContext, DeserializationContext, ClientApiRegistry), the walker (ClientApiWalker) and ClientApiArchetypes.
Stratkit.ClientApi.Actions The generated request model — one DTO per action, one writer each, and the ClientActionKind dispatch between them — plus the hand-written ClientApiActionSerializer and ActionWriteContext.
Stratkit.ClientApi.Generator Editor-only. Reads Schema~/ and writes Stratkit.ClientApi/Generated/, Stratkit.SupId/Generated/, Stratkit.ClientApi.Actions/Generated/ and the test-only request reader in Tests/Editor/Generated/.

The two generated models are separate traversals of one schema, from roots and actionRoots. They overlap without conflicting: ClientArmyAction.armies is an array of the same ClientArmy the read path builds a view for, and the two want different things from it — a view reads a node the server sent, a DTO accumulates one the client is about to send.

Writing a handler

A handler implements IDataHandler<TView> for its view and is registered at a position in the document, not against a @c discriminator. Identity computed from position rather than from an id property is an IIdProvider<TView>, registered the same way:

internal sealed class ResourceEntryDataHandler
    : IDataHandler<ClientResourceEntryView>, IIdProvider<ClientResourceEntryView> {
    public bool TryGenerateId(in ClientResourceEntryView view, IDeserializationContext context, out int id) {
        // …
    }

    public void Invoke(in ClientResourceEntryView view, IDeserializationContext context, EntityCommandBuffer ecb) {
        view.TryGetResourceID(out int resourceId);
        context.TryGetResourceProfileAncestor(out ClientResourceProfileView profile, out _);
        // …
    }

    public static void Register(ClientApiRegistry registry) {
        ResourceEntryDataHandler handler = new();
        registry.AddResourceEntryHandler(
            ClientApiPaths.ResourceState.ResourceProfs.Categories.ResourceEntries, handler);
        registry.AddResourceEntryIdProvider(
            ClientApiPaths.ResourceState.ResourceProfs.Categories.ResourceEntries, handler);
    }
}

The full version is com.stratkit.resource-state-loader/Runtime/ResourceEntryDataHandler.cs.

Registration stays in the module that owns the handler; nothing generated records which handlers exist. Paths are typed by the view they hold (ClientApiPath<TView>), so a handler cannot be registered at a position holding another type, and a schema rename breaks at the registration site rather than silently dropping data.

Referencing Unity.Serialization. An assembly that declares structs holding a NodeHandle (generated views, request wrappers) must reference Unity.Serialization in its .asmdef, and its package must depend on com.unity.serialization. Stratkit.ClientApi and Stratkit.ClientApi.Actions do. An assembly that only uses views, such as a handler package, does not need the reference. Without it, Unity reports CS0012 for SerializedValueView with no file or line. After you move a view or wrapper declaration to another assembly, recompile in Unity.

Regenerating

Tools → Stratkit → Client API → Generate Model, or in batch mode:

Unity -batchmode -quit -projectPath <project> \
  -executeMethod Stratkit.ClientApi.Generator.ClientApiGeneratorMenu.GenerateBatch

Generated files are committed. Regenerate after changing Schema~/ or schema-overrides.jsonc, and review the diff.

Generation fails if an emitted string does not fit the FixedString…Bytes it is assigned to, naming the file, line, value and byte count. Capacities come from the types themselves (UTF8MaxLengthInBytes). The check runs before anything is written, so a failed run leaves the committed output unchanged.

The run also writes Stratkit.ClientApi/Generated/generation-report.md, listing every property that fell back to a default C# type, request fields with no declared policy, points that do not convert, derived unions and identity groups. Anything in it is a candidate for an explicit entry in schema-overrides.jsonc.

Request path

ClientApiActionSerializer.Write is the public entry point. GameServerClient in com.stratkit.game-state-loader sends every request through it.

ClientActivateGameAction action = new() {
    GameID = gameId,
    SiteUserID = userId,
    UserAuth = auth,
    SelectedPlayerID = playerId,
};

ClientApiActionSerializer.Write(ref writer, action, new ActionWriteContext(idMapper, coordinates));

DTOs reproduce the schema's extends chain as C# inheritance, so shared fields are declared once on the base. Writers are generated rather than reflecting over the DTOs:

  • A field is written by naming it. Nothing reads an attribute, looks a member up by string, or touches a Type, so IL2CPP cannot strip a field a writer uses and no [Preserve] is needed.
  • A schema rename is a build failure, where a hand-typed wire name is silent until the server rejects the request.
  • Dispatch is a switch expression with no default arm. An action added to ClientActionKind without a writer is CS8509, which Stratkit.ClientApi.Actions/csc.rsp raises to an error. It has to stay a static switch: a dictionary of delegates would hide the call graph from the linker and still compile.

ClientActionKind is the only runtime identity a DTO has. The @c the server matches on is internal to the generated writers, so logging and telemetry read ClientActionKindNames.GetFixedName — a generated table, not Enum.GetName, which reflects and allocates.

Polymorphic positions get an interface and a discriminant enum apiece: IClientUserAction for anything the client can send, IClientArmyCommand for the orders inside an army action, IClientActionProvinceUpgrade for the upgrade shapes.

What the schema cannot say about a request

These override sections exist only for the request path, and describe the server contract rather than C# storage:

Section What it declares
actionRoots The action schema files the request traversal starts from.
omitWhen When a field is left out — null (the default, needs no entry), default, never (writes an explicit null), or an integer sentinel the field is compared against.
entityProperties That a field holds an Entity serialising as a server id, which identity type, and what Entity.Null writes (onNull).
coordinateProperties That a point is in the client's own space and converts on the way out.
unconvertedPoints That a point deliberately does not convert.
numericTypeCorrections That the schema's type is wrong and the wire carries a number. Distinct from stringEncodedNumbers, which means the wire carries quoted digits and lets the reader accept either — a writer has to pick one.
requiredOverrides Where the schema's required flag disagrees with the server, in either direction. Reported, never enforced.
openEnums Fields that name an enum but accept values it does not list. The field stays an integer.
actionFields Which fields of a shape shared with the read path the request DTO keeps.
discriminators The @c a request shape sends where the schema has none, or null where the schema declares one the client does not send.
addedProperties Fields a request shape sends that the schema does not declare.

Nothing is inferred from a schema default. A default says the server fills the field in when it is absent, which is not the same claim as the client never sending it. An undeclared field is written whenever it is set, and listed under Request fields with no declared policy in the generation report.

Shapes shared with the read path. ClientArmy is what the server sends and what the client sends back, and the client sets only a few of its fields. actionFields trims the request DTO to those; the read view still has every field. The trim runs during traversal, so a type reachable only through a trimmed field leaves the request model. A trimmed field that a builder later needs is a compile error at the assignment, not a missing key on the wire.

"actionFields": {
  "ClientArmy": ["id", "c", "fm", "ag", "s", "o", "u"]
}

Points. Every point the client sends is in the client's own coordinate space. The conversion is y → mapSize.y - y, a reflection with a translation: right for a position, wrong for a direction, which needs y → -y. So a point is declared in coordinateProperties (converts) or unconvertedPoints (does not). A point in neither is reported under Points that do not convert, meaning nobody has looked at it yet.

Entity identity while a pass runs

Reading SupIdMapper never needs a guard. A deserialization pass reads it but writes to a SupIdMapperDelta, which DeserializationContext.CommitIdentities applies once the pass's command buffer has been played back. The mapper therefore holds only realised entities at every moment your system could look at it — never an EntityCommandBuffer placeholder. Mid-update it is one update behind, which is what any frame between updates looks like anyway.

Destroying is different. A pass spans frames and records commands against entities it resolved in an earlier slice; destroying one leaves the buffer holding a reference playback cannot honour. If your system destroys, unmaps or re-keys an entity the server identifies, queue the change on the mapper singleton instead of doing it directly:

DynamicBuffer<SupIdMapperPendingMutation> queue = SystemAPI.GetSingletonBuffer<SupIdMapperPendingMutation>();
queue.EnqueueRemove(supId);
queue.EnqueueDestroy(entity);

SupIdMapperMutationQueueDrainSystem applies the queue at the end of SimulationSystemGroup once no pass is in flight. A re-key is EnqueueRemove followed by EnqueueAdd.

A system whose trigger persists until it acts (a tag it removes itself) can instead skip frames while a pass is in flight:

private EntityQuery _passInFlight;

public void OnCreate(ref SystemState state) =>
    _passInFlight = SystemAPI.QueryBuilder().WithAll<SupIdMapperReadOnly>().Build();

public void OnUpdate(ref SystemState state) {
    // RequireForUpdate ignores IEnableableComponent state, so this belongs here.
    if (!_passInFlight.IsEmpty) {
        return;
    }
    ...
}

SupIdMapperReadOnly is enableable and lives on the mapper singleton, and a query matches it only while it is enabled. Do not use this for a one-frame trigger, such as a reactive pulse: skipping that frame loses it.

The driver's side is two lines, and nothing may come between them:

ecb.Playback(EntityManager);
context.CommitIdentities(EntityManager);

A system running in that gap would see a world holding the new entities and a mapper that does not know them.

Running a pass

The driver lives in com.stratkit.game-state-loader: ClientApiPassSetupSystem builds the ClientApiRegistry and ClientApiArchetypes singletons before any module's setup system runs, then — once every module has registered, after AllModulesInitialisedTag is set — builds the reader, the DeserializationContext and the ClientApiWalker, held together on the ClientApiPassContainer singleton for the life of the world.

GameStatePass.Start starts a pass on the game state entity from a pending GameStateResponse request and stores the running task there. When the server answers with a switch-server exception, the pass resends the request to the new host itself. The finished pass carries the recorded EntityCommandBuffer, the ClientApiResponse read from the envelope and the server's log events, and ApplyGameStateSystem picks it up. A finished pass does not mean the server was happy — a result code and an exception are both complete, well-formed responses that contain no state, so ApplyGameStateSystem checks Response.Kind before treating a pass as an update.

Two things will bite anything that drives WalkAsync directly:

  • Start the walk on the main thread. The walker reads the response on a worker thread, but dispatches every node to handlers on the thread that called it, resuming through Unity's synchronization context. That is what lets the context read the shared SupIdMapper without a lock. ConfigureAwait(false) or starting the walk from Task.Run turns a safe read into a race.
  • The walker is built last. It derives its skip set from the registry in its constructor, so every module must have registered before it exists. A handler registered after that is never dispatched to; ClientApiRegistry logs an error when it happens.

Schema input

Schema~/ holds a pinned copy of the schema files; Schema~/VERSION records the tag and commit they came from. The ~ suffix keeps the JSON files out of the AssetDatabase — the generator reads them with System.IO.

Upgrading the schema is a deliberate commit: replace Schema~/, update VERSION, regenerate, review the diff.

Derived unions

Not every alternation is written out as a oneOf. Some of it is only in the extends edges, and the generator reads those as a second source of union membership. Both rules are printed under Derived unions in the generation report.

An abstract base becomes a union. A type that declares no @c of its own but is extended by types that do is never what the server sends — nothing could identify it on the wire. So a position naming it holds one of its descendants, and the generator emits a union of them. ClientGameEventState.gameEvents is declared as an array of AbstractClientGameEvent; it resolves to ClientGameEventView, narrowing to the concrete event types. The Abstract prefix is dropped the same way a javaInterfaces union drops the leading I.

The union keeps the base's own properties, which is the difference between an abstract class and a oneOf: eventID, time, name and the rest stay readable at the un-narrowed position, and senders stays one path there rather than becoming one per event type. Deeper hierarchies flatten — the concrete leaves are the members, not the abstract levels in between.

A oneOf over one base is completed from the same edges, and adopts its properties too. goals/ClientGameGoal.json lists only some of the types extending AbstractClientGameGoal; the rest are sent all the same. A union whose members all extend the same base takes that base's remaining descendants, so a goal added server-side joins without anyone noticing it was missing. It also carries the base's own properties, so ClientGameGoalView reads goalId, contentItemId and goalStatus without narrowing — the same shape a base-derived union has.

The test is on the immediate parent and needs at least two declared members, so a union of unrelated shapes — ClientActionResult, ClientLocation, ClientUpdatableStates — is left exactly as declared.

Id ledger

client-api-ids.json records which numeric id belongs to which type and which document position. Ids are allocated once and kept: a name already in the ledger keeps its id, a new name takes the watermark, and a name the schema drops stays recorded so its id is never handed to anything else. Adding a type is a one-line diff instead of renumbering most of the enum, and an id stays meaningful across a schema upgrade — which is what makes it safe for one to reach a save file, an analytics event or a crash breadcrumb.

Precedence when an id is first assigned:

  1. an entry already in the ledger,
  2. the schema's stateType constant, where the type has one — that is why ClientArmyState = 6,
  3. allocation, from 100 upwards for types and 1 upwards for paths.

Ids at or above 100 000 are never allocated automatically. That block is for ids assigned by hand — content-item types today. nextTypeId and nextPathId are stored in the ledger and never decrease, so deleting the highest entry cannot hand its id to something else.

SupType.None is -1, all bits set, because stateType: 0 belongs to a real state. default(SupId) is therefore not a null id.

The generator fails rather than guessing when two names claim one id, and the error names both and shows the fix: pin one in the ledger by hand (a pinned entry outranks the schema), or merge the two with typeAliases if they are one shape.

The ledger is generator output as well as input. Commit it alongside the generated code; deleting it renumbers everything.

It stays strict JSON — unlike the overrides file it is rewritten on every run, so a hand-written comment would be deleted by the next generation. It is read with MissingMemberHandling.Error, and written by hand rather than serialized so the newlines are LF on every platform.

Schema overrides

schema-overrides.jsonc is JSON with // comments, which Newtonsoft reads natively. It is deserialized into SchemaOverrides with MissingMemberHandling.Error, so a section name that matches no member fails the run rather than doing nothing quietly. Numeric widths are checked against the set of C# numeric types too, which catches both a typo in a value and the one hole the deserializer leaves — Newtonsoft coerces a JSON number to a string, so "defaultIntegerType": 7 would otherwise be emitted as a type name.

It supplies what the schema cannot express about C# types — numeric widths, string-encoded numbers, custom value types, roots, union members the schema neither declares nor implies, and type aliases. It is not a registry of what we handle; there is no allow-list and no handler paths.

Most sections serve both traversals. The request-only ones are described under Request path.

untypedValues is the one section that is asserted rather than derived, and worth treating differently for it. Everywhere else the schema is the authority and the overrides only sharpen it, so a wrong entry eventually contradicts something. Here the schema says nothing at all — "type": "object" with existingJavaType: "Object" — so nothing will ever contradict a wrong entry: a value that stops being a number simply reads as absent. Prefer the Java type where there is one; reach for this only when there is not.

droppedTypes leaves a whole schema type out — no view, no path, no handler registration, and removed from any union that lists it. Use it for types the schema itself marks as no longer used. Dropping a type drops what only it could reach. It is also one way to clear a discriminator too long for its FixedString128Bytes field (see Regenerating); widening the field is the other.

droppedProperties leaves a property out of the model entirely, for one the server still sends and the schema still describes but nothing reads — for example ClientPlayerStatisticsState.statistics, which its own description calls "Old stuff kept for backwards compatibility". This is about generated surface, not about parsing — the walker skips what nothing subscribes to regardless.

unionMembers carries only what cannot be read off the schema at all. States extend nothing, so a state's membership of ClientUpdatableStates has to be stated here. Anything a class hierarchy already describes is derived instead.

typeAliases folds one schema file onto another when the two describe a single shape sent under two @c values. states/ClientAutoGameState.json is states/ClientGameState.json under a second name — the game state as an automatic update returns it — which the schema states nowhere. The alias contributes no type of its own: one view, one path subtree, one SupType, and every position holding the target accepts either discriminator. The generator compares the two declarations property by property on every run, so a server-side divergence fails the run instead of silently dropping whichever property only one of them has.

idProperties says which property holds a type's server-side id, as the wire key. A numeric id is the default, so this carries only the departures: a key where the type names its id something else (playerID, goalId), and an empty string where a type has an id that is not an identity — ClientModableUpgrade.id is the upgrade type, shared by every instance of it. Declared on a union an entry applies to every member; a union whose members all agree takes their key. The resolved key goes into the path table, so the walker reads it at every position the type occurs at with no registration.

Identity computed from position — a map key, a pair packed with an ancestor's id, an army id negated inside a spy report — is a registered IIdProvider<TView> instead, which is scoped to one path and wins over anything declared here.

identityGroups declares which types share one server-side id space. A group named after a schema union takes that union's members automatically, so only what the schema cannot state is listed — ClientLocation is the oneOf of province, seapoint and neutral province, and the entry adds ClientProvinceProperties, which is a satellite record keyed by location id rather than a location. Union expansion applies where a group is declared and nowhere else: a oneOf says the members are alternatives at one position, not that the server numbers them from one sequence. Resolved membership is printed in the generation report.

ClientGameEvent and ClientGameGoal are declared with empty member lists, which takes every event and every goal from their unions. This is what lets one handler register at the un-narrowed position and another at a narrowing and have both resolve the same node to one entity — without it the first would build SupId(ClientGameEvent, 3541) and the second SupId(ClientArmyDestroyedGameEvent, 3541), and one event would become two entities. Both groupings are the server's own numbering rather than an assumption: ClientGameEventsReadUserAction takes a flat eventIds array of integers with no type beside them, and the schema calls goalId "the globally unique id of the goal".