feat: add persistent-world station game
Bevy ECS workspace with three crates:
- game_core: rsn data definitions, components, Tiled map loader, protocol
- game_server: authoritative simulation, Rune scripting, persistence,
dedicated server binary
- game_client: rendering, UI and input; embeds the server for
singleplayer and hosting
Items, recipes, buildings and skills are defined in assets/data, behavior
in Rune scripts under assets/scripts, and maps are made in Tiled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 17:44:13 +00:00
|
|
|
|
# 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`.
|
2026-10-18 18:08:45 +00:00
|
|
|
|
|
|
|
|
|
|
### Packaging
|
|
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
|
cargo xtask pack # for the host platform
|
|
|
|
|
|
cargo xtask pack --target x86_64-pc-windows-gnu # cross-compile for Windows (needs MinGW)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`pack` builds the client (`game`) and the dedicated server (`game-server`) with the
|
|
|
|
|
|
stripped `dist` profile and writes `dist/station-<version>-<target>.zip` (Windows) or
|
|
|
|
|
|
`.tar.gz` (other platforms). The archive contains both binaries, `assets/`, the README
|
|
|
|
|
|
and the changelog. Unpack it and run the binaries from that directory. The unpacked
|
|
|
|
|
|
staging directory is kept next to the archive. Options: `--format zip|tar.gz`,
|
|
|
|
|
|
`--out-dir DIR`, `--debug`.
|
2026-10-18 18:21:07 +00:00
|
|
|
|
|
|
|
|
|
|
The GitHub `Package` workflow (`.github/workflows/package.yml`) runs `pack` for Windows
|
|
|
|
|
|
(zip), Linux and macOS (tar.gz), uploads each archive as an artifact and attaches them
|
|
|
|
|
|
to the release when a `v*` tag is pushed. The Gitea workflow cross-compiles the Windows
|
|
|
|
|
|
package with MinGW.
|