Game modules get two complementary tools:
hi.json— fast type-driven JSON (no host vtable on parse/stringify).hi.ContentRef+hi.paths— sandboxed content identity and IO (same in editor, debug product, and packaged builds).
Cooked runtime assets use hi.AssetRef (asset://…) — not content URIs. Full identity map: Game-facing refs.
Shinra still owns cooked assets under assets/. Loose data tables (JSON, configs, mod hooks) live beside the project and stage into data/ without cooking.
Path roots
| Root | Meaning | Typical location |
|---|---|---|
| content | Shipped / staged read data | Product data/; editor often project root (parent of scenes/) |
| writable | User saves + mod overrides | OS persistent: …/Hikari/<product_id>/ |
| project | Source project tree | Editor / dev when a project is open; empty in packaged standalone |
URI schemes (preferred)
| Scheme | Root | Use |
|---|---|---|
content://path/under/root | content | Shipped / staged data (JSON tables) via ContentRef + AssetStore |
writable://path | writable | Saves, user mods (ContentRef) |
project://path | project | Editor/dev source tree only (ContentRef) |
config://name.json | <game folder>/configs | Config facade (Config.load / edit) and Kawa Content.read — not a ContentRef scheme |
Relative path after the scheme is sandboxed (no .., no absolute paths). Same idea as asset:// for cooked assets.
const hi = @import("hikari_game");
// Preferred: ContentRef
const table = hi.ContentRef.must("content://resources/spawn_table.json");
const bytes = try hi.paths.read(allocator, table);
defer allocator.free(bytes);
// Absolute OS path (caller frees)
const path = try hi.paths.resolve(allocator, table);
defer allocator.free(path);
// Saves
try hi.paths.write(hi.ContentRef.must("writable://saves/slot0.json"), json_bytes);Root-enum helpers (join / readFile with .content) exist on hi.paths; game code should prefer ContentRef.
Facade for the host table: hi.paths_api().root(.content).
JSON
Zig std.json is the only JSON parser and typed stringify in product Zig. Game code goes through hi.json (this page). Engine code uses the same std.json plus helpers in src/hikari/src/utils/json.zig (scan / write / authoring number hygiene) — do not add another tokenizer or DOM.
Type-driven parse (compile-time shape). Defaults: ignore unknown fields (mod-friendly), alloc_always (strings outlive the source buffer so parseUri / parseFile can free file bytes immediately), compact stringify.
const SpawnTable = struct {
version: u32 = 1,
spawns: []const struct {
id: []const u8,
archetype: []const u8,
position: [3]f32 = .{ 0, 1, 0 },
},
};
// From memory
var parsed = try hi.json.parse(SpawnTable, allocator, bytes);
defer parsed.deinit();
// From content (preferred)
var from_disk = try hi.json.parseRef(SpawnTable, allocator, hi.ContentRef.must("content://resources/spawn_table.json"));
defer from_disk.deinit();
// Scratch / arena (no Parsed wrapper)
const table = try hi.json.parseLeaky(SpawnTable, arena.allocator(), bytes);
// Write compact JSON under writable
try hi.json.writeUriAlloc(allocator, "writable://saves/progress.json", save_data);Hot path tips:
- Prefer concrete structs over
std.json.Value. - Use
parseLeaky+ a frame/arena allocator for one-shot tables. - Prefer
stringify(compact) over pretty for saves. - Keep tables small enough to load once at session start when possible.
Project layout (loose content)
my_game/
├── hikari.project.json
├── configs/ # solo configs → product data/configs/
│ ├── game.json
│ ├── render.json
│ ├── drivers.json
│ ├── editor.json
│ ├── packaging.json
│ ├── layout.json # pack graph: membership, delivery, deps, labels
│ ├── input_actions.json
│ └── gameplay_tags.json # tag catalog (not session compose)
├── scenes/
├── assets/ # Shinra-cooked → `assets` packs from configs/layout.json
├── resources/ # content dir tables (content pack when bundled:true + resources=bundles)
│ └── example_items.json
└── .engine/ # build/cache/stage only (never author here)The project tree is source only. Product data/ lives under bin/game/data/ (or packaged install); assets-only tooling may seal a preview under .engine/staged/data/. Do not put a data/ stage mirror next to authoring folders.
Project .engine/ (staging)
All generated engine state for a project lives under <project>/.engine/. Gitignore the whole tree (sample does). Zig SSOT for these segments is src/hikari/src/project_layout.zig — do not hardcode .engine children in engine Zig.
| Path | Role | kaji … --clean |
|---|---|---|
build/ | Batch Shinra cook output | wipe |
staged/ | Intermediate packs + assets-only seal preview | wipe |
cache/ | Live Shinra cook, shader objects, Zig game-module caches | wipe |
bin/, lib/ | Editor-loaded game module + copied dylibs | wipe |
hot-reload/ | Hot-reload dylib snapshots | wipe |
logs/ | Editor NDJSON log (editor-latest.json) | wipe |
traces/ | Profiler file traces | wipe |
plugin-settings/ | Package settings (hi.settings → <package_id>.json) | wipe |
mcp-audit.jsonl | Editor MCP audit log | wipe |
user/ | Editor chrome prefs (prefs.json) + MCP enable/host/port (mcp.json; token in OS secure storage) | keep |
--clean also clears workspace bin/* (except kaji) and build/cache/* (except kaji). It never deletes project source (assets, scenes, configs, resources).
Staging (Kaji, not Zig)
Kaji owns all product data staging and seals. Zig build.zig is compile/link only (binaries, libraries) — it never copies scenes, configs, scripts, materials, or packs.
| Stage | Owner |
|---|---|
Authoring trees (scenes/, assets/scripts → scripts/, assets/materials → materials/, configs/, hikari.project.json) | HikariAssetConductor.StageAuthoringRuntimeTrees (no project bundles/ — packs from Shinra/seal only) |
Loose content_dirs | HikariAssetConductor.StageLooseContentDirs |
| Cooked scenes / art packs | Shinra + StageCookedScenes / StageBundle |
| Domain packs / loose seal | ProductResourcePacker.SealAsync (configs/layout.json) |
Combined entry: StageProjectRuntimeData (authoring + content dirs). Destinations: product bin/game/data/, engine install bin/hikari/bin/data/, or assets-only .engine/staged/data/. Never into project authoring paths. Units stage binaries with typed FS steps (KajiPlan.Copy), not host shell soup.
On kaji game … / kaji assets …, after the Shinra bundle:
- Stage authoring trees + read
content_dirsfromhikari.project.json(see rules below). - Replace each project content
<path>/→ productdata/<path>/(bin/game/data/,bin/hikari/bin/data/; assets-only →.engine/staged/data/). Destinations are wiped before copy so deletes in the project tree do not leave stale files.
All listed content_dirs stage and ship under product data/<name>/. The bundled flag is pack vs naked files, not ship vs omit:
| Form | Stage + package | With --resources=bundles |
|---|---|---|
{ "path": "resources", "bundled": true } | yes | may go into the content pack (configs/layout.json) |
"modding" or { "path": "modding", "bundled": false } | yes | always loose under data/modding/ (easy player/mod edit) |
- Omitted field → stage
resources/(loose-mod) if it exists. "content_dirs": []→ stage nothing.- Single-segment
pathonly; invalid/duplicate entries warn and skip.
Moddable packs
content_dirs cover data tables. Cooked assets are moddable through a pack whose delivery is loose:
{ "stem": "mods", "role": "files", "delivery": "loose", "root": "mods" }The pack ships as the directory data/mods/, catalogued but not bundled — and it is catalogued even when empty, so a game can open it before anything exists to put in it. While the pack is retained, a cooked file dropped into that directory after the build resolves exactly like one the seal placed. Outside a loose pack's root, an uncatalogued cooked path resolves nowhere.
Three consequences worth knowing:
- Mods ship cooked, not source. A loose pack holds
.shinmodel/.shintexture, so modders need the Shinra cook tool, not just the engine. - Mod scenes need no build step. A scene document declares no pack membership; the runtime derives it from the assets the scene references. A
.shinsceneauthored against a shipped product loads like one that shipped with it. - Loose is additive, not an override. A mod cannot replace a path that already belongs to a packed pack — one logical path has exactly one owner. Shadowing (
delivery: "overlay") is reserved and not implemented.
Opening a mod pack from game code:
const packs = hi.host_api.packs() orelse return;
const handle = packs.open("mods"); // resolvable, not yet loaded
defer _ = packs.close(handle);packs also has openLabel for opening a themed group at once, and stat(stem) which reports whether a pack is moddable — that is, loose. All of this works in Play-in-editor too: the editor's store reads the cook plan as its catalog, so open("mods") succeeds and stat counts the retain, without a build.
Shinra never cooks these dirs — only assets/ is fed to Shinra. Put cookable art under assets/; put JSON tables, localization, balance sheets, and mod hooks under content dirs. Prefer bundled: false for files users should edit without unpacking a pack; bundled: true for ship tables you are fine packing.
Runtime allowlist + store: content:// first path segment must be in content_dirs. Reads go only through AssetStore (product data_root / packs — same loose vs bundled config as assets). No raw filesystem fallback for content://. writable:// / project:// / config:// remain FS (via fs.system()). Scenes use SceneRef, not ContentRef.
config:// (engine config.zig): one choke point for config IO under a
specific root's configs/. Generic load / edit / write;
typed shortcuts (sessionConfig, input) only convert domain types. Runtime
hosts compose authored/staged defaults with a writable per-user layer; games
access that resolution through hi.config, whose writes never mutate the
authored install. Add future domains through the shared filename/kind/version
registry, not ad-hoc paths. Kawa Content.read("config://…") remains the
session's authored-root filesystem view.
If you remove a content dir from the project file, an old copy may still sit under bin/game/data/ until the next clean stage of that tree (or --clean).
Environments
| Environment | content root | project root | writable |
|---|---|---|---|
| Editor (project open) | Derived from scene path (usually project root) | Project assets root | OS persistent Hikari/editor |
| Standalone game | …/data next to the executable | Optional (dev data_path) | OS persistent Hikari/game |
| Packaged install | App data/ | empty | OS persistent |
Mods can:
- Drop override files under the writable tree with the same relative path and use
hi.paths.readFileSearch, or - Replace / add files next to the install under
data/resources/(unpacked loose layout).
Sample: JSON spawner
The sample project exercises the full loop:
| Piece | Path |
|---|---|
| Loose table | src/games/example/resources/spawn_table.json |
| Entity | src/games/example/src/entities/json_spawner_entity.zig (json_spawner) |
| Scene | src/games/example/scenes/runtime_spawn_showcase.json |
On Play, the spawner calls hi.json.parseRef(SpawnTable, …, ContentRef) (or parseUri with the same URI) and hi.world().spawn for each entry (layer inherited from the spawner). Cubes fall with physics at the back of the combined spawning scene; the left and right stations demonstrate direct Zig and Kawa spawning.
bin/kaji/kaji game --workspace="$PWD" --project=src/games/example --type=dynamic --config=debug --run
# or editor: load scenes/runtime_spawn_showcase.json and PlayExpect logs like json_spawner: table v1, 3 entries and json_spawner: spawned json_cube_a ….
What this is not
- Not a second asset pipeline (no
asset://for these files). - Not encrypted or signed content (ship/sign at packaging level if needed).
- Not a full VFS over
.shinbundle— useAssetStorefor cooked assets.