# 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, machines 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. - **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](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 | See [SS13 feature gaps](docs/ss13-feature-gaps.md) 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 ```sh 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: ```sh 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](https://crates.io/crates/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. ```sh 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: ```sh 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: ```text 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 [DIR]` | admins | Export terrain, buildings and ground items as a Tiled project | | `import [DIR\|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: ```rsn 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 ```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: "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: floors, 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), `layer` (`"structure"` by default, or `"floor"`), `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). Each cell can hold one floor and one structure, plus ground items. Floors render below structures and have the same health, script state and construction mechanics. Interactions and deconstruction target the structure first, then the floor once the structure is gone. Remove a structure before replacing the floor underneath it. ### 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. ```rsn 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 ```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 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). Layer/group offsets are not supported. - Unmarked tile layers are static terrain/decorations, 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`. - A tileset tile with a string `proto` property becomes a gameplay building when painted. Its `state.*`, `machine.*` and integer script-variable properties become instance overrides. The painted cell is cleared from static terrain, so removing the entity cannot leave an invisible wall or a duplicate floor behind. The shipped floor, dark floor, plating, wall and window tiles use this convention. - 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. `state.layer` must match the prototype (`"floor"` or `"structure"`, as JSON strings). `state.flip_h`, `state.flip_v` and `state.flip_d` preserve tile flips. 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`. - `teleport` – a trigger that moves characters entering its tile to the object named by the string property `target`. The optional **file** property `map` names the target's map, relative to this map; without it the target is on the same map. Arrival does not trigger the destination's own teleport; step off and back on to use it. The arrival tile falls back to the nearest free tile. - Objects are placed on the tile containing their position (point objects work well). Tile objects inherit gameplay properties from their tiles; object properties override them. Painted and explicit buildings share the same occupancy checks. Set the map's **integer** `default_tile` property to a static tile's local ID (as shown in Tiled's tileset editor). The optional integer `default_tileset` selects its tileset by zero-based position in the map's tileset list (default 0). The default tile fills empty cells below gameplay floors; removing a floor exposes it wherever no explicit static terrain is present. Its `solid` property controls movement there, while a non-solid gameplay floor covers that default collision. Explicit static terrain collision still applies. The default cannot reference a tile with `proto`, and cannot be built/deconstructed itself. The starter map uses dirt (tile 6). Maps remain finite: outside their bounds is always blocked. Old maps without a default retain their empty, passable background. #### Levels The server's map (`station.tmx` by default) is the main level. Every map reached through `teleport` triggers, directly or via other levels, is loaded as another level, up to 64 per world. A level is identified by its path relative to the main map's directory and must stay inside that directory. All levels are simulated all the time, whether or not anyone is on them, so machines keep working and scripts keep running. Clients only receive the level their character is on. Characters, buildings and ground items remember their level in the save. Teleports are validated when a world loads or imports: unknown maps or missing targets are errors. The default map has stairs in the main hall leading to `cellar.tmx` and back up. A default map seeds a new world; after that the database stores the authoritative terrain and entity state of every level. Existing saves acquire their terrain snapshot on their next save. Levels referenced since a world was saved (for example the cellar, for worlds created before it existed) are seeded from their maps. To apply a map to an existing world, use the admin `import` command. Existing saved terrain is not automatically converted or reseeded on startup: import a marked map to convert old static walls/floors. Legacy saves without a terrain snapshot retain static seed terrain until explicitly imported. #### Converting painted tiles inside Tiled Open the bundled `assets/station.tiled-project` in Tiled 1.10 or newer. It loads `assets/tiled/station.js` as a project extension; accept Tiled's **Enable Extensions** prompt the first time. No copying or global plugin installation is needed. The project and extension are also included in packaged downloads. For use without the project, open **Edit > Preferences > Plugins > Open**, copy the contents of `assets/tiled/` into that extensions directory, then enable/reload it. To draw walls or floors with independent properties: 1. Select a single gameplay tile in the **Tilesets** panel. 2. Choose **Map > Station: Brush Properties** (also available in the Station brush toolbar). Set health, solidity, script variables or other tile-defined state. JSON-valued fields such as `state.vars` and `state.variant` keep their JSON syntax. Prototype and gameplay layer are fixed by the selected tile. 3. Select **Station Object Brush** in the tools toolbar and click or drag to draw. Each stroke places visual `building` tile objects, snapping to the grid, with a separate copy of the brush properties on every object. A stroke is one undo step; Escape cancels it before placement. 4. Use **Select Objects** to edit individual placed objects in the Properties panel. Floors go into the initially hidden **Floors** layer; enable its eye icon to inspect them. Brush settings are remembered per tile until extensions reload or Tiled closes. Changing them affects only future strokes, not tileset defaults or existing objects. Floor/structure overlap is allowed, but a stroke cannot overwrite an existing building in the same gameplay layer, overlap unconverted gameplay tiles, or write to a locked destination. Edit/delete the existing object or convert the painted tiles first. The normal Tiled Stamp Brush still paints shared tileset defaults, not per-cell properties. Rejected strokes leave the map unchanged and report the reason in the status bar and **Console** view. Paint the marked tiles normally, then choose **Map > Station: Convert Painted Tiles to Objects**. The extension replaces all marked painted cells with selected `building` point objects in separate **Floors** and **Objects** layers. Existing layers with those names are reused. The converter also creates **Wiring/Plumbing**, **Ground Items** and **Triggers** layers when missing. Select an object to edit its health, solidity or other custom properties in the Properties panel. New layers use the same order and visibility defaults as world exports below; existing layers retain their visibility settings. Unmarked terrain stays painted. Floor/structure overlaps are allowed; same-layer overlaps abort the entire conversion. Conversion is a single undoable operation, and running it again does not duplicate objects. To convert every map at once, choose **Map > Station: Convert All Project Maps**. It opens each `.tmx` file in the project's folders (including subfolders), converts it as above and reports how many maps and tiles changed, plus any maps that failed. Converted maps stay open and unsaved so you can review or undo them per map; use **File > Save All** to keep the result. Maps without painted gameplay tiles are closed again unless they were already open. For custom tiles, set `proto` to a building ID and add the desired `state.*` or `machine.*` defaults on the tileset tile. Floor tiles should include `state.layer` as the string `"floor"` (with JSON quotes) so the extension can check occupancy. The shipped gameplay tiles include editable state defaults. Exporting a world also fills in any missing tileset state defaults from the loaded game definitions. Omitted properties still use prototype defaults when loaded by the game. Conversion in Tiled and automatic conversion on seed/import use the same property and coordinate conventions; conversion is optional before loading a map. #### Editing a running world's map 1. Run `/export` in chat, or `export` in the server terminal. It writes a ready-to-use Tiled project directory and replies with the path of its `station.tiled-project` file (a server-local path). With SQLite the directory defaults to `..tiled/` beside the database. With PostgreSQL it defaults to `saves//`; without saving, `saves/world/`. An explicit directory, including spaces, may follow either command; relative paths are relative to the server's working directory. The directory contains: - `.tmx` (e.g. `station.tmx`, named after the server's map): the main level; - the other levels' maps at their level paths (e.g. `cellar.tmx`); - `station.tiled-project`: a Tiled project that loads the bundled extensions; - `extensions/`: a copy of `assets/tiled/` (refreshed on every export); - `tilesets/`: copies of the tileset images, referenced relatively, so the directory can be copied to another machine and opened there; - `README.txt`: short usage instructions. Re-exporting into the same directory overwrites the map, extensions, images and README but keeps an existing project file, so project settings survive. 2. Open `station.tiled-project` in Tiled 1.10.1 or newer (**Project > Open Project**), click **Enable Extensions** when asked, then open the main map (or any level) from the Project panel. Edit 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. The tileset image copies are for display only: on import they map back to the server's identical asset paths, and editing them does not change the game. Objects are organized into **Floors**, **Objects** (walls, machines and other structures), **Ground Items**, and **Triggers** (spawn points and teleports). **Wiring/Plumbing** is an empty organizational placeholder; it does not add wiring, plumbing or trigger mechanics. From top to bottom, Tiled shows **Objects**, **Ground Items**, **Triggers**, **Wiring/Plumbing**, **Floors**, then the painted terrain layers (for the default map, **Walls** above **Ground**). **Floors** and **Wiring/Plumbing** are hidden by default to reduce editor clutter; hidden layers still load into the game. Hide or lock individual layers in Tiled to edit one category without the others getting in the way. Object classes and properties determine gameplay, not the editor layer names. 3. Copy the directory back to the server if you edited it elsewhere, then run `/import` (or `/import DIR`, or `/import FILE` naming the main `.tmx` file; levels are read relative to it). **Import replaces all levels, terrain, buildings and ground items, including deletions.** Add a level by creating its map and pointing a teleport at it; levels no longer reached by any teleport are removed, and characters on them move to the main spawn point. Otherwise 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 ```sh cargo nextest run --workspace # unit tests + headless full-simulation test node --test tools/tiled.test.cjs # Tiled extension tests (Node.js 22+) 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`. ### 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--.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`. Run `cargo xtask watch` to rebuild and pack automatically after source changes. It accepts the same options as `pack`, for example `cargo xtask watch --debug`. The watcher waits for 10 seconds without further changes; each change resets that timer. It watches Git-tracked and non-ignored untracked files, including assets, manifests and packaging code, and ignores `dist/`, `target/`, Git metadata and the selected output directory. Git must be installed. No build runs on startup. Add `--serve` to browse and download the output files over HTTP: `cargo xtask watch --serve` serves `dist/` at . With `--out-dir DIR`, it serves that directory instead. The server is local-only, provides directory listings and file downloads, and remains available during builds and while rebuilds are paused. Stop the watcher to stop serving. Create `dist/NOREBUILD` to pause automatic builds. Changes remain queued while the file exists; deleting it starts a fresh 10-second wait for pending changes. An already running build finishes, and subsequent builds never overlap. Build failures are reported without stopping the watcher; a later source change retries. Press Ctrl-C to stop. Run this from the source checkout, not a packaged archive. 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.