# Development Guidelines This file is the single source of truth for architecture, conventions, and workflow for human contributors working on Synvael. Tool-specific entry points (like `CLAUDE.md`) and AI instructions (like `AGENTS.md`) import or summarize this file rather than duplicating it. ## Documentation map Our documentation is layered by altitude. Try to keep content at the layer it belongs to so no single file ends up hoarding everything. - **This file (`DEVELOPMENT.md`)**: covers cross-cutting engineering conventions and architecture invariants. These are the rules that apply no matter what feature you're touching. There is a finite set of these, so this file should grow pretty slowly. Subsystem-specific specs do not belong here. - **[`docs/`](docs/) and Rust module docs (`//!`, `///`)**: per-subsystem technical implementation docs explaining how an individual system (meshing, networking, worldgen, etc.) is actually built. We prefer module docs right next to the code. If a design spans multiple files, promote it to a `docs/.md` note. - **[`docs/adr/`](docs/adr/)**: Architecture Decision Records capturing the "why" behind significant, hard-to-reverse choices, with one append-only file per decision. Check out [`docs/README.md`](docs/README.md) for the full structure and [`docs/adr/0001-record-architecture-decisions.md`](docs/adr/0001-record-architecture-decisions.md) for the practice itself. The canonical game design specification covering intent, world rules, and gameplay behaviour is maintained separately and isn't part of this repository. This repo only documents how that design gets implemented. ## Workspace layout We use a Cargo workspace (resolver = "3", edition 2024) containing six crates under `crates/`: - `client`: binary. This is the windowed application using `winit` 0.30 (`ApplicationHandler` pattern, `ControlFlow::Poll`). It also pulls in `image`. This is the player-facing app titled "Synvael". It handles input, windowing, and drives the renderer. - `server`: binary. The authoritative game simulation covering the voxel world, combat, and players. It is used for dedicated multiplayer hosts and also acts as the simulation backend for single-player. - `renderer`: library. Voxel and scene rendering on Vulkan using `ash`. It is deliberately decoupled from windowing so the `client` can drive it. - `shared`: library. Types and protocols shared between `client` and `server` like world and voxel data, network messages, and combat primitives. This crate stays lean and dependency-light. It has no `mlua`, no rendering, and no engine internals. - `scripting`: library. The Lua modding API and bindings. This crate owns the `mlua` dependency, `UserData` wrappers around `shared` types, API table registration, and the mod loader. Both `client` and `server` depend on it. - `net`: library. QUIC transport, connection lifecycle, and wire framing for the client-server protocol. It owns the async runtime (`tokio`) and the `quinn` and `rustls` dependencies. Both `client` and `server` depend on it. See [ADR-0010](docs/adr/0010-net-crate-async-runtime.md) for more details. When adding code, please keep these boundaries tight. Protocol and data types plus game-rule primitives go in `shared`. Lua API surfaces and `mlua` integration live in `scripting`. GPU and drawing code goes in `renderer`. Transport and connection code belongs in `net` (but protocol message types stay in `shared`). Only input, windowing, and presentation glue should live in `client`. Try to avoid growing `client` with simulation logic, since it needs to work identically whether it's talking to a local or remote `server`. ## Modding API (Lua): dogfooded The game exposes a Lua modding API, and **the base game itself is built directly on top of that same API** rather than treating it as a separate add-on layer. Built-in content like blocks, items, entities, and recipes are defined through the modding API so mod authors can read the shipped code as a reference for what's possible and how to do it. This has some strict implications when adding new features: - Any new gameplay primitive (a new block type, item, entity, ability, etc.) needs to be accessible through the Lua API, not just as a Rust-only path. If you add a Rust-side concept without an API surface, you've broken our dogfooding rule. - Prefer extending the API and then *using* it from the engine over adding a parallel Rust-only entry point. - Keep the API stable and easy to discover, since mod authors will be reading it. Avoid leaking engine internals through it. - The API and its bindings live strictly 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 it needs to stay as a lean protocol layer. - Authoritative APIs like world mutation and combat resolution are defined in `scripting` but gated so the client-side Lua VM cannot invoke them. We use one API surface across two execution contexts: the client VM is for read-only UI and effects, while the server VM is authoritative. - Use wrapper newtypes inside `scripting` rather than `impl UserData for SharedType` in `shared`. This prevents 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 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, organized into subfolders by kind: `icons/`, `models/`, `shaders/`, `sounds/`, `textures/`, and `scripts/`. New assets must go in the matching subfolder. Don't drop loose files directly into `/assets`, and don't scatter assets inside crate directories. Assets are published openly under CC-BY-NC-SA 4.0 (check `LICENSE.md`). Binary assets like textures, models, sounds, and compiled shaders are tracked using **Git LFS**. Keep `.gitattributes` up to date when adding a new binary file type. Lua scripts and JSON data are plain text files and live in standard Git. ## Script locations We use three distinct locations for scripts. Please 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. We mirror the structure modders will use (like `scripts/blocks/`, `scripts/items/`, `scripts/entities/`) so it serves as a working reference. - **`/mods/`** (top-level): in-repo example mods or test fixtures. We keep these out of `/assets/` because they aren't engine-shipped content, and out of `crates/` because they aren't Rust source code. - **`/mods/`**: player-installed mods, loaded only at runtime. This path is resolved via the `directories` or `dirs` crate (on Linux, it's `~/.local/share/synvael/mods/`, with platform equivalents elsewhere). Never read from a hard-coded path. ## Data packs & resource packs These are two distinct, orthogonal systems. Keep them separate, and don't merge them into one "pack" concept. **Resource packs** are client-side asset overlays covering textures, sounds, models, fonts, and language files. They contain no logic. **Data packs** are declarative content definitions (using JSON, TOML, or RON) covering blocks, items, recipes, loot tables, biomes, and tags. Our strict rule here: **do not build a parallel registration system.** The data-pack loader reads declarative files and calls the exact same Lua API that the engine and Lua mods use, ensuring one single source of truth (e.g. `data/blocks/stone.json` is read by the loader, which calls `blocks.register{ ... }`). Each schema is a stable contract that we version deliberately. This decision is recorded in [ADR-0007](docs/adr/0007-declarative-content-via-modding-api.md). Full subsystem details regarding load order, repo and user-data layouts, and resolution semantics can be found in [`docs/packs.md`](docs/packs.md). ## Concurrency model The game is **multithreaded by design**. A single-threaded approach simply wouldn't meet our performance budget for running voxel meshing, worldgen, rendering, networking, and simulation all at once. Assume multiple threads when writing code and design data ownership accordingly: - Prefer message-passing using channels (`crossbeam-channel`, `flume`, or `std::sync::mpsc`) and per-thread ownership rather than shared mutable state. - When sharing is completely unavoidable, use the right primitive for your access pattern. Use `Arc>` for low-contention shared state, `Arc>` for read-heavy state, atomics like `AtomicU32` or `AtomicBool` for counters and flags, and lock-free structures from `crossbeam` or `dashmap` for hot paths. Try to avoid wrapping large hot data in a single `Mutex` "just in case", as this can easily accidentally serialize the entire engine. - Worldgen and chunk meshing are massive parallelism wins. We expect a thread pool like `rayon` or a hand-rolled one to feed meshing and generation jobs. - Vulkan command-buffer recording can also be parallelized, but Vulkan **queues** are not free-threaded. Only one thread can submit to a given queue at a time, so plan ownership of `vk::Queue` accordingly. - The Lua VMs (one per execution context for client and server) are **not** thread-safe in `mlua`'s default configuration. Treat each VM as owned by a single thread, and dispatch work to and from it using channels. ## Logging & error handling - **Logging:** We use [`tracing`](https://docs.rs/tracing/) with `tracing-subscriber` as the output backend. Use `info!`, `warn!`, `error!`, `debug!`, and `trace!` macros at appropriate levels. It's crucial to use **spans** (`#[tracing::instrument]`, `info_span!`) to scope work, as this is how we keep multithreaded log output readable. Avoid using `println!` or `eprintln!` for diagnostics. If it's worth printing, it's worth a proper `tracing` event. - **Errors in libraries** (`shared`, `renderer`, `scripting`): Use typed error enums via [`thiserror`](https://docs.rs/thiserror/) using `#[derive(Error)]`. Each variant should be a distinct, matchable failure mode. Do not expose `anyhow::Error` from a library API. - **Errors in binaries** (`client`, `server`): Use [`anyhow`](https://docs.rs/anyhow/) at the top level, leaning on `.context("...")` to provide human-readable layers. Library errors compose smoothly into `anyhow::Error` using the `?` operator. - **Never use `.unwrap()` or `.expect()` outside of `main`, setup logic, or tests.** The only exception is when an invariant is genuinely impossible to violate. On hot paths, propagate errors with `?` and let the caller decide what to do. ## Testing policy We prioritize tests based on risk, not raw coverage percentages. We direct our testing effort toward areas where code that compiles and appears correct isn't guaranteed to actually be correct. You must write accompanying unit tests for these categories in the same change that introduces or modifies the logic: - **Pure algorithmic logic.** Things with values in, values out, no I/O, no GPU, and no windowing. This includes coordinate and index math, packing and unpacking, meshing math, and similar self-contained computations. These are cheap to test and their edges are notoriously easy to get subtly wrong. - **Correctness traps.** Behaviors where a totally plausible implementation is silently wrong on an edge case. Examples include sign handling, off-by-one errors, integer overflow or truncation, and bit-packing boundaries. As a classic example, world-to-chunk conversion needs to floor via `div_euclid` rather than truncating via `/`. A test on negative inputs locks in that contract and prevents someone from accidentally regressing to `/`. - **Load-bearing invariants (especially determinism).** As noted in our determinism stance below, worldgen is seed-deterministic and bit-for-bit reproducible. That contract can't be verified just by looking at the code, so it is strictly guarded by tests (for example, generating a chunk twice from one seed and asserting they are equal). We guard determinism aggressively. Subsystems that are bound by I/O or hardware (like the `renderer` and Vulkan GPU paths, `client` windowing and input, and top-level binary wiring) are validated through integration tests and manual visual verification rather than strict unit tests. Their behavior relies on a live device, window, or process rather than pure logic. While the mechanism differs, the expectation that they are properly verified does not. Unit tests live right next to the code as `#[cfg(test)] mod tests` and are run using `cargo test -p `. ## Lint suppressions The workspace opts into a strict set of lints. This includes Clippy's `pedantic` group along with restriction lints that ban `unwrap`, `expect`, and `print` outside permitted contexts (you can check `[workspace.lints]` in the root `Cargo.toml`). Suppressions are expected at specific sites and are governed by these rules: - **Always prefer `#[expect(...)]` over `#[allow(...)]`** for a localized suppression. An `#[expect]` turns into a warning (`unfulfilled_lint_expectations`) if the lint it targets no longer fires, meaning obsolete suppressions surface automatically and can be cleaned up instead of lingering silently. `#[allow]` never self-reports and just accumulates as dead noise. - **Suppress narrowly.** Name the exact lint or lints, and attach the attribute to the absolute smallest scope that covers the site (like a statement, expression, or item). Never use a broad crate-level `#![allow]`. The only exception is a deliberate crate-wide policy, such as `#![allow(unsafe_code)]` in the `renderer`, where the suppression represents an architectural intent rather than a local waiver. - **Justify non-obvious suppressions.** If the reason a lint is safe to suppress isn't totally obvious from the surrounding code, leave a brief comment above the attribute explaining why (for instance, noting that a specific cast is mathematically provably in range). - **Never suppress `correctness`-tier lints.** These 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") and second-person ("you", "your") pronouns. - **Voice:** Try to use the passive voice or neutral descriptive language. Instead of "We initialize the buffer," try "The buffer is initialized." Instead of "Your vertex shader needs this," write "The vertex shader requires this." - **Focus:** Describe the code's behavior, the system's state, or technical invariants. - **Struct Documentation:** Every single field in a public or internal struct needs a doc comment (`///`) explaining what it's for and any invariants it holds. - **Function documentation sections:** Function doc comments should follow the standard sections from the [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/documentation.html). They should appear in this fixed order after the summary and description: `# Errors`, then `# Panics`, then `# Safety`. These sections apply to **all** functions, whether public or private. Clippy only enforces public ones, but we expect the same standard on private helpers by hand. - **`# Errors`** is mandatory on every function returning a `Result`. It needs to state the exact conditions under which each error variant is returned. `fn main` is exempt from this. - **`# Panics`** is mandatory on any function that can panic. This includes `expect`, `unwrap`, `panic!`, `assert!`, array indexing, or arithmetic that can trip. State the condition that triggers the panic. - **`# Safety`** is mandatory on every `unsafe fn`, stating the exact invariants the caller must uphold. - Test functions (`#[test]`, and helpers inside `#[cfg(test)]`) are fully exempt from all three sections since they aren't part of the documented surface. - We use `missing_errors_doc`, `missing_panics_doc`, and `missing_safety_doc` as warnings in our workspace lint set, so missing a section on a public item will fail CI. - **Stability:** Treat the documentation as a technical specification for the engine. - **Line breaks:** Do not insert line returns inside a comment unless it's genuinely necessary. If a comment fits on a single line, leave it on a single line. Don't artificially wrap text at 80 characters just for aesthetics. Only break across lines when the comment is actually long (like multi-sentence prose or enumerated invariants) or when a hard break carries meaning (like separating an intro line from a bulleted list). ## Target platforms **Linux and Windows only.** We do not support macOS, mobile, consoles, or web/WASM. - Both platforms feature native Vulkan support via vendor ICDs (NVIDIA, AMD, Intel). There is no translation layer like MoltenVK, meaning we can adopt modern Vulkan extensions freely without checking a portability matrix. - **File paths:** Always use `std::path::Path` or `PathBuf` along with the `directories` (or `dirs`) crate for looking up user data. Never hard-code paths like `/home/...` or `~`. Linux properly follows XDG standards (`$XDG_DATA_HOME`, etc.), while Windows correctly uses `%APPDATA%`. - **Line endings:** The repository is strictly LF-only. Make sure to set `core.autocrlf = false` and rely on our `.gitattributes` setting `* text eol=lf` to keep diffs completely clean across both operating systems. - **Filename casing:** Never create two files that differ only in casing. Linux is case-sensitive and Windows isn't, so mismatches create incredibly confusing "works on my machine" bugs. ## Determinism stance - **Worldgen is seed-deterministic.** Given the exact same seed, worldgen must produce bit-for-bit the same world on any platform, at any time. This strongly constrains our worldgen code: you must use a fixed RNG algorithm like `wyrand` or `xoshiro`. **Never** use `rand::thread_rng()` or anything seeded directly from the OS. Do not depend on `HashMap` iteration order, as Rust's default hasher is randomized. Use `BTreeMap`, `IndexMap`, or explicitly sort your data when iteration order feeds into RNG draws or content placement. For more detail, check [ADR-0003](docs/adr/0003-seed-deterministic-worldgen.md). - **Simulation is server-authoritative.** The server runs the absolute truth. Clients send their inputs and receive state snapshots back, predicting locally for responsiveness and reconciling whenever they disagree with the server. Combat, physics, mob AI, and item drops are computed exactly once, on the server. - **Full simulation determinism (lockstep, rollback, replay-from-inputs) is a non-goal.** Because of this, floats, hash-map iteration, and platform-specific math are all totally fair game *outside of worldgen*. We don't want to pay the massive performance cost of cross-platform float reproducibility for a feature we aren't even building. See [ADR-0004](docs/adr/0004-server-authoritative-simulation.md). ## Content IDs & namespacing All registered content (like blocks, items, recipes, biomes, and entities) is identified using a **namespaced string** in the exact format `"namespace:id"`. The full rationale for this is in [ADR-0005](docs/adr/0005-namespaced-content-ids.md). - **Engine's reserved namespace:** `core:`. All first-party content registered directly by the base game uses this namespace (e.g. `"core:stone"`, `"core:iron_sword"`). Mods pick their own short namespace (e.g. `"mymod:weird_dirt"`). - **Strict form required.** A bare ID with no `:` is considered an **error at registration and parse time**. It will not be silently coerced to `core:`. This same rule applies absolutely everywhere: engine scripts, data packs, Lua mods, recipe references, and save files. There are no exceptions. The symmetry is entirely the point. - **Charset:** The namespace and id must each match `[a-z0-9_-]+`, separated by exactly one `:`. Stick to lowercase ASCII only. No uppercase letters, no Unicode, no spaces, no dots, and no slashes. This keeps IDs easy to grep, completely filesystem-safe, and unambiguous in logs and save data. - **Runtime representation:** We intern each ID string into a small integer handle (like `BlockId(u32)`) when it gets registered. Hot paths should always compare handles, never strings. We keep the original string around purely for display, saving and loading, and the Lua API surface. > *Project name note:* The project is named **Synvael** ("Catalyst" was our old working codename). The engine namespace is deliberately kept as `core:` rather than the project name, ensuring it stays stable even if branding changes. ## Coordinate system & units - **Up axis:** **+Y**. - **Handedness:** **right-handed** (this is the default math convention where +X is right, +Y is up, and +Z points toward the viewer or out of the screen). - **World unit:** **1 unit = 1 block.** Blocks are exactly 0.5 meters in physical scale, but inside the engine, everything is counted in *blocks*, not meters. A player is therefore exactly 3 units tall and 2 units wide in world coordinates. We've collected implementation gotchas that pop up because neighboring tools use different conventions (like Vulkan clip space, Blender import, or glTF) in [`docs/rendering.md`](docs/rendering.md). Note that these are not convention changes for the engine, just mismatches that we handle in one agreed-upon place. ## Development Setup ### Prerequisites - [Rust](https://www.rust-lang.org/tools/install) (stable toolchain, edition 2024) - [Git LFS](https://git-lfs.com/) (binary assets are tracked via LFS) - A Vulkan-capable GPU with up-to-date drivers (Linux or Windows) ### Building ```bash git clone https://github.com/Cryoforge-Nexus/Synvael.git cd Synvael git lfs pull cargo build ``` ### Running ```bash cargo run -p client # windowed client cargo run -p server # dedicated server ``` ### Testing ```bash cargo test # all tests cargo test -p shared # tests for a single crate ``` ### Linting The CI pipeline enforces strict linting. Run these locally before pushing your code: ```bash cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings selene . stylua . ``` Lua linting requires [Selene](https://kampfkarren.github.io/selene/) and [StyLua](https://github.com/JohnnyMorganz/StyLua). You can install them by running `cargo install selene` and `cargo install stylua`, or by using the pre-built binaries from their GitHub release pages.