Hands-on walkthrough: Tutorials — First project.
Every game root contains hikari.project.json. It is the editor entry point for project identity: durable id, name, source and asset roots, engine SDK location, content dirs. Tunable session policy and packaging live as solo JSON files under configs/. Zig ProjectConfig (GameModule.config) supplies defaults; present configs/*.json fields win at session boot (composeFromFiles / Config.sessionConfig).
{
"kind": "com.hikari.project",
"version": "5",
"id": "b0d18223-4b45-4611-b17f-fcf4175a51b7",
"name": "My Game",
"source": "src",
"assets": ".",
"last_editor_version": "0.1.0",
"content_dirs": [
{ "path": "resources", "bundled": true }
]
}| Field | Role |
|---|---|
kind | Must be com.hikari.project. |
version | Descriptor schema version ("5" today). Not the engine/editor product version. |
id | Required durable project UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, RFC 4122). Stable across renames and moves; shown read-only in Project Settings and exposed to editor/MCP as project_id. Generate once (editor mints UUID v4 when creating a descriptor); do not recycle or rewrite casually. |
name / source / assets / engine_sdk | Discovery paths for the editor and Kaji. engine_sdk is the thin game-module SDK (build.zig + hikari_game): a path relative to the project root, or "bundled" / omitted → <exe_dir>/sdk if present (packaged / --vendor:engine=bundle), else walk project parents for a sibling hikari/sdk (e.g. src/games/craft → src/hikari/sdk). |
last_editor_version | Written by the editor on open. Internal engine/editor version number (src/hikari/src/version.zig current.version.number) that last opened the project. Absent until the first successful open. When present and different from the running editor, the project selector / boot path shows a warning before proceeding. |
content_dirs | Loose content roots staged by Kaji into data/<path>/ (not Shinra). See Content dirs and Data, JSON, and modding. |
Legacy top-level config / packaging objects in the project file are rejected at open (LegacyEmbeddedSessionConfig / LegacyEmbeddedPackaging) so session policy is never silently dropped. Move them to configs/*.json before opening the project.
Solo configs (configs/)
Same layout in the project and in the staged product data/configs/:
| File | Role |
|---|---|
configs/game.json | Game window (mode, display, resizable, VSync / frame rate) + startup.scene + asset residency budget + world tuning |
configs/render.json | Render pipeline (features / quality / look) for the game and editor viewport |
configs/audio.json | Boot mix (bus gains, ducking, reverb), voice budgets, and audio device policy |
configs/drivers.json | Runtime driver recipe and GPU adapter preference; driver implementations are inherited by the editor host |
configs/editor.json | Editor window + host frame pacing; optional sparse render / drivers overlays on the game recipes |
configs/packaging.json | Shipping identity (Kaji --package; not runtime) |
configs/layout.json | Pack graph: membership, delivery, deps, labels (Kaji seal + Project Settings → Packs) |
configs/input_actions.json | Authored action ids and default bindings |
Writable configs/input_bindings.json | Sparse per-user binding overrides; never shipped |
configs/gameplay_tags.json | Gameplay-tag catalog (com.hikari.gameplay_tags). Not session compose — File → Project Settings… → Gameplay Tags |
The editor does not keep a second full render or driver recipe. Compose copies composed game.render / game.drivers into the editor recipe, then applies only keys present under editor.json → render / drivers. GPU preference is the one target-local policy: editor defaults to auto instead of inheriting the runtime's adapter policy. Leave editor.json without other driver keys unless you need a rare host-only override (hand-edit).
GPU adapter preference
GPU selection is a launch policy, not a persistent adapter id. The runtime value lives in configs/drivers.json; the independent editor value lives under configs/editor.json → drivers. Project Settings exposes both choices and marks them Requires restart because the device, queues, swapchains, and resident resources are created for one adapter.
configs/drivers.json:
{
"kind": "com.hikari.config.drivers",
"version": 1,
"gpu_preference": "high_performance"
}configs/editor.json (sparse host override):
{
"kind": "com.hikari.config.editor",
"version": 1,
"drivers": {
"gpu_preference": "auto"
}
}Accepted values are auto and high_performance. auto uses the platform's normal adapter ordering; high_performance asks for the discrete GPU and falls back when there is none. It reorders compatible adapters only — it never makes an otherwise unsupported GPU eligible.
There is deliberately no "prefer the low-power GPU" value: battery and thermal modes are player-facing runtime settings, not project constants. The knob is implemented on D3D12; on single-GPU platforms such as Apple silicon a non-auto value is logged and ignored.
Project Settings shows apply timing on every non-live row, repeats the strongest pending boundary in the footer, and posts the same short reminder after Save:
Apply | Meaning |
|---|---|
live | Written to disk and pushed into the open editor session on Save (shadows, RT, reflections, AO, TAA, tonemap, …) |
restart | Disk only until the editor restarts (GPU adapter preference, graphics/audio drivers, editor module linkage, plugin enablement, …) |
launch | Disk only until the next standalone game launch. Play-in-editor uses the policy composed at editor startup, so restart the editor before testing the change in Play. |
package | Packaging identity; next --package |
All Rendering and Quality rows are live. Feature gates, graph-shaping quality choices, post-process look values, occlusion policy, and directional-shadow tuning use one frame-boundary render-config transaction; the renderer ensures newly needed pipelines and invalidates temporal history where required.
Document header
Every solo config file starts with a document header (same shape as other Hikari JSON docs):
{
"kind": "com.hikari.config.game",
"version": 1,
...
}| File | kind | Schema version |
|---|---|---|
game.json | com.hikari.config.game | 1 |
render.json | com.hikari.config.render | 3 |
drivers.json | com.hikari.config.drivers | 1 |
editor.json | com.hikari.config.editor | 1 |
packaging.json | com.hikari.config.packaging | 1 |
layout.json | com.hikari.config.layout | 2 |
input_actions.json | com.hikari.config.input_actions | 2 |
input_bindings.json | com.hikari.config.input_bindings | 1 |
gameplay_tags.json | com.hikari.gameplay_tags | 1 |
version is the document-domain schema, not one repository-wide number. For packaging, the marketing/store version is a separate field: product_version (e.g. "0.1.0").
Settings composition model
Runtime policy has three deliberate forms:
- A resolved canonical value (
ProjectConfig,RenderPipelineConfig) with no missing fields. - A sparse domain patch (
WorldPatch,DriverPatch,RenderPipelinePatch) where omission means “inherit the lower layer”. - A document envelope carrying
kindand that domain’s schemaversion.
Composition order is engine defaults, game-module Zig defaults, project JSON,
editor target overrides, then live runtime patches. The render document keeps
its domain body under pipeline; editor.json → render uses the same
RenderPipelinePatch directly. Render live changes resolve once in the session
and cross the render thread/backend as one complete pipeline value, so related
fields never arrive as transient half-configurations.
pipeline.display is shipped (output, prefer, paper_white_nits, tone_target_nits, sdr_preview). The example game sets HDR + HDR10 prefer. Contract: HDR display output. world.residency_bind_budget_ms lives on game.json world — asset residency.
Async-compute scheduling (render.json)
Async compute is a device-wide scheduling policy, not a visual feature. auto
uses a dedicated compute queue when the backend reports one usable; off runs
the same eligible work on the graphics queue. Unsupported devices always fall
back to graphics.
{
"kind": "com.hikari.config.render",
"version": 3,
"pipeline": {
"scheduling": {
"async_compute": "off"
}
}
}The default is auto. Project Settings → Rendering → Platform and
hi.render().setAsyncCompute both apply the policy live at a frame boundary;
no process restart is required. Switching policy may recreate visible
content-compute producers once so their queue-owned resources remain coherent.
Occlusion quality (render.json)
Occlusion separates graph shape from camera-motion policy. mode chooses the
algorithm; profile chooses when camera movement should conservatively fall
back to GPU frustum-only culling.
{
"kind": "com.hikari.config.render",
"version": 3,
"pipeline": {
"quality": {
"occlusion": {
"mode": "two_phase",
"profile": "stable"
}
}
}
}| Profile | Policy | Typical use |
|---|---|---|
stable | Guard nearly all deliberate camera motion | Racing, fast traversal, fly cameras |
balanced | Keep Hi-Z during ordinary movement; guard fast motion | General third-person / open world |
performance | Keep Hi-Z active through motion | Constrained cameras, corridor-heavy games |
The thresholds are engine-owned and measured as camera velocity, so projects do not accumulate resolution-, FOV-, or refresh-rate-specific shader constants. Project Settings → Quality authors both fields as sparse JSON.
Ray-tracing budget and frame governor (render.json)
The shared RT budget is a deterministic authored ceiling divided among the RT
effects that are enabled. The separate frame governor is adaptive by default:
its ray lever first reduces grants above each effect's useful floor, then its
indirect-resolution lever may reduce the stochastic input grids of RT GI and
reflections. RTAO keeps its fixed half-resolution producer/history lattice.
Temporal histories keep their authored grid and the bilateral resolve stays
full-resolution. Set governor.mode to off for deterministic captures or
fixed-tier benchmarks; an unlimited RT tier disables governing altogether.
{
"kind": "com.hikari.config.render",
"version": 3,
"pipeline": {
"quality": {
"rt_budget": {
"tier": "high"
},
"governor": {
"mode": "adaptive",
"levers": {
"rays": true,
"indirect_resolution": true
}
}
}
}
}Both policies are live in Project Settings → Quality.
Example configs/game.json
{
"kind": "com.hikari.config.game",
"version": 1,
"window": { "title": "My Game", "width": 1280, "height": 720, "vsync": true, "frame_rate": "unlimited" },
"startup": { "scene": "scenes/main" },
"residency": {
"keep_alive_mb": 512,
"gpu_geometry_mb": 1024,
"texture": {
"gpu_budget_mb": 768,
"initial_tail_mips": 4,
"lod_bias": 0.0,
"eviction_grace_frames": 30,
"max_transitions_per_frame": 16
}
}
}residency.keep_alive_mb bounds decoded, unreferenced keep-alive assets for
chunk streaming (default 512; 0 destroys on last release). Referenced and
pinned assets are never evicted by this budget. residency.gpu_geometry_mb
ceilings live GPU mesh geometry; 0 is unlimited.
residency.texture.gpu_budget_mb controls projected-size mip streaming for
ordinary 2D material maps. It defaults to 768, so projects stream without any
author setup; an explicit 0 keeps every authored mip fully GPU resident. A
streamed texture first uploads its initial_tail_mips smallest
mips, then moves toward the detail demanded by visible screen coverage while
staying under the shared budget. lod_bias is in mip levels (positive is
coarser), eviction_grace_frames prevents boundary churn, and
max_transitions_per_frame caps replacement uploads requested by one publish.
See Asset residency.
window.vsync (default true) and window.frame_rate (default "unlimited") are standalone game only. Play-in-editor uses the editor host table below, never these keys. frame_rate is one of "hz_10" / "hz_15" / "hz_30" / "hz_60" / "hz_120" / "unlimited". Unlimited with VSync is display-driven; unlimited without VSync is uncapped.
Input actions are not referenced from startup — authored actions are always
config://input_actions.json; runtime rebinding persists separately to the
writable config://input_bindings.json layer.
Editor frame pacing (editor.json → frame_pacing)
Editor tick/present rates are independent of the game window. Defaults match the historical host table; omit the object to keep them. Every field accepts the same frame_rate tags, including "unlimited".
{
"kind": "com.hikari.config.editor",
"version": 1,
"frame_pacing": {
"ac_active": "hz_60",
"ac_idle": "hz_30",
"battery_active": "hz_30",
"battery_idle": "hz_15",
"saver_active": "hz_30",
"saver_idle": "hz_10"
}
}| Field | Default | When it applies |
|---|---|---|
ac_active | hz_60 | Play, viewport navigation, UI interaction, while on AC |
ac_idle | hz_30 | Settled Edit with no input, on AC |
battery_active | hz_30 | Active, unplugged |
battery_idle | hz_15 | Idle, unplugged |
saver_active | hz_30 | Active, OS Low Power Mode / battery saver |
saver_idle | hz_10 | Idle, OS Low Power Mode / battery saver |
A machine the OS cannot classify (typical desktop) stays on the AC rates. Project Settings → Editor authors this live; Project Settings → Game authors standalone window.vsync / window.frame_rate (next game launch).
World tuning (game.json → world)
Gameplay-world knobs whose right value depends on the game rather than the
engine. Every default reproduces built-in behaviour, so omitting the section
changes nothing. The editor applies the game's world section during
play-in-editor, so it matches the shipped build.
{
"kind": "com.hikari.config.game",
"version": 1,
"world": {
"lod_bands": [
{ "max_distance": 150, "rate": 1 },
{ "max_distance": 800, "rate": 4 },
{ "max_distance": 3.4e38, "rate": 0 }
],
"lod_hysteresis": 5.0,
"lod_budget": 4096,
"fixed_hz": 60,
"expected_entities": 100000
}
}| Field | Default | Meaning |
|---|---|---|
lod_bands | full rate everywhere | Tick-rate bands, nearest first; the last one catches everything beyond it. rate is 1 every frame, N every Nth, 0 asleep. Throttling is always an explicit project decision — with auto significance filling distances for every actor, a throttling default would silently stop far actors in games that never asked for LOD |
lod_hysteresis | 1.0 | Distance past a band edge before an actor switches, so one loitering on a boundary does not flip rate every frame |
lod_budget | 1024 | Actors re-bucketed per frame; trades how fast a rate change takes effect against frame cost. Also budgets the auto-distance sweep |
lod_auto_distance | true | Engine fills each actor's LOD distance from the primary camera (budgeted round-robin), so distance LOD, animation rates, and particle sleep work with zero game code. setLodDistance overrides per actor |
mesh_lod_distance_scale | 1 | Multiplier on the distance at which mesh LOD levels switch. 2 holds every level to twice the distance (more detail, more triangles); 0.5 switches at half. The main mesh-detail knob for a quality preset — per-mesh tuning belongs on the primitive's lod_bias. Clamped to [0.05, 20] |
mesh_lod_fade_time | 0.25 | Seconds a mesh LOD switch spends dissolving between levels (unrelated to the tick-rate bands above). The pair draws as one widened vertex span, screen-door dithered, and the temporal resolve turns that into a cross-fade. 0 switches instantly — cheapest, and pops. A mesh mid-fade rasterises both levels, so this is a duration, not a quality tier |
fixed_hz | 0 (off) | fixedUpdate rate. While this is 0 the fixed phase never runs, so components declaring fixedUpdate are never called — the engine warns at archetype registration if that combination appears |
max_fixed_steps | 8 | Fixed steps per frame before the accumulator is clamped; the clamp is what stops a long frame spiralling |
job_workers | 0 (auto) | Job-system threads. Auto leaves a core for the main thread and caps at 16 |
scene_preload_budget_ms | 4 | Per-frame streaming residency budget |
scene_instantiate_budget_ms | 4 | Per-frame actor spawn budget |
expected_entities | 0 | Rough live actor count. Pre-sizes component stores so loading a large level does not regrow them repeatedly |
Bands must be strictly ascending; a non-ascending list is rejected at compose
time rather than asserting mid-frame. With lod_auto_distance (the default)
every actor gets a camera distance automatically; world().setLodDistance
overrides it per actor and clearLodDistance returns to auto. With auto off,
only actors the game publishes are throttled. To disable LOD outright, give a
single catch-all band at rate 1 rather than an empty list (empty means "keep
the engine default").
A game that needs to change policy at runtime — per region, or off a quality
setting — calls world().setLodPolicy(bands, hysteresis, budget) with the same
band type. Static policy belongs in this file.
mesh_lod_distance_scale and mesh_lod_fade_time are the two knobs here that
are not tick-rate LOD. Runtime levers: world().setMeshLodDistanceScale /
setMeshLodFadeTime (read back with meshLodDistanceScale() /
meshLodFadeTime()); both are snapshotted on Play and reverted on Stop like
every runtime-API global. Per-primitive overrides are lod_bias / force_lod
on render().applyRender / applyRenderSlot (the runtime form of scene JSON
render.lod_bias / render.force_lod; a negative force_lod returns to
automatic selection).
In the editor, Project Settings → World authors the same section: scalar
knobs as number fields, and lod_bands through a list editor (add / remove /
catch-all ∞). An empty band list means "engine default" and removes the key on
Save, matching every other unset convention in the dialog.
Particle scalability (game.json → particles)
Global cost levers for the GPU particle pipeline. In a fixed-step deterministic
sim, "tick less often" is not a lever — batched catch-up runs the same steps —
so the honest knobs are fewer particles, sleeping far systems, and cheaper
shading. Omitting the section changes nothing; every value is also live-tunable
from the game through hi.host_api.particle().setGlobal*.
{
"kind": "com.hikari.config.game",
"version": 1,
"particles": {
"spawn_scale": 0.75,
"cull_distance": 150,
"cull_fade_band": 20,
"max_total_capacity": 65536,
"lit": true,
"soft_fade": true
}
}| Field | Default | Meaning |
|---|---|---|
spawn_scale | 1 | Multiplies every emitter's authored rate and bursts — the single knob quality presets hang off. Explicit emitBurst calls are gameplay and are never scaled |
max_steps_per_frame | 8 | Fixed-step catch-up cap per system per frame (1–8). Lower drops sim time after a hitch instead of spiking the GPU |
max_frame_delta | 0.25 | Frame delta clamp (seconds) fed to the fixed-step accumulator |
cull_distance | 0 (never) | Systems farther than this from the primary camera sleep: no sim dispatches, no draw, GPU state frozen for seamless resume. Per-component cull_distance overrides (-1 inherits, 0 never, >0 explicit) |
cull_fade_band | 5 | Width of the dim-out band just inside the cull distance, so systems fade instead of popping off |
max_total_capacity | 0 (unlimited) | Budget over the summed asset capacities of live systems. When exceeded, the farthest non-important systems sleep until the total fits — mark the campfire important, not the footstep dust |
lit | true | Master switch for scene-lit particles (the per-fragment cluster light walk is the most expensive thing in the pipeline). Off forces every emitter unlit |
soft_fade | true | Master switch for soft depth-fade. Off skips the depth read |
In the editor, Project Settings → Particles authors the same section. The
per-actor overrides (cull_distance, important) live on the particle
component in the inspector and in scene JSON.
One significance spine
world, animation, and particles all consume the same per-actor
distance: by default the engine fills it from the primary camera
(world.lod_auto_distance, budgeted round-robin), and world().setLodDistance
overrides it per actor — a game's custom significance (behind-camera bias,
gameplay weight) drives gameplay tick rates, animation evaluation rates, and
particle sleep coherently. clearLodDistance returns an actor to auto. This is
what makes the sections compose for open worlds: one distance, per-system
levers.
Animation scalability (game.json → animation)
Animation is not a fixed-step sim, so — unlike particles — evaluation-rate
reduction is a real lever: a skipped character advances its clip/graph clock
but holds its pose, staying in sync and snapping to the correct frame when it
next evaluates. Live-tunable via animation().setGlobal*; per-actor
important (scene JSON on the animation component, or
animation().setImportant) exempts a character from the budget.
{
"kind": "com.hikari.config.game",
"version": 1,
"animation": {
"cull_distance": 150,
"lod_bands": [
{ "max_distance": 25, "rate": 1 },
{ "max_distance": 60, "rate": 2 },
{ "max_distance": 3.4e38, "rate": 4 }
],
"max_evaluations_per_frame": 64
}
}| Field | Default | Meaning |
|---|---|---|
cull_distance | 0 (never) | Characters beyond this freeze entirely — no pose, no clock; the pose they slept with is the pose they wake with |
lod_bands | empty | Animation-specific evaluation rates by distance. Empty = follow the world tick-LOD bands (one significance policy); non-empty decouples animation rates from gameplay rates |
lod_hysteresis | 1.0 | Band-edge hysteresis for the animation-specific bands |
max_evaluations_per_frame | 0 (off) | Hard cap on characters evaluated per frame, nearest first; important actors are exempt. Everything past the cap advances clocks only |
Physics stepping (game.json → physics)
Launch-only — the backend consumes these at initialization, so there are no runtime setters. Distance-based body sleeping is deliberately absent: the backend's own sleep system owns that, and force-sleeping distant bodies breaks gameplay.
| Field | Default | Meaning |
|---|---|---|
gravity | [0, -9.81, 0] | World gravity in m/s² |
substep_count | 4 | Collision substeps per step (1–8) — the accuracy/cost knob |
max_substep_dt | 1/240 | Largest dt one substep integrates; substep_count × max_substep_dt is the largest step absorbed without escalating work |
max_catchup_steps | 4 | Whole catch-up steps per iteration after a hitch (1–8); the rest is repaid later or dropped — never a death spiral |
pace_hz | 60 | Step-thread pacing target, and the step-size ceiling unless the substep budget is smaller |
Example configs/layout.json (product pack layout)
Controls what goes into which pack, how each pack is delivered, and what a retain pulls in with it. The Shinra cook resolves it; Project Settings → Packs authors all of it — add and remove packs, reorder them, set role, delivery and root, and edit the include globs, dependencies and labels of the selected pack. Absent file → one monolithic packed pack holding everything the project ships — a working product with zero configuration.
{
"kind": "com.hikari.config.layout",
"version": 2,
"default_pack": "game",
"packs": [
{
"stem": "damaged_helmet",
"role": "assets",
"include": ["scenes/damaged_helmet/**"],
"deps": ["shared"]
},
{
"stem": "shared",
"role": "assets",
"labels": ["boot"],
"include": ["models/**", "materials/**", "textures/**"]
},
{ "stem": "scenes", "role": "scenes" },
{ "stem": "shaders", "role": "shaders" },
{ "stem": "content", "role": "content" },
{
"stem": "mods",
"role": "files",
"delivery": "loose",
"root": "mods"
}
],
"ship_exclude": ["configs/packaging.json"]
}| Field | Meaning |
|---|---|
role | assets (cooked art, membership by include globs) · scenes · shaders · content (content_dirs with bundled: true) · files (a plain directory, named by root). Omitted: assets when include is present, files otherwise. |
delivery | packed (default) → data/bundles/<stem>.shinbundle · loose → files under root |
include | Cook-tree globs, for role assets only. First match wins, in declaration order — put specific packs above catch-alls. Globs on any other role are a cook error, not a silent no-op. |
deps | Packs retained transitively with this one. For what no reference can express: a VO bank a script names by string. |
labels | Retain-by-label groups. hi.packs().openLabel("boot") opens every pack carrying it. Packs with role scenes, shaders or content must carry boot: the session reads them before any scene can retain anything, and the cook refuses a layout where they do not. |
root | Required for role files and for any loose pack. Bounds where uncatalogued (mod-added) files may resolve. |
default_pack | Absorbs cooked art no pack claims. Reported, never a build failure. |
Engine shaders are not listed here. The product always seals them as stem engine.
Who resolves this
The cook does, once. Shinra reads this document, matches the globs against the cook tree, and writes packs.shinplan.json next to the asset manifest. Kaji seals what the plan says and the editor reads it; neither matches a glob. Editing this file re-resolves on the next cook, which watch mode triggers automatically.
Between a save here and that cook, the editor shows the previous partition and says so.
Packed vs loose
A packed pack is a .shinbundle and the catalog is the whole truth of its membership. A loose pack is a directory, and the directory is the truth: a file dropped in after the build resolves exactly like a sealed one, while the pack is open. That is the only place an uncatalogued read is legal, which is what makes a partially moddable product expressible without weakening resolve anywhere else. Loose packs are additive — they never shadow a path a packed pack already owns.
Scenes do not declare packs
A scene names assets; the runtime derives which packs it needs from those references plus each pack's deps. There is no packs field in a scene document (one in an older file is ignored with a warning). A scene authored against a shipped product — by a modder, with no cook and no seal — resolves exactly like one that shipped with it. The editor's Scene Packs tab shows the resulting closure and which reference pulled each pack in.
Runtime retain: session open keeps the system packs (engine, scenes, shaders, content). Scene loads retain their derived closure. Game code opens the rest through hi.host_api.packs(). URI rules: Assets and Shinra — Asset URIs and packs.
Full design: resources-layout-and-logical-packs.md. Layout mode is still CLI --resources=bundles|loose, which now only decides whether packed packs are sealed at all.
Config ownership
| Layer | Role |
|---|---|
Zig ProjectConfig | Defaults for .game / .editor / .startup |
Project/staged configs/*.json | Authored solo-file overlays; JSON wins when a field is present |
Platform user-data configs/*.json | Sparse player overrides written by hi.config; wins over the authored layer |
Engine Config facade | load(T, file) (read-only) / edit(T, file) (mutate + save()); shortcuts sessionConfig(), input(), … |
startup.scene is commonly set only in configs/game.json. When both Zig and JSON set it and they differ, compose logs a warning and JSON wins — so changing only the compiled recipe cannot silently fight a leftover overlay.
Hosts load authored configs from the project root (editor) or staged data/
(standalone), then load the writable layer scoped by the durable project id and
compose both with composeLayers. Absent files keep the previous layer.
Engine/editor authoring uses Editable.save() against the explicit project
root. Game code uses hi.config.load/save/reset: reads are user → authored,
writes are atomic and affect only the user layer. Known engine documents are
header-validated; custom nested .json documents are allowed under configs/.
Content dirs (loose data)
Optional content_dirs lists project folders that Kaji copies into product data/ for content:// access. Policy lives only in this project file (no hikari.content.json per folder).
"content_dirs": [
{ "path": "resources", "bundled": true },
"modding"
]| Form | Local stage | --package |
|---|---|---|
"name" or { "path": "name", "bundled": false } | yes | yes — loose under data/<path>/ |
{ "path": "name", "bundled": true } | yes | yes — may pack into content when --resources=bundles (see layout.json) |
Rules:
pathis a single segment under the project root (no/,\,.,..).- Default
bundledis false (string form is the same as{ "path", "bundled": false }). - Omitted
content_dirs: ifresources/exists, stage it as loose-mod (ships naked). - Explicit
"content_dirs": []: stage nothing (disables the resources default). - Duplicate paths: first wins (warning). Invalid entries are skipped (warning).
- Missing source directories are skipped quietly.
Prefer "bundled": true for tables you are fine packing; use "bundled": false for mod-friendly loose files (both ship when listed).
Version axes (do not conflate)
These are independent; bumping one does not imply the others.
| Axis | Where | Role |
|---|---|---|
Thin SDK VERSION | src/hikari/sdk/VERSION (copied into staged/packaged sdk/VERSION) | Packaging / human SDK identity. Kaji also writes CONTENT_HASH over staging inputs — content edits restage even when VERSION is unchanged. |
Game ABI abi_version | sdk/src/game_module_def.zig abi_version_current (handshake symbols + GameModule.abi_version) | Editor ↔ libgame load gate. Bump on GameModule / thin ABI semantic breaks. HostApi subtable versions (host_api_version_current, world_api_version_current, …) are checked at bindHost and are separate from this integer. |
| Editor / engine version | src/hikari/src/version.zig current.version.number ↔ project last_editor_version | Product/editor identity for the project-open warning; the sibling subtitle and slogan are display identity, not compatibility keys. This is not the game-module ABI or thin SDK VERSION. |
Packaging (game)
configs/packaging.json is the game shipping identity for kaji game … --package (see Build and packaging). It is not runtime session policy. Kaji requires this solo file — a top-level packaging block in hikari.project.json is rejected (same as the editor open path).
| Field | Role |
|---|---|
kind / version | Document header: com.hikari.config.packaging / schema 1. |
title | Display name (Finder / Start menu / MSIX display name). Defaults to project name. |
product_version | Marketing version (1–4 numeric components). MSIX expands to four parts. |
identifier | Reverse-DNS id (com.studio.game). Defaults to com.hikari.<slug>. |
authors / publisher | Human credits; publisher seeds copyright and Windows display publisher. |
description / copyright | Store / About strings. |
icon | Project-relative .png (or platform-native icon). Required for --package=bundle. |
startup_background_color | Optional #RRGGBB, default #000000. Author in Project Settings → Packaging → Startup → Startup background. Kaji compiles it into the macOS/Windows game frontend, including loose builds. Colors are opaque sRGB. Editor startup ignores it. |
macos.category | LSApplicationCategoryType (default public.app-category.games). |
macos.minimum_system_version | LSMinimumSystemVersion (default 26.0; Metal 4.0 / macOS 26 product floor). |
macos.icon | Optional macOS override: Icon Composer .icon (Liquid Glass via actool → Assets.car + fallback .icns), or .icns. Otherwise Kaji builds .icns from icon via sips/iconutil. |
windows.publisher | MSIX identity publisher DN (e.g. CN=Studio Name). |
windows.publisher_display_name | MSIX PublisherDisplayName. |
windows.icon | Optional Windows logo override (PNG). |
Keep game icons under something like packaging/ next to the game project root. The sample ships PNG icons under src/games/example/packaging/. For Liquid Glass on macOS 26+, author a real Icon Composer .icon and set macos.icon — do not wrap a finished PNG in a hand-rolled .icon (that produces nested frames or blank plates).
Packaging (editor)
Two packaging identities:
| Product | Descriptor |
|---|---|
kaji editor (no --project) | Engine-owned src/hikari/editor.project.json |
kaji editor --project=<game> --package | Optional editor overlay in the game’s configs/packaging.json (falls back to shared packaging fields). Overlay marketing version is version (the solo-file root uses product_version). Paths relative to the game root. |