Updated the Verify-then-commit loop to mandate that function doc comments and important inline comments are present before any code is committed to the repository.
19 KiB
AGENTS.md
This file provides guidance to coding agents when working with code in this repository.
Project goal
Voxel-based game with souls-like combat. Built in Rust; rendering targets Vulkan via ash (raw Vulkan bindings, not a higher-level wrapper like wgpu or vulkano).
World is procedurally generated. Voxel edge length is half of Minecraft's (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 Minecraft-scale world per unit volume — pick chunk sizes and data layouts accordingly. 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).
Workspace layout
Cargo workspace (resolver = "3", edition 2024) with four crates under crates/:
client— binary. Windowed application usingwinit0.30 (ApplicationHandlerpattern,ControlFlow::Poll). Also pulls inimage. Player-facing app titled "Project Catalyst"; 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 viaash, decoupled from windowing so it can be driven byclient.shared— library. Types and protocol shared betweenclientandserver(world/voxel data, network messages, combat primitives). Stays lean and dep-light; nomlua, no rendering, no engine internals.scripting— library. Lua modding API and bindings (owns themluadependency,UserDatawrappers aroundsharedtypes, API table registration, mod loader). Bothclientandserverdepend 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 — mod authors will be reading it. Avoid leaking engine internals through it.
- The API and its bindings live in the
scriptingcrate. It owns themluadependency, the API table registration, and the mod loader. Bothclientandserverdepend on it;shareddoes not —sharedstays the lean protocol/data layer. - Authoritative APIs (world mutation, combat resolution) are defined in
scriptingbut 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
scriptingoverimpl UserData for SharedTypeinshared, to avoid coupling the protocol crate tomlua.
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.
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 ofcrates/because they're not Rust source.<user-data-dir>/mods/— player-installed mods, loaded at runtime only. Resolved via thedirectories/dirscrate (Linux:~/.local/share/project-catalyst/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 — client-side asset overlays. Textures, sounds, models, fonts, language files. No logic. A pack is a directory tree mirroring /assets/ that overrides files by path. The renderer/asset loader resolves logical asset IDs against a stack of pack roots (base game → installed packs by priority) and the topmost hit wins. Server has no involvement. Owned by the asset pipeline (in client, or a sibling assets crate if it grows). Pack authors never touch Lua.
Data packs — declarative content definitions in JSON (or TOML/RON, TBD): blocks, items, recipes, loot tables, biomes, tags. Do not build a parallel registration system — the loader reads the JSON and calls the same Lua API the engine and Lua mods use. One source of truth: data/blocks/stone.json → loader → blocks.register{ id = "stone", … }. Loader belongs in scripting (or a sibling crate if it grows). Engine first-party content may use either JSON or Lua, whichever fits.
Canonical load order (later layers override earlier ones):
base game (assets/scripts + assets/data)
→ data packs (declarative content add/override)
→ Lua mods (full API access)
→ resource packs (client-only, asset overlay — always last so visuals win)
Repo layout:
/assets/
data/ # base-game declarative content
blocks/ items/ recipes/ …
scripts/ # base-game Lua (behavior)
textures/ models/ sounds/ icons/ shaders/ # base-game assets
User-data layout (runtime, resolved via directories/dirs):
<user-data>/
mods/ # Lua mods
datapacks/ # JSON content packs
resourcepacks/ # asset overlays (client only)
Every data-pack schema you accept is a stable contract, same as the Lua API. Version it deliberately.
How the user wants to work with you
The user is learning most of the stack used here (Rust, Vulkan/ash, winit, mlua, voxel engines, networking, etc.) and wants you to act as a teacher, not as an autocomplete. This shapes every response:
- Do not write the code outright. Explain the concept, walk through the reasoning, point at what they'd need to write and why. Let them attempt it.
- Exception: if the user explicitly says they're stuck, struggling, or asks for the code directly ("just show me", "I give up", "write it"), then provide it — and explain it line by line afterwards.
- Explain thoroughly. Don't assume familiarity with crate APIs, idioms, GPU concepts, lifetimes, traits, etc. When you use a term that's non-obvious in context, define it briefly. Prefer "why" over "what" — they can read docs for "what".
- Link to resources. Point at official docs (rust-lang.org/Rustonomicon, Ash examples,
winitdocs, Khronos Vulkan spec,mluabook, vkguide.dev, "Learn Wgpu", etc.) when a topic deserves deeper reading than a chat reply can give. Prefer canonical sources over random blog posts. - Show worked examples and analogies when a concept is abstract (lifetimes, descriptor sets, command buffers, ECS, network reconciliation). A small concrete example beats a paragraph of theory.
- Ask before acting when a task could be done several ways — surface the trade-offs and let the user pick the path, rather than picking silently.
- Reviewing user-written code is fair game. When the user writes something and asks for feedback, point out issues, suggest improvements, and explain why — that's still teaching.
In short: optimise for the user's understanding growing over time, not for the fastest path to working code.
Verify-then-commit loop
When the user reports they've done what you asked, do not take their word for it. They are learning and may have misunderstood the task, edited the wrong file, or introduced an unrelated regression. Always verify against the actual repo state (read the files, run cargo check / cargo clippy / cargo test as appropriate, inspect git diff).
Then follow this loop on every step:
- Verify the user's claimed change is actually present and correct.
- If wrong or incomplete, explain what's off and let them fix it — do not silently patch it yourself.
- Once correct, ensure useful comments are added before committing. This includes function doc comments (
///) and inline comments above important parts of the logic. If they are missing, add them yourself and try to follow the style of the existing comments in the codebase. - After comments are verified, create a git commit capturing that step (following the commit conventions above) before moving on.
- Then tell the user what to do next.
Each verified step gets its own commit. This keeps history aligned with the teaching cadence: every commit corresponds to a concept the user has actually understood and produced working code for. Do not batch multiple verified steps into one commit, and do not move on to the next instruction without committing the previous one.
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, orstd::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 singleMutex"just in case" — that's 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::Queueaccordingly. - 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(withtracing-subscriberas the output backend). Useinfo!/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 forprintln!/eprintln!for diagnostics; if it's worth printing, it's worth atracingevent. - Errors in libraries (
shared,renderer,scripting): typed error enums viathiserror(#[derive(Error)]). Each variant is a distinct, matchable failure mode. Don't exposeanyhow::Errorfrom a library API. - Errors in binaries (
client,server):anyhowat the top level, with.context("...")for human-readable layering. Library errors compose intoanyhow::Errorcleanly via?. - Never
.unwrap()or.expect()outsidemain/ setup / tests, except where the invariant is genuinely impossible to violate. In the hot path, propagate with?and let the caller decide.
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/PathBufand thedirectories(ordirs) crate for user-data lookup. Never hard-code/home/...or~. Linux follows XDG ($XDG_DATA_HOMEetc.); Windows uses%APPDATA%. - Line endings: repo is LF-only. Set
core.autocrlf = falseand/or a.gitattributeswith* text eol=lfto 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) — neverrand::thread_rng()or anything seeded from the OS. Do not depend onHashMapiteration order (Rust's default hasher is randomised); useBTreeMap,IndexMap, or sort explicitly when iteration order feeds into RNG draws or content placement. - 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.
Content IDs & namespacing
All registered content (blocks, items, recipes, biomes, entities, …) is identified by a namespaced string of the form "namespace:id".
- 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 tocore:. 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 codename note: "Catalyst" is the codename of the project, not the namespace. The engine namespace is deliberately
core:so it stays stable if/when the project is renamed.
Coordinate system & units
- Up axis: +Y (Minecraft-style).
- 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 smaller than Minecraft's (0.5 m 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.
Things to be aware of when writing rendering or import code (these are not convention changes — just gotchas you'll hit because the rest of the world disagrees):
- Vulkan clip space is Y-down by default (and Z is
[0, 1], not[-1, 1]like OpenGL). The projection matrix has to flip Y, or you setviewport.heightnegative — both are common idioms inashexamples. World/view space stays Y-up; only clip space differs. - 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 sign). Decide once where that swap happens — at export, at import, or never (by adopting Blender's convention) — and stick to it. Doing it in two places will eventually produce a model that's mirrored or upside-down and you'll spend an afternoon on it.
- glTF is Y-up, right-handed — matches your engine convention, so it's the most friction-free model format if you have a choice.
Commit conventions
Conventional Commits 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 —
client,server,renderer,shared,scripting. For changes that genuinely span the whole workspace (e.g. workspace-level Cargo config, repo-wide.gitattributes), useworkspace. For changes confined to non-Rust assets, useassets. Avoid omitting the scope, and avoid inventing per-commit scopes. - Subject: imperative mood ("add", not "added" / "adds"), lowercase, no trailing period, ≤ ~72 chars.
- Body (optional): wrap at ~72 chars, explain why not what.
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
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
cargo fmt