Go to file
Steve Biedermann fadd159de6 docs: document SS13 feature gaps and development priorities
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 20:57:50 +02:00
.cargo build: add cargo xtask pack for distributable archives 2026-10-18 20:08:45 +02:00
.config test: run tests with cargo-nextest in CI 2026-10-18 19:44:13 +02:00
.gitea/workflows ci: package Windows builds with cargo xtask pack 2026-10-18 20:08:45 +02:00
.github/workflows ci(github): package client and server for Windows, Linux and macOS 2026-10-18 20:21:07 +02:00
assets feat!: start menu, file-based UI, machine windows and server access control 2026-10-18 22:29:05 +02:00
crates feat(chat): add native text editing, history and command completion 2026-10-21 20:22:32 +02:00
docs docs: document SS13 feature gaps and development priorities 2026-10-21 20:57:50 +02:00
tools feat: add data-driven machines 2026-10-18 20:28:16 +02:00
xtask build: add cargo xtask pack for distributable archives 2026-10-18 20:08:45 +02:00
.gitignore build: add cargo xtask pack for distributable archives 2026-10-18 20:08:45 +02:00
AGENTS.md feat!: start menu, file-based UI, machine windows and server access control 2026-10-18 22:29:05 +02:00
CHANGELOG.md feat(chat): add native text editing, history and command completion 2026-10-21 20:22:32 +02:00
Cargo.lock feat(world)!: add lossless Tiled export and import 2026-10-21 20:22:13 +02:00
Cargo.toml feat(server): add file and command line config 2026-10-18 21:41:58 +02:00
README.md docs: document SS13 feature gaps and development priorities 2026-10-21 20:57:50 +02:00

README.md

Station

