# Station A persistent-world, RPG-style station simulation inspired by Space Station 13, built with [Bevy](https://bevy.org) in Rust. - **ECS** – all game state lives in Bevy entities and components. - **Data driven** – items, recipes, buildings and skills are defined in [rsn](https://crates.io/crates/rsn) files under `assets/data/`. - **Scriptable** – behaviour is written in [Rune](https://rune-rs.github.io) scripts under `assets/scripts/`. - **Tiled maps** – maps are regular [Tiled](https://www.mapeditor.org) `.tmx` files. - **Persistent world** – no rounds. The world and every character (inventory, skills, vitals, position) are saved and restored across restarts. - **Singleplayer and multiplayer** – one authoritative server simulation, used locally in singleplayer and over UDP in multiplayer ([bevy_replicon](https://crates.io/crates/bevy_replicon) + renet). ## Crates | Crate | Purpose | |----------------------|-------------------------------------------------------------------------| | `crates/game_core` | Shared data definitions, components, Tiled loader, network protocol | | `crates/game_server` | Authoritative simulation, Rune scripting, persistence, `game-server` binary | | `crates/game_client` | Rendering, UI and input (`game` binary); embeds the server for singleplayer/host | The client never changes game state directly. It sends requests (`Login`, `SetMoveDir`, `Interact`, `Craft`, …) and the server replicates the resulting state back. In singleplayer the same server systems run inside the client process, so singleplayer and multiplayer behave identically. ## Running ```sh cargo run # singleplayer (world: saves/singleplayer.rsn) cargo run -- --host 5000 # singleplayer world that others can join cargo run -- --connect 127.0.0.1:5000 # join a server or host cargo run -p game_server --bin game-server # dedicated server (world: saves/server.rsn) ``` Client options: `--name NAME`, `--save FILE`, `--no-save`, `--map MAP`. Server options: `--port PORT`, `--save FILE`, `--no-save`, `--map MAP`. On Windows, release builds of the client open no console window, so command-line output such as `--help` and logs isn't shown. Use a debug build to see it. The world autosaves periodically and on exit (window close, Ctrl-C, SIGTERM). Characters are identified by name and are restored when that name logs in again. ## Controls | Key | Action | |---------------------|------------------------------------------------------| | WASD / arrows | Move (you face the direction you move) | | E | Pick up (own tile, otherwise the faced tile) | | F / left click | Interact with the faced / clicked building | | U | Use the selected item (eat, heal, …) | | G | Drop the selected item | | X | Deconstruct the faced building | | 1–9, 0, mouse wheel | Select an inventory slot | | C / B | Crafting / build menu (W/S select, Enter confirm, Esc close) | | H | Help | ## Modding All content is loaded from `assets/` at startup. Every `.rsn` file below `assets/data/` (recursively) may contain any mix of `items`, `recipes`, `buildings` and `skills`, so a feature can ship in a single file. References between prototypes and script hooks are validated at load time and errors name the offending file and id. ### Items ```rsn items: [ { id: "apple", name: "Apple", sprite: "sprites/items/apple.png", max_stack: 10, tags: ["food"], on_use: Some("food::eat"), consume_on_use: true, props: { "nutrition": 15 } }, ] ``` Required: `id`, `name`, `sprite`. Optional: `description`, `max_stack` (default 50), `tags`, `on_use` (script hook), `consume_on_use`, `props` (numbers readable by scripts). ### Recipes ```rsn recipes: [ { id: "smelt_iron", name: Some("Smelt iron plate"), inputs: [("iron_ore", 2)], outputs: [("iron_plate", 1)], tools: [], station: Some("furnace"), skill: Some({ skill: "smithing", level: 1 }), xp: 10 }, ] ``` Required: `id`, `inputs`, `outputs`. Optional: `name`, `tools` (needed but not consumed), `station` (a tag provided by a building adjacent to the player), `skill`, `xp`, `on_craft` (script hook). Ingredients are written as `("item", count)` or `{ item: "item", count: n }`. ### Buildings Anything placed on the grid: walls, doors, machines, trees, rocks. ```rsn buildings: [ { id: "door", name: "Door", sprite: "sprites/buildings/door_closed.png", variants: { "open": "sprites/buildings/door_open.png" }, construction: Some({ materials: [("iron_plate", 2), ("plank", 1)], tools: ["screwdriver"], xp: 10, skill: Some({ skill: "construction", level: 1 }) }), on_interact: Some("door::toggle") }, ] ``` Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name → sprite), `solid` (default true), `max_health`, `stations` (tags for recipes), `construction` (makes it buildable from the build menu), `deconstructible` (default true; refunds materials), `on_interact` (script hook), `vars` (integer state, persisted per building). ### Skills ```rsn skills: [ { id: "smithing", name: "Smithing" } ] ``` ### Scripts Each `.rn` file in `assets/scripts/` is a module named after its path (`scripts/door.rn` → `door`, `scripts/npc/guard.rn` → `npc::guard`). Data files refer to public functions as `"module::function"`. Every hook receives one argument, `ctx`; returning `false` signals failure (for example, a used item is then not consumed). Script errors are logged and have no effect on the world. ```rune // assets/scripts/door.rn pub fn toggle(ctx) { if ctx.is_solid() { ctx.set_solid(false); ctx.set_variant("open"); ctx.message("You open the door."); } else { ctx.set_solid(true); ctx.set_variant(""); ctx.message("You close the door."); } } ``` `ctx` methods: | Area | Methods | |-----------|---------| | Actor | `actor_name()`, `message(text)` | | Inventory | `item_count(id)`, `has_item(id, n)`, `give_item(id, n)`, `take_item(id, n) -> bool`, `spawn_item(id, n)` (on the ground) | | Vitals | `health()`, `max_health()`, `heal(n)`, `damage(n)`, `nutrition()`, `feed(n)` (integers) | | Skills | `skill_level(id)`, `add_xp(id, n)` | | Used item | `item_id()`, `item_prop(key)` | | Building | `target_name()`, `get_var(key)`, `set_var(key, n)`, `is_solid()`, `set_solid(b)`, `variant()`, `set_variant(name)` (`""` = default sprite), `destroy_target()` | | Misc | `random(max)` → `0..max` | ### Maps (Tiled) Maps are orthogonal `.tmx` files with embedded or external tilesets. Each map cell is one game tile (drawn at 32×32; 32 px tilesets look best). - Tile layers are rendered in order. A tile is solid if the tile has the boolean custom property `solid = true` in its tileset, or its layer has `solid = true`. - Object layers spawn gameplay entities by object **class**: - `spawn` – player spawn point. - `building` – custom property `proto` = building id; any integer properties override the building's `vars`. - `item` – custom property `proto` = item id and optional `count`. - Objects are placed on the tile containing their position (point objects work well). A map is only used to create a new world; after that the save file is authoritative. Delete the save file to start a fresh world. `tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map. ## Development ```sh cargo nextest run --workspace # unit tests + headless full-simulation test cargo clippy --workspace --all-targets ``` Tests run with [cargo-nextest](https://nexte.st) (`cargo install cargo-nextest --locked`); its configuration is in `.config/nextest.toml`. CI uses `--profile ci`, which writes a JUnit report to `target/nextest/ci/junit.xml`.