synvael/DEVELOPMENT.md

22 KiB

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/ 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/<subsystem>.md note.
  • 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 for the full structure and 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 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.

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.
  • <user-data-dir>/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.

Full subsystem details regarding load order, repo and user-data layouts, and resolution semantics can be found in 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<Mutex<_>> for low-contention shared state, Arc<RwLock<_>> 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 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 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 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 <crate>.

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. 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.
  • 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.

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.

  • 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. Note that these are not convention changes for the engine, just mismatches that we handle in one agreed-upon place.

Development Setup

Prerequisites

  • Rust (stable toolchain, edition 2024)
  • Git LFS (binary assets are tracked via LFS)
  • A Vulkan-capable GPU with up-to-date drivers (Linux or Windows)

Building

git clone https://github.com/Cryoforge-Nexus/Synvael.git
cd Synvael
git lfs pull
cargo build

Running

cargo run -p client    # windowed client
cargo run -p server    # dedicated server

Testing

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:

cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
selene .
stylua .

Lua linting requires Selene and 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.