A persistent-world, RPG-style station simulation inspired by Space Station 13, built with Bevy in Rust.

  • ECS – all game state lives in Bevy entities and components.
  • Data driven – items, recipes, buildings, machines and skills are defined in rsn files under assets/data/.
  • Scriptable – behaviour is written in Rune scripts under assets/scripts/.
  • Tiled maps – maps are regular Tiled .tmx files.
  • Persistent world – no rounds. The world and every character (inventory, skills, vitals, position) are saved and restored across restarts.
  • Free movement – characters walk anywhere, not tile by tile, and slide along walls. Interactions still target the tile you face.
  • Singleplayer and multiplayer – one authoritative server simulation, used locally in singleplayer and over UDP in multiplayer (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

See SS13 feature gaps for an inventory of major systems and smaller features that are not yet implemented or are only partial.

The client never changes game state directly. It sends requests (Login, SetMovement, 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

cargo run                                   # start menu
cargo run -p game_server --bin game-server  # dedicated server (saves/server.sqlite, world `main`)

The start menu offers New game (optionally open to other players), Load game (play or delete a save) and Join server. Command-line options skip the menu:

cargo run -- --save mars                    # play (or create) the local world `mars`
cargo run -- --save mars --host 5000 --password secret  # let others join it
cargo run -- --connect 127.0.0.1:5000 --password secret # join a server or host

Client options: --save NAME, --no-save, --host [PORT], --connect ADDR[:PORT], --name NAME, --password TEXT, --map MAP. Server options: --config FILE, --port PORT, --password TEXT, --max-players N, --database URL, --world NAME|UUID, --no-save, --map MAP, --autosave SECS, --motd TEXT, --admin ID, --whitelist.

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 (in the background) and on exit (window close, Ctrl-C, SIGTERM).

The client keeps a profile in client.toml in the platform config directory (~/.config/station/ on Linux, %APPDATA%\station\config\ on Windows, ~/Library/Application Support/station/ on macOS). It holds a random player id, the last character name and the last server address. Characters belong to the player id: logging in again restores your character, and its name is fixed once it exists. Hosting a world uses the server settings in host.toml in the same directory, except that the port and password come from the menu or the command line.

Save games and databases

Worlds are stored with sqlx in SQLite or PostgreSQL. Every world has a UUID, and all rows are keyed by it, so any number of worlds can share one database.

  • Singleplayer/host: each save game is its own SQLite file in the platform data directory: ~/.local/share/station/saves/ on Linux, %APPDATA%\station\data\saves\ on Windows and ~/Library/Application Support/station/saves/ on macOS.
  • Dedicated server: --database takes a sqlx URL and defaults to sqlite://saves/server.sqlite. Use postgres://user:password@host/db for PostgreSQL. --world selects a world by name (created if missing) or by UUID.
game-server --database postgres://station:secret@db/station --world europa

The schema is created and migrated automatically. To run the PostgreSQL tests, set STATION_TEST_POSTGRES_URL to a database URL; without it they are skipped. The native chat clipboard test can be enabled with STATION_TEST_CLIPBOARD=1 when running tests in a desktop session with an available system clipboard.

Dedicated server configuration

The dedicated server reads server.toml by default and creates it on first start. Command-line options override the file for one run only:

game-server --config prod.toml --port 5001 --database sqlite://saves/prod.sqlite

The config file also stores the server password, MOTD, max players, the database URL and world, plus access lists (admins, whitelist, bans). While the server is running, type commands into its terminal:

help
players
save
config get database.world
config set autosave_secs 30
stop

Passwords, admins and bans

A server with a password only accepts players who send it. Admins are listed by player id or character name in access.admins (--admin ID adds one for a run). The player who hosts a world from the client is always admin and may stop it; in singleplayer you are always admin. With access.whitelist_enabled, only players in access.whitelist (and admins) may join. Banned ids and names in access.banned are rejected.

Commands are typed into the server terminal or, prefixed with /, into the in-game chat. Admin commands change server.toml (or host.toml) immediately.

Command Who Action
help, players everyone List commands / online players
say MESSAGE admins Message all players
kick PLAYER [REASON] admins Disconnect a player
ban PLAYER, unban PLAYER, bans admins Ban by name or id (kicks if online), lift or list bans
whitelist on|off|list admins Toggle or show the whitelist
whitelist add|remove PLAYER admins Edit the whitelist
admin add|remove PLAYER, admin list admins Grant, revoke or list admin rights
rejected admins Recently rejected logins (with player ids)
save admins Save the world now
export [FILE] admins Export terrain, buildings and ground items to Tiled
import [FILE] admins Replace terrain, buildings and ground items from Tiled; keep characters
config get KEY, config set KEY VALUE admins Read or change a setting, e.g. config set motd Hi
stop console, host Save and shut down

Security: the network transport is unencrypted and unauthenticated. The password is sent in plain text and player ids can be impersonated by anyone who knows them. Treat the password as a guard against casual visitors, not as real security, and don't reuse a valuable password.

Controls

Chat supports cursor navigation (arrows, Home/End, Ctrl+Left/Right), Shift selection, Ctrl+A, mouse positioning/drag selection, double-click word selection, and system clipboard copy/cut/paste (Ctrl+C/X/V). On macOS, use the usual Command shortcuts and Option for word navigation. Up/Down recall the last 100 sent messages and commands; Down past the newest entry restores the unsent draft. Tab completes command names and subcommands allowed for your role; repeated Tab cycles matches, and Shift+Tab cycles backwards. Escape closes chat without sending.

Key Action
WASD / arrows Move freely, also diagonally (you face that way)
E Pick up (own tile, otherwise the faced tile)
F / left click Interact with the faced / clicked building; machines open their window
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 (or click it)
C / B Crafting / build menu (W/S select, Enter confirm, or click)
H Help
Enter / T Chat; / starts a command
Esc Close the open window, otherwise pause menu (leave world, quit)

In a machine window, click an empty input slot to insert the selected inventory item and click a filled slot to take its items. Hover over items, recipes and buttons for tooltips.

User interface

The menus and the HUD are built from layout files in assets/ui/ and can be restyled or rearranged without recompiling:

File Screen
theme.rsn Font sizes, spacing and named colors used by all layouts
main_menu.rsn, new_game.rsn, load_game.rsn, connect.rsn, connecting.rsn Start menu screens
hud.rsn In-game HUD, menus, chat and pause overlay

A layout has one root node. Nodes:

Node Fields
Column {..}, Row {..}, Panel {..} children, gap, padding, align (Start, Center, End, Stretch), justify (Start, Center, End, SpaceBetween), width/height (Auto, Px(n), Percent(n), Fill), anchor (TopLeft … BottomRight, relative to the screen), offset: (x, y), background, border, radius
Text {..} text, size, color, max_width
Button {..} text, action, width
Input {..} id, value, placeholder, password, max_len, width, submit (action on Enter)
Checkbox {..} id, text, checked
Image {..} path (PNG below assets/), width, height
Spacer Takes up the remaining space
Widget("name") Content generated by the game

Every node except Spacer and Widget also accepts tooltip and visible. visible names a binding or form field and shows the node only while it is set; prefix it with ! to invert it. Colors are #rrggbb, #rrggbbaa or a theme color name. Texts, tooltips and input values may contain {binding} placeholders:

Panel {
    anchor: TopLeft,
    offset: (10, 10),
    children: [
        Text { text: "{character}", color: "accent", tooltip: "Playing on {server}" },
        Text { text: "You are hungry!", color: "error", visible: "hungry" },
        Button { text: "Craft", action: "menu:craft", tooltip: "Crafting (C)" },
    ],
}
  • Bindings: menu: message, profile_name, last_address, default_port, saves_dir, save_count, has_saves, suggested_world, version, status. HUD: character, server, admin, health, max_health, nutrition, hungry, position, craft_open, build_open, help_open, chat_open, paused.
  • Widgets: save_list (menu); inventory, skills, log, craft_list, build_list (HUD).
  • Actions: screen:main|new_game|load_game|connect, new_game, join, quit (menu); menu:craft|build|help|close, chat_send, resume, leave, quit (HUD). The menu forms read the inputs world, name, address, password, port and the checkbox host; the HUD chat input is chat.

Modding

All content is loaded from assets/ at startup. Every .rsn file below assets/data/ (recursively) may contain any mix of items, recipes, buildings, machines 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

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

recipes: [
    { id: "make_pickaxe", inputs: [("iron_plate", 2), ("plank", 1)],
      outputs: [("pickaxe", 1)], tools: ["wrench"], station: Some("workbench"),
      skill: Some({ skill: "smithing", level: 1 }), xp: 25 },
]

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.

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

Machines

A machine adds item slots and its own recipes to the building with the same id. Interacting with it opens the machine window: put items into the input slots, the machine processes the first recipe whose inputs are present and whose outputs fit, and the results appear in the output slots. Machine contents are saved with the world and dropped when the machine is taken apart.

machines: [
    { id: "assembler", input_slots: 3, output_slots: 1, active_variant: Some("active"),
      recipes: [
          { id: "assemble_wrench", inputs: [("iron_plate", 2)], outputs: [("wrench", 1)],
            time: 3.0 },
      ],
      ui: { background: Some("ui/machines/assembler.png"), size: Some((260.0, 150.0)),
            inputs: [(16.0, 14.0), (16.0, 96.0), (70.0, 55.0)], outputs: [(200.0, 55.0)],
            progress: Some((124.0, 69.0, 60.0, 12.0)) } },
]

Required: id (an existing building), recipes (id, inputs, outputs, optional name and time in seconds, default 1). Optional: input_slots and output_slots (default 1), active_variant (building variant shown while working) and ui. Without ui positions, inputs are stacked on the left and outputs on the right with a progress bar between them. ui fields: background (PNG drawn behind the slots), size (window content size in pixels), slot_size (default 40), inputs/outputs (one (x, y) per slot, measured from the top-left corner) and progress ((x, y, width, height)).

Skills

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.

// 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 finite orthogonal .tmx files (at most 1,000,000 cells) 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. state.health, state.solid, state.variant and state.vars override the prototype defaults. Integer custom properties without a state./machine. prefix also override individual script variables, for compatibility with existing maps. Machines accept machine.inputs, machine.outputs, machine.recipe and machine.progress.
    • item – custom property proto = item id and optional count.
  • Objects are placed on the tile containing their position (point objects work well).

A default map seeds a new world; after that the database stores the authoritative terrain and entity state. Existing saves acquire their terrain snapshot on their next save. To apply a map to an existing world, use the admin import command.

Editing a running world's map

  1. Run /export in chat, or export in the server terminal. The reply shows the server-local file path. With SQLite it defaults to <database-stem>.<world-uuid>.tmx beside the database. With PostgreSQL it defaults to saves/<world-uuid>/world.tmx; without saving, saves/world.tmx. An explicit path, including spaces, may follow either command; relative paths are relative to the server's working directory.
  2. Open that file in Tiled, edit it and save. Buildings and ground items are point objects with their prototype and full mutable state in custom properties. Terrain layers and collision properties are included. Tilesets are embedded with absolute image references into the server's assets directory; when editing on another machine, relink the images to that machine's matching assets.
  3. Run /import (or /import FILE). Import replaces all terrain, buildings and ground items, including deletions. It does not merge objects or change online or offline characters, their positions or inventories. Export a separate backup before importing, and avoid editing a stale export while players keep building.

Booleans and small integers use native Tiled property types. Other state values use JSON in string properties: for example state.variant is "open" (including the quotes) or null, state.vars is {"stock":2}, and machine.inputs is [{"item":"iron_ore","count":4},null]. machine.recipe is "smelt_iron" or null; machine.progress is elapsed seconds. Large integers use JSON strings to avoid Tiled's 32-bit integer limit. These same overrides work in default maps, not just exports; omitted properties retain prototype defaults.

Import validates the whole map before changing anything, and commits it to the database before applying it to the simulation. Invalid prototypes, properties, positions or machine state abort the import. Saving-disabled worlds can still import, but changes are only in memory. Terrain changes are replicated immediately to connected clients and retained across restarts; remote clients need matching sprite assets, not a copy of the exported map. Characters are left at their current positions, so avoid enclosing them in newly solid terrain.

tools/gen_placeholder_assets.py regenerates the placeholder sprites and example map.

Development

cargo nextest run --workspace   # unit tests + headless full-simulation test
cargo clippy --workspace --all-targets

Tests run with cargo-nextest (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.

Packaging

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.

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.