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 | Player settings (configs/), saves, mod overrides | Frontend writable_path when the platform owns storage (console save data, store sandbox); else the product folder (macOS ~/Library/Application Support/<identifier>, Windows %LOCALAPPDATA%\<publisher>\<title>, Linux $XDG_DATA_HOME/<identifier>) |
| 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.
Read a configured root with hi.paths.rootPath(.content); it returns null when unavailable.
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)
├── assets/ # the one authoring tree: scenes, models, materials, scripts … anywhere under it
├── 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 |
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 |
|---|---|
| Cook (Shinra; scripts, materials, art, scenes, prefabs) | assets step, action cook — writes only <project>/.engine/build/shinra |
configs/, hikari.project.json, content_dirs, cooked scenes and prefabs | assets step, action seal — HikariAssetConductor.StageRuntimeData |
| Domain packs / loose seal | the same seal action — ProductResourcePacker.SealAsync (configs/layout.json) |
Each data root has exactly one writer, and that writer rebuilds it from sources every time it runs, so a reseal never depends on what an earlier seal left behind. Destinations: product bin/game/data/ (the game seal, or the editor's editor-data step) or the kaji assets preview .engine/staged/data/. Never into project authoring paths.
On kaji game … / kaji assets …, after the cook:
- Empty the data root, then stage
configs/, the descriptor, everycontent_dirsentry (see rules below), and the cooked scenes and prefabs. - Seal: pack, catalog, ship-exclude.
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.packs() orelse return;
const handle = packs.open("mods");
if (handle == .none) return;
// Store the handle on the owner; poll packs.state(handle) on later callbacks.
// Once .ready, acquire assets. Close the handle when the owner is finished.open returns immediately. Cold storage opens on a worker; state(handle) distinguishes pending, ready, failed and closed. Closing a pending handle cancels it without blocking. 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, balance sheets, and mod hooks under content dirs. Compiled translations belong in assets/localization/; see localization. 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 | The product folder (shared with a packaged build of the project) |
| Standalone game | …/data next to the executable | Optional (dev data_path) | Frontend writable_path, else the product folder |
| 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 | hikari-games/example/resources/spawn_table.json |
| Entity | hikari-games/example/src/entities/json_spawner_entity.zig (json_spawner) |
| Scene | hikari-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=../hikari-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.