PHP SDK
KAT\Codex\Sdk\Codex is the server-side surface used inside api.katforge.com (controllers, EmberService, LevelService, internal commands). Same identifier grammar, same response shape as the HTTP API — but reads go straight through the repositories to Postgres, so there's no HTTP round-trip.
Construct
The SDK is a Symfony service. Inject it like any other:
use KAT\Codex\Sdk\Codex;
final class ItemController extends AbstractController
{
public function __construct (private readonly Codex $codex) {}
public function show (string $id): JsonResponse
{
$sword = $this->codex->dnd ()->item ($id);
return $this->json ($sword);
}
}
Codex
Root entry point. Resolve a Game instance and proceed from there.
game($slugOrAbbreviation, $branch = 'main')
Canonical accessor. Returns a Game bound to a specific game and branch.
$dnd = $codex->game ('dark-and-darker');
$qa = $codex->game ('dark-and-darker', branch: 'map.lich_king');
Game abbreviation methods
Driven by data/codex/games.yaml — adding a game adds the abbreviation method automatically. The current set:
$codex->dnd (); // dark-and-darker
$codex->wc3 (); // wc3
$codex->mecha (); // mechabellum
$codex->gg (); // gear-goblins
$codex->lex (); // lextris
$codex->kat (); // katforge
// Same as $codex->game ('dark-and-darker', 'map.lich_king'):
$codex->dnd ('map.lich_king');
These are syntactic sugar — each one is the same call as game('<slug>') with the right slug, generated from data/codex/games.yaml at boot. Calling ->{'dark-and-darker'}() is awkward syntax; use game('dark-and-darker') instead.
dictionary()
Returns the underlying Games registry. Useful for iterating every registered game (e.g. listing them in admin UI).
foreach ($codex->dictionary ()->all () as $entry) {
echo "{$entry ['slug']} ({$entry ['abbreviation']})\n";
}
Game
Returned by Codex::game() / ->dnd() / etc. Per-game read surface.
Single-entity accessors
Each accepts either the canonical id (id.<type>.…) or the upstream key (Id_<UpstreamPrefix>_…). The id form is type-checked at the call site — passing id.skill.heavy_swing to ->item() throws TypeMismatchException rather than silently returning a row of the wrong shape.
$dnd = $codex->dnd ();
// Typed accessor — returns Item (subclass of Entity) with extra getters.
$sword = $dnd->item ('id.item.longsword_5001');
$sword?->name; // "Longsword"
$sword?->rarity; // "epic"
$sword?->gear_score; // 50
// Upstream key works too:
$sword = $dnd->item ('Id_Item_Longsword_5001');
// Generic accessors — return Entity:
$cock = $dnd->merchant ('id.merchant.cockatrice');
$bless = $dnd->spell ('id.spell.bless');
$religion = $dnd->religion ('id.religion.faith_of_the_seven');
$prop = $dnd->itemProperty ('id.item_property.primary_longsword_5001');
// Untyped lookup — useful when iterating generic links:
$row = $dnd->entity ('id.merchant.cockatrice');
The full set of typed accessors:
item, skill, spell, perk, monster, quest, dungeon, religion, merchant, itemProperty
For everything else, entity($id) returns the base Entity wrapper.
patches($limit = 50)
Patch list newest first. Each row carries id, version, version_raw, imported_at, plus the join to parent_patch_id for forks.
$recent = $codex->dnd ()->patches (limit: 10);
foreach ($recent as $p) {
echo "{$p ['version']} {$p ['imported_at']}\n";
}
currentPatch()
The head patch on this game + branch — the same row entity_resolved_current reads from.
$head = $codex->dnd ()->currentPatch ();
$head ['version']; // "0.16.137+build.8750"
diff($from, $to)
Patch-to-patch delta. Returns a Diff (or null if either patch doesn't exist on this branch).
$delta = $codex->dnd ()->diff ('0.16.135.8645-2663', '0.16.137.8750');
$delta?->summary; // [ 'added' => 12, 'changed' => 184, 'removed' => 3 ]
$delta?->changed ('item'); // every item that changed
See Diff below for the full surface.
assetMeta($hash)
Asset row by hash — (hash, media_type, bytes). Memoized per-instance to avoid re-fetching when an entity wrapper resolves its icon multiple times.
$meta = $codex->dnd ()->assetMeta ('22cd8c50…');
$meta ['bytes']; // 8868
Escape hatches
$dnd->repository (); // EntityRepository — for queries the SDK doesn't cover
$dnd->patchRepository (); // PatchRepository — same
Entity
Generic entity wrapper. Per-type subclasses (currently Item) extend it with typed accessors. Construction is private to the SDK — callers go through Game::item() etc.
Public properties
These are genuinely tabular — every entity row carries the same shape:
| Field | Type | Notes |
|---|---|---|
$id | string | Canonical id, e.g. id.item.longsword_5001. |
$type | string | Lowercase type slug (item, skill, …). |
$game | string | Game slug. |
$branch | string | Branch slug, default main. |
$name | string | null | Locale-resolved display name. |
$tags | string[] | |
$data | array<string, mixed> | Per-game projection, mostly verbatim from upstream. |
$links | array<{ rel, target }> | Resolved cross-references. |
$iconRef | array{ hash, url } | null | Raw primary-icon ref. |
$assetRefs | array<{ role, hash, url, media_type, bytes }> | Multi-asset list. |
icon()
Returns an Asset for the primary icon (the role=icon entry, or first overall if no role is named). null when there's no asset list.
$sword = $codex->dnd ()->item ('id.item.longsword_5001');
$icon = $sword->icon ();
$icon?->url (); // "/v1/codex/assets/22cd8c50…"
$icon?->url (size: '256x256'); // CDN-hinted variant
$icon?->url (size: '256x256', format: 'webp'); // ?size=256x256&format=webp
assets()
Every asset attached to this entity, keyed by role for direct slot access. Multi-entry roles (e.g. several portraits) collapse to the first — use assetList() to keep duplicates.
$cock = $codex->dnd ()->merchant ('id.merchant.cockatrice');
$slots = $cock->assets ();
$slots ['icon']?->url ();
$slots ['portrait']?->url ();
assetList()
Ordered list, duplicates preserved. Use when an entity legitimately has multiple of one role:
foreach ($cock->assetList () as $asset) {
echo "{$asset->mediaType} {$asset->url ()}\n";
}
at($version)
Snapshot at a prior patch on the same branch. Returns the same wrapper type (an Item stays an Item).
$sword = $codex->dnd ()->item ('id.item.longsword_5001');
$old = $sword->at ('0.16.134.8542');
$old?->gear_score; // 50 (vs. 55 on the current patch)
history()
Every patch that touched this entity, oldest first.
foreach ($sword->history () as $row) {
echo "{$row ['patch_version_raw']} {$row ['content_hash']}\n";
}
diff_since($fromVersion)
Single-entity diff between an older patch and the current one. Same shape as a Diff entry but scoped to this entity.
$changes = $sword->diff_since ('0.16.134.8542');
$changes ['op']; // 'changed'
$changes ['changes']; // [ { path, before, after }, ... ]
Item
Subclass of Entity with typed magic getters over the upstream-shape data blob:
$sword = $codex->dnd ()->item ('id.item.longsword_5001');
$sword->archetype; // "weapon"
$sword->slot; // "primary"
$sword->hand; // "two_handed"
$sword->weapon_type; // "sword"
$sword->rarity; // "epic"
$sword->gear_score; // 50
$sword->inventory; // [ 'w' => 1, 'h' => 4 ]
$sword->is_tradable; // true
The raw $sword->data blob stays accessible for forensic queries that need the upstream shape. The mapping table — useful for figuring out where each getter pulls from:
| Getter | Source path | Example |
|---|---|---|
archetype | data.item_type enum leaf | "weapon" |
slot | data.slot_type tail | "primary" |
hand | data.hand_type tail | "two_handed" |
weapon_type | data.weapon_types[0] tail | "sword" |
rarity | data.rarity_type tail | "epic" |
gear_score | data.gear_score | 50 |
adv_point | data.adv_point | 12 |
exp_point | data.exp_point | 5 |
inventory | { w, h } | [ 'w' => 1, 'h' => 4 ] |
is_tradable | data.tradable | true |
Asset
Content-addressed binary asset (icon, portrait, audio).
Public fields
$asset->hash; // SHA-256, 64 hex chars
$asset->mediaType; // "image/png", "image/webp", "audio/ogg", ...
$asset->bytes; // file size in bytes
$asset->width; // ?int — reserved for future backfill
$asset->height; // ?int — reserved for future backfill
url($size = null, $format = null)
Builds the public URL. Without params returns the canonical bytes; with params appends CDN resize / format hints.
$icon->url (); // /v1/codex/assets/<h>
$icon->url (size: '256x256'); // ?size=256x256
$icon->url (size: '256x256', format: 'webp'); // ?size=256x256&format=webp
The CDN serves resized / reformatted variants by query string; the canonical URL without params is the source of truth.
Diff
Returned by Game::diff(). Wraps a list of EntityDiff entries with fluent op + type filters.
Public fields
$delta->entries; // EntityDiff[] — every entity in the diff
$delta->summary; // [ 'added' => N, 'removed' => N, 'changed' => N ]
added($type = null) / changed($type = null) / removed($type = null)
Filter by op, optionally by type. Returns EntityDiff[].
$delta = $codex->dnd ()->diff ('0.16.135.8645-2663', '0.16.137.8750');
$delta->changed (); // every changed entry
$delta->changed ('item'); // every item that changed
$delta->added ('skill'); // skills introduced this patch
$delta->removed ('quest'); // quests retired this patch
entity($id) and type sugar
Single entry by full id. null if the entity didn't appear in this delta.
$delta->entity ('id.item.longsword_5001'); // EntityDiff or null
$delta->item ('id.item.longsword_5001'); // sugar — same call
$delta->skill ('id.skill.bash');
$delta->spell ('id.spell.bless');
EntityDiff
One row of a Diff. Field-level changes for op === 'changed'; before/after snapshots for added/removed.
Public fields
$row->id; // 'id.item.longsword_5001'
$row->type; // 'item'
$row->op; // 'added' | 'removed' | 'changed' | 'unchanged'
$row->changes; // [ { path, before, after }, ... ]
$row->before; // mixed — full snapshot for 'removed' / 'changed'
$row->after; // mixed — full snapshot for 'added' / 'changed'
$row->changed_paths; // string[] — paths where both before and after exist
$row->added_paths; // string[] — paths that appeared (before === null)
$row->removed_paths; // string[] — paths that disappeared (after === null)
The *_paths buckets are derived from changes so call sites that only care about "what fields appeared / disappeared / mutated" don't have to re-classify every entry themselves.
$row = $delta->item ('id.item.longsword_5001');
$row->op; // 'changed'
$row->changed_paths; // [ 'data.gear_score', 'data.primary.physical_damage.max' ]
foreach ($row->changes as $c) {
echo "{$c ['path']}: {$c ['before']} → {$c ['after']}\n";
}
SDK-as-implementation
Controller\Codex\Entities is a thin JSON wrapper around Codex::entity(). So the PHP SDK and the HTTP API never drift — the SDK is the API's read implementation. If you find a discrepancy, it's a bug in the wrapper, not a divergence in semantics.