Rust crate
The simulation engine of Verticopolis, a tower building game inspired by SimTower (1994). This crate is the whole simulation: every rule, price, build cap, command, save format and gameplay event. It is the same engine the web game runs, compiled to WebAssembly there and linked as a plain Rust library here.
Your code owns everything a player sees and hears (rendering, audio, UI, input). The engine owns what happens in the tower. Frontends may differ; simulations may not, and the conformance suite is how a host proves it runs the real one.
- Status
- Add it
- Quick start
- How the engine works
- The host API
- Features
- Saves
- Proving conformance
- Versioning and stability
- The JavaScript package
- License
Status
Section titled “Status”The crate is not on crates.io yet. It compiles the gameplay event catalog
in from conformance/ beside engine-rs/ in the repository, so it builds
from a checkout of the whole repository and cannot be packaged on its own
until that moves
(#893). Until then,
depend on it by git tag or commit, as below. This README is the one a
registry release will show.
Add it
Section titled “Add it”Each engine release is tagged engine-v<version>:
[dependencies]verticopolis-engine = { git = "https://github.com/maniator/verticopolis", tag = "engine-v1.24.0" }Or pin a commit with rev = "<sha>". The crate needs the Rust release named
by rust-version in its Cargo.toml (1.97 today) and nothing else: no
Node.js, no build script, no system library.
Quick start
Section titled “Quick start”use verticopolis_engine::clock::GameMode;use verticopolis_engine::facilities::Kind;use verticopolis_engine::load::{ decode_vctower, deserialize, encode_simulation_vctower, mark_founder_from_loaded_file,};use verticopolis_engine::sim::Simulation;
fn main() -> Result<(), String> { // A new Classic game from seed 42. let mut sim = Simulation::new_game(42, GameMode::Classic);
// A lobby on the ground floor (floor 1), one tile per call. for x in 100..124 { sim.build(Kind::Lobby, 1, x); }
// Quote an office before placing it: the same dry run `build` makes, // with the full price. let quote = sim.can_build(Kind::Office, 2, 106); println!("ok {} cost {}", quote.ok, quote.cost); let placed = sim.build(Kind::Office, 2, 106); if !placed.ok { return Err(placed.reason.unwrap_or_default()); }
// One game day, a minute at a time, draining the gameplay events. for _ in 0..24 * 60 { sim.tick(1.0); for event in sim.gameplay.drain() { println!("{}", event.to_json()); } } println!("population {} money {}", sim.population(), sim.money);
// Save as a .vctower file the web game can open, then load it back. let bytes = encode_simulation_vctower(&sim, "my-host 1.0.0", 1_791_849_600_000.0)?; let text = String::from_utf8(bytes).map_err(|e| e.to_string())?; let save = decode_vctower(&text)?; let mut loaded = deserialize(&save)?; mark_founder_from_loaded_file(&mut loaded, &save); Ok(())}These pages are also at docs.verticopolis.com, with the Rust API reference: every host call with what it does and a runnable example. The reference shows the host API only; the engine’s internals are public for its tests and hidden there.
A fuller host, which every CI run builds and runs from outside the engine,
is engine-rs/consumer/.
How the engine works
Section titled “How the engine works”- One
Simulationis one game. Start one withSimulation::new_game(ornew_game_withfor the calendar and bridging options), or load one from a save. - Commands answer instead of panicking. A command a player could make
returns a result with
okand, when it is refused, areasonsentence you can show the player. Commands that move money report the amount. - The engine does no I/O. It reads no file, clock, environment, console
or network, and spawns no thread or process. You pass in time (
tick), file contents and timestamps, and you get bytes back to store wherever you like. A test in the repository holds the library to this. - It is deterministic. The same seed, the same commands and the same
sequence of
tickcalls give the same game on any machine, in this crate and in the WebAssembly build. - Coordinates.
floorcounts up from the ground floor, which is1;0and below are basements.xis a tile column from0across the lot. The catalog’s world record carries the exact bounds. - Modes.
GameMode::Classicfollows the 1994 original;GameMode::Modernadds facilities and rules the original did not have.
The host API
Section titled “The host API”These are the calls a frontend makes, in short. The repository’s
engine-rs/API.md
gives each one in full; a test there names each of its calls, so a rename
fails CI until that page and the example host follow.
| Need | Call |
|---|---|
| Start a game | sim::Simulation::new_game(seed, mode), new_game_with(seed, mode, calendar, start_unbridged) |
| Advance time | sim.tick(dt_minutes), or fixed_step::FrameStepper::advance to pace time from frames the way the web game does |
| Quote a placement | sim.can_build(kind, floor, x), sim.can_build_transport(kind, x, bottom, top) |
| Player commands | build, build_transport, sell_at, paint_brush, add_car, remove_car, extend_transport, remove_facility, adjust_rent, set_no_rate, price_unit, change_film (show a film tier at a theater, at the price in the catalog’s films; Modern theaters also book their set_film_policy film again once a film is spent), resolve_choice |
| Read state | the public fields (money, star, clock, tower, log) and population(); read them, never write them |
| Draw a frame | view::structure_view(&sim) and view::ViewReader::frame, or frame::frame_view(&sim), flat number arrays read through the named slots in view::layout and frame |
| Gameplay events | sim.gameplay.drain() after each frame; the names and payloads are in gameplay::CATALOG_JSON |
| Reference data | catalog::catalog(mode): every price, size, cap and rule a frontend shows |
| Identity | identity::identity(): the crate version and the save, layout, catalog and conformance versions it speaks |
| State hash | canonical::digest(&snapshot::state_view(&sim)) |
| Saves | load::encode_simulation_vctower, load::decode_vctower, load::deserialize, import_tdt, export_tdt |
Read rules, prices and caps from the catalog rather than copying them into
your code. Many other items are pub so the tests can reach them; they are
internal and can change in any release. Calling a per-phase step or writing
a field directly skips the rules a command applies.
Features
Section titled “Features”The crate has no default features.
| Feature | What it adds |
|---|---|
conformance |
The harness that replays the conformance suite (scenario, suite, the .TDT referee) and the command-line tools. It reads the suite’s files, so a host turns it on only to prove conformance. |
wasm |
The JavaScript binding, for a wasm32-unknown-unknown build through wasm-bindgen. A native host leaves it off. |
.vctoweris the native save format, the file the web game imports and exports.encode_simulation_vctower(&sim, app_version, saved_at_ms)writes the same bytes the web game writes for the same game. The engine has no clock and no build name, so you pass both.- Loading is
decode_vctower, thendeserialize, thenmark_founder_from_loaded_file, which keeps the status the web game gives a tower saved before build stamps existed. .TDTis the 1994 SimTower format:import_tdt(bytes, filename)reads one andexport_tdt(save_json)writes one.- A save holds the tower, not the moment. The people in the building and the elevator cars restart on load, so a loaded game does not carry on exactly where the saving game was. Two loads of the same save stay identical to each other.
- The loader repairs. A damaged or hand-edited save is coerced the way the web game’s loader does; only a file it cannot read is refused, and so is a save from a newer engine.
Proving conformance
Section titled “Proving conformance”The repository’s
conformance/
directory is a suite of scripted games with the state hash pinned at each
checkpoint (expected.json). An engine at a given version replays every
scenario exactly. With the conformance feature a host replays it itself:
verticopolis-engine = { git = "https://github.com/maniator/verticopolis", tag = "engine-v1.24.0", features = ["conformance"] }scenario::Scenario::parse and scenario::run_scenario(&scenario, suite)
replay one scenario against a copy of the suite, which is what
engine-rs/consumer/ does for every scenario on every CI run.
Versioning and stability
Section titled “Versioning and stability”- The crate version moves with the engine’s contract: the conformance lock (simulation behavior), the save version, the frame and view layouts, the catalog, the identity record and the JavaScript binding’s declarations. A change that keeps all of these, such as a speed-up, does not raise it on its own.
- No semver promise yet. Until the compatibility policy is written (#893), a minor version can change a call’s shape or the game’s behavior. Pin an exact tag and read the changelog before you move.
- The lock is the contract. Every change to simulation behavior lands
with an updated
conformance/expected.json, and that diff is what you take on when you move your pin. - The host API above changes only on purpose, with a changelog line.
The JavaScript package
Section titled “The JavaScript package”Each engine release also publishes this crate’s WebAssembly build as the
npm package @maniator/verticopolis-engine on GitHub Packages, with builds
for Node and the browser. Its
README
covers installing and calling it from JavaScript.
License
Section titled “License”MIT. The crates the engine depends on are listed in its Cargo.toml.
