Guidance for AI coding agents — and human contributors — working in this repository. This file is the single source of truth for architecture, conventions, and workflow. Tool-specific entry points (e.g. `CLAUDE.md`) import it rather than duplicating it.
Guidance for AI coding agents (and human contributors) working in this repository. This file is the single source of truth for architecture, conventions, and workflow. Tool-specific entry points (e.g. `CLAUDE.md`) import it rather than duplicating it.
## Project goal
## Project goal
Voxel-based game with souls-like combat. Built in Rust; rendering targets Vulkan via [`ash`](https://github.com/ash-rs/ash) (raw Vulkan bindings, not a higher-level wrapper like `wgpu` or `vulkano`).
Voxel-based game with souls-like combat. Built in Rust; rendering targets Vulkan via [`ash`](https://github.com/ash-rs/ash) (raw Vulkan bindings, not a higher-level wrapper like `wgpu` or `vulkano`).
World is procedurally generated. Voxel edge length is 0.5 m, so the player occupies **3 blocks tall × 2 blocks wide**. This finer grid is load-bearing for design decisions: collision, mesh chunking, LOD thresholds, and network bandwidth all need to assume ~8× the voxel count of a 1 m-grid world per unit volume — pick chunk sizes and data layouts accordingly. The rationale and trade-offs of this scale are recorded in [ADR-0002](docs/adr/0002-half-scale-voxel-grid.md). Supports both single-player and multiplayer via a dedicated server — that dual mode is why `server` exists as its own crate even for solo play (the single-player path is expected to run the server logic in-process or invoke the same crate, rather than having a separate offline code path).
World is procedurally generated. Voxel edge length is 0.5 m, so the player occupies **3 blocks tall × 2 blocks wide**. This finer grid is load-bearing for design decisions: collision, mesh chunking, LOD thresholds, and network bandwidth all need to assume ~8× the voxel count of a 1 m-grid world per unit volume, so pick chunk sizes and data layouts accordingly. The rationale and trade-offs of this scale are recorded in [ADR-0002](docs/adr/0002-half-scale-voxel-grid.md). Supports both single-player and multiplayer via a dedicated server. That dual mode is why `server` exists as its own crate even for solo play (the single-player path is expected to run the server logic in-process or invoke the same crate, rather than having a separate offline code path).
## Documentation map
## Documentation map
Documentation is layered by altitude; keep content at the layer it belongs to so no single file accretes everything.
Documentation is layered by altitude; keep content at the layer it belongs to so no single file accretes everything.
- **This file (`AGENTS.md`)** — cross-cutting engineering conventions and architecture invariants: rules that apply regardless of which feature is being touched. There is a finite set of these, so this file should grow slowly. Subsystem-specific specs do **not** belong here.
- **This file (`AGENTS.md`)**: cross-cutting engineering conventions and architecture invariants, i.e. the rules that apply regardless of which feature is being touched. There is a finite set of these, so this file should grow slowly. Subsystem-specific specs do **not** belong here.
- **[`docs/`](docs/) + Rust module docs (`//!`, `///`)** — per-subsystem technical implementation docs. How an individual system (meshing, networking, worldgen, …) is built. Prefer module docs next to the code; promote to a `docs/<subsystem>.md` note when the design spans multiple files.
- **[`docs/`](docs/) + Rust module docs (`//!`, `///`)**: per-subsystem technical implementation docs. How an individual system (meshing, networking, worldgen, …) is built. Prefer module docs next to the code; promote to a `docs/<subsystem>.md` note when the design spans multiple files.
- **[`docs/adr/`](docs/adr/)** — Architecture Decision Records: the *why* behind significant, hard-to-reverse choices, one append-only file per decision. See [`docs/README.md`](docs/README.md) for the full scheme and [`docs/adr/0001-record-architecture-decisions.md`](docs/adr/0001-record-architecture-decisions.md) for the practice.
- **[`docs/adr/`](docs/adr/)**: Architecture Decision Records capturing the *why* behind significant, hard-to-reverse choices, one append-only file per decision. See [`docs/README.md`](docs/README.md) for the full scheme and [`docs/adr/0001-record-architecture-decisions.md`](docs/adr/0001-record-architecture-decisions.md) for the practice.
The canonical game-*design* specification (intent, world rules, gameplay behaviour) is maintained separately and is not part of this repository; this repo documents how that design is implemented.
The canonical game-*design* specification (intent, world rules, gameplay behaviour) is maintained separately and is not part of this repository; this repo documents how that design is implemented.
@ -22,15 +22,15 @@ The canonical game-*design* specification (intent, world rules, gameplay behavio
Cargo workspace (resolver = "3", edition 2024) with four crates under `crates/`:
Cargo workspace (resolver = "3", edition 2024) with four crates under `crates/`:
- `client` — binary. Windowed application using `winit` 0.30 (`ApplicationHandler` pattern, `ControlFlow::Poll`). Also pulls in `image`. Player-facing app titled "Synvael"; handles input, windowing, and drives the renderer.
- `client`: binary. Windowed application using `winit` 0.30 (`ApplicationHandler` pattern, `ControlFlow::Poll`). Also pulls in `image`. Player-facing app titled "Synvael"; handles input, windowing, and drives the renderer.
- `server` — binary. Authoritative game simulation (voxel world, combat, players). Used both for dedicated multiplayer hosts and as the simulation backend for single-player.
- `server`: binary. Authoritative game simulation (voxel world, combat, players). Used both for dedicated multiplayer hosts and as the simulation backend for single-player.
- `renderer` — library. Voxel/scene rendering on Vulkan via `ash`, decoupled from windowing so it can be driven by `client`.
- `renderer`: library. Voxel/scene rendering on Vulkan via `ash`, decoupled from windowing so it can be driven by `client`.
- `shared` — library. Types and protocol shared between `client` and `server` (world/voxel data, network messages, combat primitives). Stays lean and dep-light; no `mlua`, no rendering, no engine internals.
- `shared`: library. Types and protocol shared between `client` and `server` (world/voxel data, network messages, combat primitives). Stays lean and dep-light; no `mlua`, no rendering, no engine internals.
- `scripting` — library. Lua modding API and bindings (owns the `mlua` dependency, `UserData` wrappers around `shared` types, API table registration, mod loader). Both `client` and `server` depend on it.
- `scripting`: library. Lua modding API and bindings (owns the `mlua` dependency, `UserData` wrappers around `shared` types, API table registration, mod loader). Both `client` and `server` depend on it.
When adding code, keep the boundary tight: protocol/data types and game-rule primitives go in `shared`; Lua API surface and `mlua` integration in `scripting`; GPU/draw code in `renderer`; only input, windowing, and presentation glue live in `client`. Avoid growing `client` with simulation logic since it must work identically against either a local or remote `server`.
When adding code, keep the boundary tight: protocol/data types and game-rule primitives go in `shared`; Lua API surface and `mlua` integration in `scripting`; GPU/draw code in `renderer`; only input, windowing, and presentation glue live in `client`. Avoid growing `client` with simulation logic since it must work identically against either a local or remote `server`.
## Modding API (Lua) — dogfooded
## Modding API (Lua): dogfooded
The game exposes a Lua modding API, and **the base game itself is built on top of that same API** rather than treating it as a separate add-on layer. Built-in content (blocks, items, entities, recipes, etc.) is defined through the modding API so that mod authors can read the shipped code as reference for what's possible and how to do it.
The game exposes a Lua modding API, and **the base game itself is built on top of that same API** rather than treating it as a separate add-on layer. Built-in content (blocks, items, entities, recipes, etc.) is defined through the modding API so that mod authors can read the shipped code as reference for what's possible and how to do it.
@ -38,8 +38,8 @@ This has hard implications when adding features:
- Any new gameplay primitive (a new block type, item, entity, ability, …) needs to be reachable through the Lua API, not just a Rust-only path. If you add a Rust-side concept without an API surface, you've broken the dogfooding contract.
- Any new gameplay primitive (a new block type, item, entity, ability, …) needs to be reachable through the Lua API, not just a Rust-only path. If you add a Rust-side concept without an API surface, you've broken the dogfooding contract.
- Prefer extending the API and then *using* it from the engine over adding a parallel Rust-only entry point.
- Prefer extending the API and then *using* it from the engine over adding a parallel Rust-only entry point.
- Keep the API stable and discoverable — mod authors will be reading it. Avoid leaking engine internals through it.
- Keep the API stable and discoverable, since mod authors will be reading it. Avoid leaking engine internals through it.
- The API and its bindings live in the **`scripting`** crate. It owns the `mlua` dependency, the API table registration, and the mod loader. Both `client` and `server` depend on it; `shared` does **not** —`shared` stays the lean protocol/data layer.
- The API and its bindings live in the **`scripting`** crate. It owns the `mlua` dependency, the API table registration, and the mod loader. Both `client` and `server` depend on it; `shared` does **not**, since`shared` stays the lean protocol/data layer.
- Authoritative APIs (world mutation, combat resolution) are defined in `scripting` but gated so the client-side Lua VM can't invoke them. One API surface, two execution contexts: client VM = read-only/UI/effects, server VM = authoritative.
- Authoritative APIs (world mutation, combat resolution) are defined in `scripting` but gated so the client-side Lua VM can't invoke them. One API surface, two execution contexts: client VM = read-only/UI/effects, server VM = authoritative.
- Prefer wrapper newtypes inside `scripting` over `impl UserData for SharedType` in `shared`, to avoid coupling the protocol crate to `mlua`.
- Prefer wrapper newtypes inside `scripting` over `impl UserData for SharedType` in `shared`, to avoid coupling the protocol crate to `mlua`.
@ -47,25 +47,25 @@ The decision to build the base game on top of the modding API, and the client/se
## Assets
## Assets
All game assets live under `/assets` at the repo root, organised into subfolders by kind: `icons/`, `models/`, `shaders/`, `sounds/`, `textures/`, `scripts/`. New assets must be placed in the matching subfolder — do not drop loose files into `/assets` itself, and do not scatter assets inside crate directories.
All game assets live under `/assets` at the repo root, organised into subfolders by kind: `icons/`, `models/`, `shaders/`, `sounds/`, `textures/`, `scripts/`. New assets must be placed in the matching subfolder; do not drop loose files into `/assets` itself, and do not scatter assets inside crate directories.
Assets are published openly under CC-BY-NC-SA 4.0 (see `LICENSE.md`). Binary assets (textures, models, sounds, compiled shaders) are tracked via **Git LFS** — keep `.gitattributes` up to date when adding a new binary file type. Lua scripts and JSON data live in plain Git (text files).
Assets are published openly under CC-BY-NC-SA 4.0 (see `LICENSE.md`). Binary assets (textures, models, sounds, compiled shaders) are tracked via **Git LFS**; keep `.gitattributes` up to date when adding a new binary file type. Lua scripts and JSON data live in plain Git (text files).
## Script locations
## Script locations
Three distinct locations, do not mix them:
Three distinct locations, do not mix them:
- **`/assets/scripts/`** — the base game's own Lua, shipped with the binary. This is the dogfooded "first-party mod" the engine loads through the same API mod authors use. Mirror the structure modders will use (e.g. `scripts/blocks/`, `scripts/items/`, `scripts/entities/`) so it serves as a working reference.
- **`/assets/scripts/`**: the base game's own Lua, shipped with the binary. This is the dogfooded "first-party mod" the engine loads through the same API mod authors use. Mirror the structure modders will use (e.g. `scripts/blocks/`, `scripts/items/`, `scripts/entities/`) so it serves as a working reference.
- **`/mods/`** (top-level) — in-repo example mods or test fixtures. Kept out of `/assets/` because they're not engine-shipped content, and out of `crates/` because they're not Rust source.
- **`/mods/`** (top-level): in-repo example mods or test fixtures. Kept out of `/assets/` because they're not engine-shipped content, and out of `crates/` because they're not Rust source.
- **`<user-data-dir>/mods/`** — player-installed mods, loaded at runtime only. Resolved via the `directories` / `dirs` crate (Linux: `~/.local/share/synvael/mods/`, with platform equivalents elsewhere). Never read from a hard-coded path.
- **`<user-data-dir>/mods/`**: player-installed mods, loaded at runtime only. Resolved via the `directories` / `dirs` crate (Linux: `~/.local/share/synvael/mods/`, with platform equivalents elsewhere). Never read from a hard-coded path.
## Data packs & resource packs
## Data packs & resource packs
Two distinct, orthogonal systems — keep them separate, do not collapse them into one "pack" concept. **Resource packs** are client-side asset overlays (textures, sounds, models, fonts, language files; no logic). **Data packs** are declarative content definitions (JSON/TOML/RON): blocks, items, recipes, loot tables, biomes, tags.
Two distinct, orthogonal systems; keep them separate, do not collapse them into one "pack" concept. **Resource packs** are client-side asset overlays (textures, sounds, models, fonts, language files; no logic). **Data packs** are declarative content definitions (JSON/TOML/RON): blocks, items, recipes, loot tables, biomes, tags.
The load-bearing rule: **do not build a parallel registration system.** The data-pack loader reads the declarative files and calls the same Lua API the engine and Lua mods use — one source of truth (`data/blocks/stone.json` → loader → `blocks.register{ … }`). Each schema is a stable contract; version it deliberately. The decision is recorded in [ADR-0007](docs/adr/0007-declarative-content-via-modding-api.md).
The load-bearing rule: **do not build a parallel registration system.** The data-pack loader reads the declarative files and calls the same Lua API the engine and Lua mods use, giving one source of truth (`data/blocks/stone.json` → loader → `blocks.register{ … }`). Each schema is a stable contract; version it deliberately. The decision is recorded in [ADR-0007](docs/adr/0007-declarative-content-via-modding-api.md).
Full subsystem detail — load order, repo and user-data layout, resolution semantics — lives in [`docs/packs.md`](docs/packs.md).
Full subsystem detail (load order, repo and user-data layout, resolution semantics) lives in [`docs/packs.md`](docs/packs.md).
## Contributing workflow
## Contributing workflow
@ -73,24 +73,24 @@ Before committing a change, verify it against the actual repo state rather than
1. **Verify** the change is actually present and correct in the working tree.
1. **Verify** the change is actually present and correct in the working tree.
2. **Run the linter and formatter** to ensure no regressions or style issues were introduced: `cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --all -- --check`, `selene .`, and `stylua .`.
2. **Run the linter and formatter** to ensure no regressions or style issues were introduced: `cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --all -- --check`, `selene .`, and `stylua .`.
3. **Ensure useful comments are present** before committing — function doc comments (`///`) and inline comments above non-obvious logic, following the documentation style below.
3. **Ensure useful comments are present** before committing: function doc comments (`///`) and inline comments above non-obvious logic, following the documentation style below.
4. **Create a focused git commit** following the commit conventions below.
4. **Create a focused git commit** following the commit conventions below.
Keep commits scoped to a single concept. Do not batch multiple unrelated changes into one commit, and do not leave a verified change uncommitted before moving on to the next.
Keep commits scoped to a single concept. Do not batch multiple unrelated changes into one commit, and do not leave a verified change uncommitted before moving on to the next.
## Concurrency model
## Concurrency model
The game is **multithreaded by design** — single-threaded would not meet the perf budget for voxel meshing, worldgen, rendering, networking, and simulation running together. Code should assume multiple threads and design data ownership accordingly:
The game is **multithreaded by design**: single-threaded would not meet the perf budget for voxel meshing, worldgen, rendering, networking, and simulation running together. Code should assume multiple threads and design data ownership accordingly:
- Prefer message-passing (channels: `crossbeam-channel`, `flume`, or `std::sync::mpsc`) and per-thread ownership over shared mutable state.
- Prefer message-passing (channels: `crossbeam-channel`, `flume`, or `std::sync::mpsc`) and per-thread ownership over shared mutable state.
- When sharing is unavoidable, use the right primitive for the access pattern: `Arc<Mutex<_>>` for low-contention shared state, `Arc<RwLock<_>>` for read-heavy, atomics (`AtomicU32`, `AtomicBool`, …) for counters and flags, lock-free structures (`crossbeam`, `dashmap`) for hot paths. Avoid wrapping large hot data in a single `Mutex` "just in case" — that's how you accidentally serialise the whole engine.
- When sharing is unavoidable, use the right primitive for the access pattern: `Arc<Mutex<_>>` for low-contention shared state, `Arc<RwLock<_>>` for read-heavy, atomics (`AtomicU32`, `AtomicBool`, …) for counters and flags, lock-free structures (`crossbeam`, `dashmap`) for hot paths. Avoid wrapping large hot data in a single `Mutex` "just in case", which is how you accidentally serialise the whole engine.
- Worldgen and chunk meshing are the obvious parallelism wins. A thread pool (e.g. `rayon`, or a hand-rolled one) feeding meshing/generation jobs is expected.
- Worldgen and chunk meshing are the obvious parallelism wins. A thread pool (e.g. `rayon`, or a hand-rolled one) feeding meshing/generation jobs is expected.
- Vulkan command-buffer recording can be parallelised too, but Vulkan **queues** are not free-threaded — only one thread submits to a given queue at a time. Plan ownership of `vk::Queue` accordingly.
- Vulkan command-buffer recording can be parallelised too, but Vulkan **queues** are not free-threaded: only one thread submits to a given queue at a time. Plan ownership of `vk::Queue` accordingly.
- The Lua VMs (one per execution context — client, server) are **not** thread-safe in `mlua`'s default config; treat each VM as owned by a single thread, and dispatch work to/from it via channels.
- The Lua VMs (one per execution context: client, server) are **not** thread-safe in `mlua`'s default config; treat each VM as owned by a single thread, and dispatch work to/from it via channels.
## Logging & error handling
## Logging & error handling
- **Logging:** [`tracing`](https://docs.rs/tracing/) (with `tracing-subscriber` as the output backend). Use `info!` / `warn!` / `error!` / `debug!` / `trace!` macros at appropriate levels, and use **spans** (`#[tracing::instrument]`, `info_span!`) to scope work — they're how you keep multithreaded log output legible. Don't reach for `println!`/`eprintln!` for diagnostics; if it's worth printing, it's worth a `tracing` event.
- **Logging:** [`tracing`](https://docs.rs/tracing/) (with `tracing-subscriber` as the output backend). Use `info!` / `warn!` / `error!` / `debug!` / `trace!` macros at appropriate levels, and use **spans** (`#[tracing::instrument]`, `info_span!`) to scope work, which is how you keep multithreaded log output legible. Don't reach for `println!`/`eprintln!` for diagnostics; if it's worth printing, it's worth a `tracing` event.
- **Errors in libraries** (`shared`, `renderer`, `scripting`): typed error enums via [`thiserror`](https://docs.rs/thiserror/) (`#[derive(Error)]`). Each variant is a distinct, matchable failure mode. Don't expose `anyhow::Error` from a library API.
- **Errors in libraries** (`shared`, `renderer`, `scripting`): typed error enums via [`thiserror`](https://docs.rs/thiserror/) (`#[derive(Error)]`). Each variant is a distinct, matchable failure mode. Don't expose `anyhow::Error` from a library API.
- **Errors in binaries** (`client`, `server`): [`anyhow`](https://docs.rs/anyhow/) at the top level, with `.context("...")` for human-readable layering. Library errors compose into `anyhow::Error` cleanly via `?`.
- **Errors in binaries** (`client`, `server`): [`anyhow`](https://docs.rs/anyhow/) at the top level, with `.context("...")` for human-readable layering. Library errors compose into `anyhow::Error` cleanly via `?`.
- **Never `.unwrap()` or `.expect()` outside `main` / setup / tests**, except where the invariant is genuinely impossible to violate. In the hot path, propagate with `?` and let the caller decide.
- **Never `.unwrap()` or `.expect()` outside `main` / setup / tests**, except where the invariant is genuinely impossible to violate. In the hot path, propagate with `?` and let the caller decide.
@ -103,7 +103,7 @@ The game is **multithreaded by design** — single-threaded would not meet the p
- **Focus:** Describe the code's behavior, the system's state, or technical invariants.
- **Focus:** Describe the code's behavior, the system's state, or technical invariants.
- **Struct Documentation:** Every field in a public or internal struct must have a doc comment (`///`) explaining its purpose and any invariants.
- **Struct Documentation:** Every field in a public or internal struct must have a doc comment (`///`) explaining its purpose and any invariants.
- **Stability:** Treat the documentation as a technical specification for the engine.
- **Stability:** Treat the documentation as a technical specification for the engine.
- **Line breaks:** Do not insert line returns inside a comment unless necessary. A comment that fits on a single line stays on a single line — do not pre-wrap at ~80 chars for aesthetics. Only break across lines when the comment is genuinely long (multi-sentence prose, enumerated invariants) or when a hard break carries meaning (separating an intro line from a bullet list, for instance).
- **Line breaks:** Do not insert line returns inside a comment unless necessary. A comment that fits on a single line stays on a single line; do not pre-wrap at ~80 chars for aesthetics. Only break across lines when the comment is genuinely long (multi-sentence prose, enumerated invariants) or when a hard break carries meaning (separating an intro line from a bullet list, for instance).
## Target platforms
## Target platforms
@ -116,7 +116,7 @@ The game is **multithreaded by design** — single-threaded would not meet the p
## Determinism stance
## Determinism stance
- **Worldgen is seed-deterministic.** Given the same seed, worldgen must produce bit-for-bit the same world on any platform, any time. This constrains worldgen code: use a fixed RNG algorithm (e.g. `wyrand`, `xoshiro`) —**never**`rand::thread_rng()` or anything seeded from the OS. Do not depend on `HashMap` iteration order (Rust's default hasher is randomised); use `BTreeMap`, `IndexMap`, or sort explicitly when iteration order feeds into RNG draws or content placement. See [ADR-0003](docs/adr/0003-seed-deterministic-worldgen.md).
- **Worldgen is seed-deterministic.** Given the same seed, worldgen must produce bit-for-bit the same world on any platform, any time. This constrains worldgen code: use a fixed RNG algorithm (e.g. `wyrand`, `xoshiro`),**never**`rand::thread_rng()` or anything seeded from the OS. Do not depend on `HashMap` iteration order (Rust's default hasher is randomised); use `BTreeMap`, `IndexMap`, or sort explicitly when iteration order feeds into RNG draws or content placement. See [ADR-0003](docs/adr/0003-seed-deterministic-worldgen.md).
- **Simulation is server-authoritative.** The server runs the truth; clients send inputs and receive state snapshots, predicting locally for responsiveness and reconciling on disagreement. Combat, physics, mob AI, and item drops are computed once, on the server.
- **Simulation is server-authoritative.** The server runs the truth; clients send inputs and receive state snapshots, predicting locally for responsiveness and reconciling on disagreement. Combat, physics, mob AI, and item drops are computed once, on the server.
- **Full simulation determinism (lockstep / rollback / replay-from-inputs) is a non-goal.** This means floats, hash-map iteration, and platform-specific math are all fair game *outside of worldgen*. Don't pay the cost of cross-platform float reproducibility for a feature we're not building. See [ADR-0004](docs/adr/0004-server-authoritative-simulation.md).
- **Full simulation determinism (lockstep / rollback / replay-from-inputs) is a non-goal.** This means floats, hash-map iteration, and platform-specific math are all fair game *outside of worldgen*. Don't pay the cost of cross-platform float reproducibility for a feature we're not building. See [ADR-0004](docs/adr/0004-server-authoritative-simulation.md).
@ -125,11 +125,11 @@ The game is **multithreaded by design** — single-threaded would not meet the p
All registered content (blocks, items, recipes, biomes, entities, …) is identified by a **namespaced string** of the form `"namespace:id"`. The full rationale is in [ADR-0005](docs/adr/0005-namespaced-content-ids.md).
All registered content (blocks, items, recipes, biomes, entities, …) is identified by a **namespaced string** of the form `"namespace:id"`. The full rationale is in [ADR-0005](docs/adr/0005-namespaced-content-ids.md).
- **Engine's reserved namespace:**`core:`. All first-party content registered by the base game uses it (`"core:stone"`, `"core:iron_sword"`). Mods pick their own short namespace (`"mymod:weird_dirt"`).
- **Engine's reserved namespace:**`core:`. All first-party content registered by the base game uses it (`"core:stone"`, `"core:iron_sword"`). Mods pick their own short namespace (`"mymod:weird_dirt"`).
- **Strict form required.** A bare ID with no `:` is an **error at registration / parse time**, not silently coerced to `core:`. Same rule everywhere: engine scripts, data packs, Lua mods, recipe references, save files. No exceptions — the symmetry is the point.
- **Strict form required.** A bare ID with no `:` is an **error at registration / parse time**, not silently coerced to `core:`. Same rule everywhere: engine scripts, data packs, Lua mods, recipe references, save files. No exceptions; the symmetry is the point.
- **Charset:** namespace and id are each `[a-z0-9_-]+`, exactly one `:` between them. Lowercase ASCII only. No uppercase, no Unicode, no spaces, no dots, no slashes. Keeps IDs greppable, filesystem-safe, and unambiguous in logs and save files.
- **Charset:** namespace and id are each `[a-z0-9_-]+`, exactly one `:` between them. Lowercase ASCII only. No uppercase, no Unicode, no spaces, no dots, no slashes. Keeps IDs greppable, filesystem-safe, and unambiguous in logs and save files.
- **Runtime representation:** intern each ID string into a small integer handle (e.g. `BlockId(u32)`) at registration time. Hot paths compare handles, not strings. Keep the original string for display, save/load, and the Lua API surface.
- **Runtime representation:** intern each ID string into a small integer handle (e.g. `BlockId(u32)`) at registration time. Hot paths compare handles, not strings. Keep the original string for display, save/load, and the Lua API surface.
> *Project name note:* The project is named **Synvael** ("Catalyst" was the working codename). The engine namespace is deliberately `core:`— not the project name — so it stays stable independent of branding.
> *Project name note:* The project is named **Synvael** ("Catalyst" was the working codename). The engine namespace is deliberately `core:`(not the project name), so it stays stable independent of branding.
## Coordinate system & units
## Coordinate system & units
@ -137,7 +137,7 @@ All registered content (blocks, items, recipes, biomes, entities, …) is identi
- **Handedness:****right-handed** (default math convention; +X right, +Y up, +Z toward the viewer / out of the screen).
- **Handedness:****right-handed** (default math convention; +X right, +Y up, +Z toward the viewer / out of the screen).
- **World unit:****1 unit = 1 block.** Blocks are simply 0.5 m in physical scale, but inside the engine everything is counted in *blocks*, not metres. A player is therefore 3 units tall × 2 units wide in world coordinates.
- **World unit:****1 unit = 1 block.** Blocks are simply 0.5 m in physical scale, but inside the engine everything is counted in *blocks*, not metres. A player is therefore 3 units tall × 2 units wide in world coordinates.
Implementation gotchas that arise because neighbouring tools use different conventions (Vulkan clip space, Blender import, glTF) are collected in [`docs/rendering.md`](docs/rendering.md). They are not convention changes — only mismatches to handle in one agreed place.
Implementation gotchas that arise because neighbouring tools use different conventions (Vulkan clip space, Blender import, glTF) are collected in [`docs/rendering.md`](docs/rendering.md). They are not convention changes, only mismatches to handle in one agreed place.
## Branching Strategy & Workflow
## Branching Strategy & Workflow
@ -160,9 +160,9 @@ Format:
```
```
- **Type** (required, exactly one): `feat` (new feature), `fix` (bug fix), `refactor` (no behaviour change), `perf`, `docs`, `test`, `chore` (build/tooling/deps), `build`, `ci`. Breaking changes append `!` before the colon: `feat(scripting)!: …`.
- **Type** (required, exactly one): `feat` (new feature), `fix` (bug fix), `refactor` (no behaviour change), `perf`, `docs`, `test`, `chore` (build/tooling/deps), `build`, `ci`. Breaking changes append `!` before the colon: `feat(scripting)!: …`.
- **Scope** (required): the crate the change primarily affects —`client`, `server`, `renderer`, `shared`, `scripting`. For changes that genuinely span the whole workspace (e.g. workspace-level Cargo config, repo-wide `.gitattributes`), use `workspace`. For changes confined to non-Rust assets, use `assets`. Avoid omitting the scope, and avoid inventing per-commit scopes.
- **Scope** (required): the crate the change primarily affects, one of`client`, `server`, `renderer`, `shared`, `scripting`. For changes that genuinely span the whole workspace (e.g. workspace-level Cargo config, repo-wide `.gitattributes`), use `workspace`. For changes confined to non-Rust assets, use `assets`. Avoid omitting the scope, and avoid inventing per-commit scopes.
- **Subject:** imperative mood ("add", not "added" / "adds"), lowercase, no trailing period, ≤ ~72 chars.
- **Subject:** imperative mood ("add", not "added" / "adds"), lowercase, no trailing period, ≤ ~72 chars.
- **Body:** keep commit messages short and simple — usually subject only, no body. The exception is `fix(...)` commits for non-trivial bugs, where a body explaining the root cause and why the fix works is valuable. Don't pad routine `feat`/`refactor`/`chore`/`docs` commits with bodies.
- **Body:** keep commit messages short and simple, usually subject only, no body. The exception is `fix(...)` commits for non-trivial bugs, where a body explaining the root cause and why the fix works is valuable. Don't pad routine `feat`/`refactor`/`chore`/`docs` commits with bodies.
Project guidance for this repository is maintained in **[AGENTS.md](AGENTS.md)** — the tool-agnostic single source of truth for architecture, conventions, and workflow. It is imported below so Claude Code loads it directly; edit `AGENTS.md`, not this file.
Project guidance for this repository is maintained in **[AGENTS.md](AGENTS.md)**, the tool-agnostic single source of truth for architecture, conventions, and workflow. It is imported below so Claude Code loads it directly; edit `AGENTS.md`, not this file.
@ -25,8 +25,8 @@ This program is distributed in the hope that it will be useful, but WITHOUT ANY
The game assets are licensed under the **Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License**.
The game assets are licensed under the **Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License**.
**Under this license, you are free to:**
**Under this license, you are free to:**
- **Share** — copy and redistribute the material in any medium or format.
- **Share**: copy and redistribute the material in any medium or format.
- **Adapt** — remix, transform, and build upon the material.
- **Adapt**: remix, transform, and build upon the material.
**Under the following terms:**
**Under the following terms:**
- **Attribution (BY):** You must give appropriate credit, provide a link to the license, and indicate if changes were made.
- **Attribution (BY):** You must give appropriate credit, provide a link to the license, and indicate if changes were made.
@ -37,13 +37,13 @@ To view a copy of this license, visit [http://creativecommons.org/licenses/by-nc
This license explicitly permits the creation and distribution of **resource packs** that modify or replace official assets, provided they remain non-commercial and are themselves released under CC-BY-NC-SA 4.0.
This license explicitly permits the creation and distribution of **resource packs** that modify or replace official assets, provided they remain non-commercial and are themselves released under CC-BY-NC-SA 4.0.
**Open distribution of assets:** Game assets are published openly in this repository (typically tracked via Git LFS). This is intentional. Modders, resource pack authors, and self-builders are welcome to clone, reference, and remix them. The non-commercial and share-alike obligations remain in force regardless of how the assets are obtained — cloning the repository does not grant any commercial-use rights.
**Open distribution of assets:** Game assets are published openly in this repository (typically tracked via Git LFS). This is intentional. Modders, resource pack authors, and self-builders are welcome to clone, reference, and remix them. The non-commercial and share-alike obligations remain in force regardless of how the assets are obtained, cloning the repository does not grant any commercial-use rights.
---
---
## Part 3: Modding Policy
## Part 3: Modding Policy
**Applicability:** Mods for Synvael — content written by third parties that runs through the modding API or replaces game assets.
**Applicability:** Mods for Synvael, content written by third parties that runs through the modding API or replaces game assets.
Cryoforge Nexus grants a limited, non-exclusive, non-transferable license to use the Game Assets (Part 2) and the modding API (Part 1) specifically for the creation, distribution, and play of mods for Synvael, provided that:
Cryoforge Nexus grants a limited, non-exclusive, non-transferable license to use the Game Assets (Part 2) and the modding API (Part 1) specifically for the creation, distribution, and play of mods for Synvael, provided that:
@ -51,7 +51,7 @@ Cryoforge Nexus grants a limited, non-exclusive, non-transferable license to use
2. **Platform Integration:** Mods are encouraged to be shared through the official Synvael modding platform.
2. **Platform Integration:** Mods are encouraged to be shared through the official Synvael modding platform.
3. **No Standalone Usage:** You may not use the Game Assets to create a standalone game or software product unrelated to Synvael.
3. **No Standalone Usage:** You may not use the Game Assets to create a standalone game or software product unrelated to Synvael.
4. **Credit:** You must credit Synvael as the source of the assets.
4. **Credit:** You must credit Synvael as the source of the assets.
5. **Mod licensing — Lua vs native code:**
5. **Mod licensing, Lua vs native code:**
- **Lua mods** running on the modding-API VM are *not* considered derivative works of the engine. Lua mod authors may license their own code under any license they choose.
- **Lua mods** running on the modding-API VM are *not* considered derivative works of the engine. Lua mod authors may license their own code under any license they choose.
- **Native (Rust) mods** that link against engine internals are derivative works of the engine and are subject to the AGPLv3 terms of Part 1. If distributed, their source must be made available under AGPLv3.
- **Native (Rust) mods** that link against engine internals are derivative works of the engine and are subject to the AGPLv3 terms of Part 1. If distributed, their source must be made available under AGPLv3.
6. **Mod assets:** Original assets authored by the mod creator may be released under any license. Assets *derived* from official Synvael assets are governed by Part 2 (CC-BY-NC-SA 4.0) and must be shared under the same terms.
6. **Mod assets:** Original assets authored by the mod creator may be released under any license. Assets *derived* from official Synvael assets are governed by Part 2 (CC-BY-NC-SA 4.0) and must be shared under the same terms.
@ -8,7 +8,7 @@ Documentation lives at three altitudes. Each layer answers a different question,
| Layer | Location | Answers | Churn |
| Layer | Location | Answers | Churn |
|-------|----------|---------|-------|
|-------|----------|---------|-------|
| **Project conventions** | [`AGENTS.md`](../AGENTS.md) | "What rules apply no matter which feature I touch?" | Slow — a finite set of cross-cutting invariants |
| **Project conventions** | [`AGENTS.md`](../AGENTS.md) | "What rules apply no matter which feature I touch?" | Slow, a finite set of cross-cutting invariants |
| **Subsystem technical docs** | this `docs/` tree + Rust module docs (`//!`, `///`) | "How does *this* subsystem work?" | Grows with features, distributed across files |
| **Subsystem technical docs** | this `docs/` tree + Rust module docs (`//!`, `///`) | "How does *this* subsystem work?" | Grows with features, distributed across files |
| **Architecture decisions** | [`docs/adr/`](adr/) | "*Why* was this chosen over the alternatives?" | Append-only, one file per decision |
| **Architecture decisions** | [`docs/adr/`](adr/) | "*Why* was this chosen over the alternatives?" | Append-only, one file per decision |
@ -23,25 +23,25 @@ The rule that keeps `AGENTS.md` lean: if a piece of documentation is specific to
## Relationship to the design specification
## Relationship to the design specification
The canonical **design** specification — intent, world rules, gameplay-system behaviour, and unresolved questions — is maintained separately and is **not** part of this repository. This `docs/` tree records how the engine *implements* those designs.
The canonical **design** specification (intent, world rules, gameplay-system behaviour, and unresolved questions) is maintained separately and is **not** part of this repository. This `docs/` tree records how the engine *implements* those designs.
Each subsystem note should name the design topic it implements (by title, e.g. "Design source: *Worldgen*"), so the trail from intent to implementation exists without coupling the repository to an external location. When the implementation and the design disagree, surface the disagreement rather than silently resolving it in code.
Each subsystem note should name the design topic it implements (by title, e.g. "Design source: *Worldgen*"), so the trail from intent to implementation exists without coupling the repository to an external location. When the implementation and the design disagree, surface the disagreement rather than silently resolving it in code.
## Index
## Index
- [`adr/`](adr/) — Architecture Decision Records.
- [`adr/`](adr/): Architecture Decision Records.
- [`adr/0001-record-architecture-decisions.md`](adr/0001-record-architecture-decisions.md) — establishes the ADR practice.
- [`adr/0001-record-architecture-decisions.md`](adr/0001-record-architecture-decisions.md): establishes the ADR practice.
- [`adr/0002-half-scale-voxel-grid.md`](adr/0002-half-scale-voxel-grid.md) — the half-scale voxel grid.
- [`adr/0002-half-scale-voxel-grid.md`](adr/0002-half-scale-voxel-grid.md): the half-scale voxel grid.
- [`adr/0006-base-game-on-modding-api.md`](adr/0006-base-game-on-modding-api.md) — base game built on the modding API.
- [`adr/0006-base-game-on-modding-api.md`](adr/0006-base-game-on-modding-api.md): base game built on the modding API.
- [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md) — declarative content loads through the modding API.
- [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): declarative content loads through the modding API.
- [`adr/template.md`](adr/template.md) — template for new decisions.
- [`adr/template.md`](adr/template.md): template for new decisions.
@ -22,5 +22,5 @@ Architecture Decision Records (ADRs), in the lightweight format popularised by M
- The rationale behind significant choices is preserved with its historical context, independent of how the code later evolves.
- The rationale behind significant choices is preserved with its historical context, independent of how the code later evolves.
- `AGENTS.md` stays lean: it can state a rule and link to the ADR that explains it, rather than carrying the justification inline.
- `AGENTS.md` stays lean: it can state a rule and link to the ADR that explains it, rather than carrying the justification inline.
- A small, ongoing discipline is required — a contributor making a significant architectural choice is expected to add an ADR for it. Trivial or easily reversible choices do not warrant one.
- A small, ongoing discipline is required : a contributor making a significant architectural choice is expected to add an ADR for it. Trivial or easily reversible choices do not warrant one.
- Because records are numbered and append-only, the directory grows monotonically without any single file becoming a bottleneck.
- Because records are numbered and append-only, the directory grows monotonically without any single file becoming a bottleneck.
@ -17,7 +17,7 @@ The simulation is **server-authoritative**. The server computes the truth; clien
## Consequences
## Consequences
- The engine does not pay the cost of cross-platform float reproducibility for the simulation — only worldgen carries that burden.
- The engine does not pay the cost of cross-platform float reproducibility for the simulation, only worldgen carries that burden.
- Clients require prediction and reconciliation logic to stay responsive against an authoritative server.
- Clients require prediction and reconciliation logic to stay responsive against an authoritative server.
- Cheat resistance follows from authority residing on the server.
- Cheat resistance follows from authority residing on the server.
- Features that would require deterministic replay of the full simulation are out of scope by this decision; reopening them would mean revisiting the determinism cost deliberately avoided here.
- Features that would require deterministic replay of the full simulation are out of scope by this decision; reopening them would mean revisiting the determinism cost deliberately avoided here.
All registered content — blocks, items, recipes, biomes, entities, and so on — needs a stable identifier that is unambiguous across the engine, data packs, Lua mods, recipe references, and save files. First-party and third-party content must coexist without collision, and identifiers must survive being written to disk and read back.
All registered content (blocks, items, recipes, biomes, entities, and so on) needs a stable identifier that is unambiguous across the engine, data packs, Lua mods, recipe references, and save files. First-party and third-party content must coexist without collision, and identifiers must survive being written to disk and read back.
## Decision
## Decision
@ -13,7 +13,7 @@ Content is identified by a **namespaced string** of the form `"namespace:id"`.
- The namespace `core:` is reserved for first-party content (`"core:stone"`, `"core:iron_sword"`). Mods choose their own short namespace (`"mymod:weird_dirt"`).
- The namespace `core:` is reserved for first-party content (`"core:stone"`, `"core:iron_sword"`). Mods choose their own short namespace (`"mymod:weird_dirt"`).
- The strict form is mandatory. A bare id with no `:` is an **error at registration / parse time**, never silently coerced to `core:`. The rule is identical everywhere: engine scripts, data packs, Lua mods, recipe references, save files.
- The strict form is mandatory. A bare id with no `:` is an **error at registration / parse time**, never silently coerced to `core:`. The rule is identical everywhere: engine scripts, data packs, Lua mods, recipe references, save files.
- Charset: namespace and id are each `[a-z0-9_-]+` with exactly one `:` between them. Lowercase ASCII only — no uppercase, Unicode, spaces, dots, or slashes.
- Charset: namespace and id are each `[a-z0-9_-]+` with exactly one `:` between them. Lowercase ASCII only, no uppercase, Unicode, spaces, dots, or slashes.
- At registration time each id string is interned into a small integer handle (e.g. `BlockId(u32)`). Hot paths compare handles; the original string is kept for display, save/load, and the Lua API.
- At registration time each id string is interned into a small integer handle (e.g. `BlockId(u32)`). Hot paths compare handles; the original string is kept for display, save/load, and the Lua API.
The reserved namespace is deliberately `core:` rather than the project name, so it remains stable independent of branding.
The reserved namespace is deliberately `core:` rather than the project name, so it remains stable independent of branding.
The engine exposes a Lua modding API. A modding API can be treated as a bolt-on layer over a separate, privileged engine path, or the engine's own content can be defined through the same API that mod authors use. The former tends to let the engine drift ahead of the API, leaving mod authors with second-class capabilities and no working reference.
The engine exposes a Lua modding API. A modding API can be treated as a bolt-on layer over a separate, privileged engine path, or the engine's own content can be defined through the same API that mod authors use. The former tends to let the engine drift ahead of the API, leaving mod authors with second-class capabilities and no working reference.
The API is also exposed to two execution contexts — a client-side Lua VM and a server-side Lua VM — with different trust levels. Authoritative operations (world mutation, combat resolution) must not be invocable from the client VM.
The API is also exposed to two execution contexts, a client-side Lua VM and a server-side Lua VM, with different trust levels. Authoritative operations (world mutation, combat resolution) must not be invocable from the client VM.
## Decision
## Decision
@ -23,5 +23,5 @@ The base game is built on top of the modding API; the shipped content (blocks, i
- Mod authors can read first-party content as a faithful example of what the API allows, because it uses no privileged path they lack.
- Mod authors can read first-party content as a faithful example of what the API allows, because it uses no privileged path they lack.
- The API must stay stable and discoverable, since it is both the engine's and the modder's surface; engine internals must not leak through it.
- The API must stay stable and discoverable, since it is both the engine's and the modder's surface; engine internals must not leak through it.
- The client/server trust boundary is enforced at the API layer rather than re-checked ad hoc.
- The client/server trust boundary is enforced at the API layer rather than re-checked ad hoc.
- A feature cannot be "added to the engine" and exposed to mods later as an afterthought — the API surface is part of the definition of done.
- A feature cannot be "added to the engine" and exposed to mods later as an afterthought, the API surface is part of the definition of done.
- Declarative content loading follows the same single-path rule; see [ADR-0007](0007-declarative-content-via-modding-api.md).
- Declarative content loading follows the same single-path rule; see [ADR-0007](0007-declarative-content-via-modding-api.md).
@ -18,7 +18,7 @@ No parallel registration system is built. The loader reads the declarative files
data/blocks/stone.json → loader → blocks.register{ id = "stone", … }
data/blocks/stone.json → loader → blocks.register{ id = "stone", … }
```
```
The loader belongs in `scripting` (or a sibling crate if it grows). Engine first-party content may use either JSON or Lua, whichever fits. Every data-pack schema is a stable contract, the same as the Lua API — version it deliberately.
The loader belongs in `scripting` (or a sibling crate if it grows). Engine first-party content may use either JSON or Lua, whichever fits. Every data-pack schema is a stable contract, the same as the Lua API, version it deliberately.
## Canonical load order
## Canonical load order
@ -28,7 +28,7 @@ Later layers override earlier ones:
base game (assets/scripts + assets/data)
base game (assets/scripts + assets/data)
→ data packs (declarative content add/override)
→ data packs (declarative content add/override)
→ Lua mods (full API access)
→ Lua mods (full API access)
→ resource packs (client-only, asset overlay — always last so visuals win)
→ resource packs (client-only, asset overlay, always last so visuals win)
Implementation notes for the `renderer` crate and for code that imports geometry. The project-wide coordinate convention itself (+Y up, right-handed, 1 unit = 1 block) is stated in [`AGENTS.md`](../AGENTS.md#coordinate-system--units); this note collects the gotchas that arise because neighbouring systems use different conventions. These are not convention changes — only mismatches to handle in one agreed place.
Implementation notes for the `renderer` crate and for code that imports geometry. The project-wide coordinate convention itself (+Y up, right-handed, 1 unit = 1 block) is stated in [`AGENTS.md`](../AGENTS.md#coordinate-system--units); this note collects the gotchas that arise because neighbouring systems use different conventions. These are not convention changes, only mismatches to handle in one agreed place.
## Vulkan clip space
## Vulkan clip space
Vulkan clip space is **Y-down** by default, and its depth range is `[0, 1]` (not `[-1, 1]` as in OpenGL). The projection matrix must flip Y, or the viewport height is set negative — both are common idioms in `ash` examples. World and view space stay Y-up; only clip space differs.
Vulkan clip space is **Y-down** by default, and its depth range is `[0, 1]` (not `[-1, 1]` as in OpenGL). The projection matrix must flip Y, or the viewport height is set negative, both are common idioms in `ash` examples. World and view space stay Y-up; only clip space differs.
## Blender import
## Blender import
Blender is **Z-up, right-handed**. Models exported from Blender need a coordinate swap on import: rotate −90° around X, or swap Y/Z with a sign change. Decide once where that swap happens — at export, at import, or never (by adopting the source convention) — and keep it in a single place. Performing it in two places eventually produces a model that is mirrored or upside-down.
Blender is **Z-up, right-handed**. Models exported from Blender need a coordinate swap on import: rotate −90° around X, or swap Y/Z with a sign change. Decide once where that swap happens, at export, at import, or never (by adopting the source convention), and keep it in a single place. Performing it in two places eventually produces a model that is mirrored or upside-down.