synvael/AGENTS.md

217 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AGENTS.md
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
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, 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 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, 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/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.
## Workspace layout
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.
- `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`.
- `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.
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
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.
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.
- 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, 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**, 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.
- Prefer wrapper newtypes inside `scripting` over `impl UserData for SharedType` in `shared`, to avoid coupling the protocol crate to `mlua`.
The decision to build the base game on top of the modding API, and the client/server VM gating that follows from it, are recorded in [ADR-0006](docs/adr/0006-base-game-on-modding-api.md).
## 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.
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
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.
- **`/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.
## 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.
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).
## Contributing workflow
Before committing a change, verify it against the actual repo state rather than assuming it is correct: read the files, inspect `git diff`, and run `cargo check` / `cargo clippy` / `cargo test` as appropriate. Then follow this loop for each change:
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 .`.
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.
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
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.
- 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.
- 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.
## 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, 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 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.
## Testing policy
Tests are prioritised by risk, not by a coverage percentage. Effort is directed where "compiles and appears correct" does not guarantee correctness. The following categories require accompanying unit tests, written in the same change that introduces or modifies the logic:
- **Pure, algorithmic logic** — values in, values out, no I/O, no GPU, no windowing: coordinate and index math, packing/unpacking, meshing math, and similar self-contained computation. These are cheap to test and their edges are easy to get subtly wrong.
- **Correctness traps** — behaviour where a plausible implementation is silently wrong on an edge case: sign handling, off-by-one, integer overflow or truncation, bit-packing boundaries. As a canonical example, world-to-chunk conversion floors via `div_euclid` rather than truncating via `/`; a test on negative inputs pins that contract and prevents a regression to `/`.
- **Load-bearing invariants, especially determinism** — per the determinism stance below, worldgen is seed-deterministic and bit-for-bit reproducible. That contract cannot be verified by inspection and is guarded by tests (for example, generating a chunk twice from one seed and asserting equality). Determinism is guarded aggressively.
Subsystems that are I/O- or hardware-bound — the `renderer`/Vulkan GPU paths, `client` windowing and input, and top-level binary wiring — are validated through integration and manual/visual verification rather than unit tests, since their behaviour depends on a live device, window, or process rather than on pure logic. The appropriate mechanism differs; the expectation of verification does not.
Unit tests live beside the code (`#[cfg(test)] mod tests`) and run with `cargo test -p <crate>`.
## Lint suppressions
The workspace opts into strict linting: Clippy's `pedantic` group plus restriction lints that ban `unwrap`, `expect`, and `print` outside the permitted contexts (see `[workspace.lints]` in the root `Cargo.toml`). Suppressions are therefore expected at specific sites, and are governed by these rules:
- **Prefer `#[expect(...)]` over `#[allow(...)]`** for a localised suppression. An `#[expect]` becomes a warning (`unfulfilled_lint_expectations`) if the lint it names no longer fires, so an obsolete suppression surfaces and is removed instead of lingering silently. `#[allow]` never self-reports and accumulates into dead noise.
- **Suppress narrowly.** Name the exact lint(s), and attach the attribute to the smallest scope that covers the site — a statement, expression, or item — never a broad crate-level `#![allow]`. The sole standing exception is a deliberate crate-wide policy, such as `#![allow(unsafe_code)]` in `renderer`, where the suppression is the architectural intent rather than a local waiver.
- **Justify non-obvious suppressions.** Where the reason a lint is safe to suppress is not self-evident from the surrounding code, precede the attribute with a brief comment stating why (for example, that a cast is provably in range).
- **Never suppress `correctness`-tier lints.** Those indicate real defects; fix the code instead.
## Documentation style
- **Objective Tone:** All comments (both doc comments `///` and inline `//`) must be written in a formal, objective, and neutral tone.
- **No Personal Pronouns:** Avoid first-person ("we", "our", "us") or second-person ("you", "your") pronouns.
- **Voice:** Use the passive voice or neutral descriptive language. Instead of "We initialize the buffer," use "The buffer is initialized." Instead of "Your vertex shader needs this," use "The vertex shader requires this."
- **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.
- **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).
## Target platforms
**Linux and Windows only.** No macOS, no mobile, no console, no web/WASM.
- Both platforms have native Vulkan via vendor ICDs (NVIDIA / AMD / Intel). No translation layer (no MoltenVK story), so modern Vulkan extensions can be adopted freely without consulting a portability matrix.
- **File paths:** always use `std::path::Path` / `PathBuf` and the `directories` (or `dirs`) crate for user-data lookup. Never hard-code `/home/...` or `~`. Linux follows XDG (`$XDG_DATA_HOME` etc.); Windows uses `%APPDATA%`.
- **Line endings:** repo is LF-only. Set `core.autocrlf = false` and/or a `.gitattributes` with `* text eol=lf` to keep diffs clean across the two OSes.
- **Filename casing:** never have two files differing only in case. Linux is case-sensitive; Windows isn't; the mismatch produces confusing "works on my machine" bugs.
## 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).
- **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).
## Content IDs & namespacing
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"`).
- **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.
- **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.
## Coordinate system & units
- **Up axis:** **+Y**.
- **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.
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
- **`main` vs `dev`**: The repository follows a strict workflow. The `main` branch is reserved purely for stable releases. All active development happens on the **`dev`** branch.
- **Feature Branches**: For any large feature, always create a new branch off of `dev` (e.g., `feat/new-worldgen`). Do not commit large, work-in-progress features directly to `dev`. Once the feature is complete and verified, merge it back into `dev`.
- **Continuous Integration**: The project enforces strict linting and formatting via GitHub Actions. This includes workspace-level `clippy` rules (banning `unwrap` and `println!`), `cargo fmt`, `selene` for Lua, and `stylua`. Always ensure your code passes these tools locally before pushing.
## Commit conventions
[**Conventional Commits**](https://www.conventionalcommits.org/) with **mandatory crate-name scope**.
Format:
```
<type>(<crate>): <imperative subject>
[optional body]
[optional footer(s)]
```
- **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, 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.
- **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.
Examples:
```
feat(scripting): expose blocks.register to lua
fix(renderer): clamp swapchain extent to surface caps
refactor(shared): split network message types into submodule
chore(workspace): bump ash to 0.39
docs(assets): document texture-pack overlay layout
feat(server)!: change tick rate from 20 to 30 Hz
```
If a single commit truly touches multiple crates and can't be reasonably split, that's a signal to split it. Only fall back to `workspace` scope when the change is intrinsically workspace-wide.
**Do not add any AI assistant as a co-author on commits.** No `Co-Authored-By: ...` trailers for assistants, no "Generated with ..." footers. Commits are authored by the human running the work.
## Common commands
```bash
cargo build # build all crates
cargo run -p client # run the windowed client
cargo run -p server # run the server
cargo test # run all tests
cargo test -p renderer it_works # run a single test by name
cargo check -p <crate> # fast type-check one crate
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt
selene .
stylua .
```