Skip to content

Host API guide

verticopolis-engine is the whole simulation: rules, commands, state, saves and gameplay events. A frontend owns everything a player sees and hears (rendering, audio, UI, input, presentation) and drives the engine through the calls below. Frontends may differ. Simulations may not: a host proves it runs the real simulation by replaying the conformance suite (../conformance/).

consumer/ is a working example and the CI gate for this page: a crate outside the engine that depends on it by path and makes every tier 1 call below. The consumer job in .github/workflows/engine-rs.yml runs in the official Rust container image, which has no Node.js, from a checkout holding only engine-rs/ and conformance/. It copies consumer/ out of the checkout and builds, tests and runs the copy there, depending on the engine by an absolute path. The same job runs the engine’s own tests and the full referee from that checkout. The plan this page belongs to is issue #980.

What that proves today: the engine builds, passes its tests and runs with no web game and no Node.js, and a crate in another directory, reaching this repository only through its dependency line and the conformance directory it is given, can drive it and replay every conformance scenario, the ones that start from a saved tower (conformance/fixtures/) included, from a copy of the suite under another directory name. What it does not prove yet: a registry or packaged release (the crate reads ../conformance/ at compile time, so it needs the repository beside it; #893).

One of these two:

[dependencies]
# From a checkout of this repository:
verticopolis-engine = { path = "../verticopolis/engine-rs" }
[dependencies]
# Pinned to a commit:
verticopolis-engine = { git = "https://github.com/maniator/verticopolis", rev = "<sha>" }

The crate has no default features. The wasm feature is the JavaScript binding (src/wasm.rs and src/wasm/) and a native host leaves it off. The conformance feature is the harness that replays the lock (tier 2 below); a host turns it on only to prove conformance, as consumer/ does:

verticopolis-engine = { git = "https://github.com/maniator/verticopolis", rev = "<sha>", features = ["conformance"] }

The JavaScript binding’s exports carry tiers of their own, separate from the crate tiers on this page: player commands, restores and reads, and the relay, test and seed ops that skip the charge rules. The table is src/dualrun/exportTiers.ts; a new export picks a tier there, and src/tests/bindingExportTiers.guard.test.ts keeps the third tier out of the game’s UI (#946).

The build needs the Rust toolchain in Cargo.toml (rust-version) and the engine-rs/ and conformance/ directories (the event catalog is compiled in from there). It needs no Node.js, no npm and nothing else from the web game. Because the crate reads ../conformance/ at compile time, it builds from a checkout of the whole repository (which a path or git dependency gives you) and cannot be packaged on its own yet (#893).

A game does no IO. The simulation reads no file, clock, environment, console or network, and spawns no thread or process; the host passes in bytes, time and its build name. The one exception is a short list of conformance harness functions in scenario.rs, suite.rs and tdt/referee.rs, which read the scenario and fixture files they are pointed at. All three modules are compiled only with the conformance feature, so the library a host links by default reads no file at all. The rest of each file is checked like any other library code. tests/independence.rs scans the library source for those services (by path, grouped import, macro and foreign-function block), refuses a build script for the engine itself, data (include_str!, include_bytes!) from outside engine-rs/ and conformance/, and code (include!, #[path]) from anything but a .rs file under src/ outside src/bin/, and fails when the engine’s or the consumer’s Cargo.lock names a crate nobody reviewed for host access. It also checks that the harness modules and every binary sit behind the conformance feature. tests/read_boundary.rs checks the Rust files under src/, tests/ and consumer/src/ (tests/independence.rs and tests/read_boundary.rs aside, whose examples climb on purpose): a .. path leads only to conformance/. Both read source text, so they catch the ordinary spellings; review still owns the rest, and the sparse CI job is the proof for a path that steps up another way (Path::parent, ancestors).

These are the calls a frontend makes (tier 1 in #980). consumer/ calls each of them, and tests/api.rs names each one in a build with default features, so a rename or a removal fails CI until this page and the consumer follow, and none of them needs the harness. The last row is tier 2, the harness a host uses to prove conformance.

Need Call
Start a game sim::Simulation::new_game(seed, GameMode::Classic), or new_game_with(seed, mode, calendar, start_unbridged)
Advance time sim.tick(dt_minutes): any dt, split into steps that never skip an hour. Same seed, same commands and the same sequence of dt give the same game
Drive time from frames stepper.advance(&mut sim, dt_ms, minutes_per_second, steady_clock, step) on a fixed_step::FrameStepper::default(): the frame loop the web host runs, for a Rust host that drives the engine itself. It owes each frame’s minutes (paced by the canon day curve, or flat with the steady clock), caps the carry at MAX_CATCHUP_MINUTES, and cuts what is owed into whole quanta sized from the sim’s own clock (quantum_for, at most MAX_STEPS_PER_FRAME a frame), so the tower is the same at any frame rate. Use it instead of choosing your own dt. Given the same frames, the ticks it cuts and the carry it keeps match the browser’s frame loop bit for bit (engine-rs/tests/data/fixed-step-cases.json records them). Two kinds of rule live in the browser’s host, outside that loop: Excalibur turns any frame over 200 ms into 1 ms before the loop sees it, and the host zeroes the carry (stepper.acc_minutes = 0.0) when the player changes speed (even when the change is undone before the next frame) or toggles the steady clock, while a blocking modal is open, and once the engine has stopped. A host that wants the browser’s tower applies these before calling advance
Quote a placement sim.can_build(kind, floor, x): { ok, reason, cost }, the dry run build makes, with the full price. sim.can_build_transport(kind, x, bottom, top) is the same for a shaft, the dry run build_transport makes: an elevator costs the catalog cost plus transportFloorCost for each floor past its first, stairs and escalators the catalog cost. The price is a shaft’s only when the kind is a transport and top is above bottom; the engine refuses anything else
Player commands build, build_transport, sell_at, toggle_auto_bridge (build.rs); paint_brush (brush.rs), the floor and lobby brush as one command; add_car, remove_car, extend_transport, remove_facility (charges.rs), which charge or refund and report the money moved, with begin_extend and end_extend around a drag so extend_transport bills a wiggle once against the engine-held mark; adjust_rent, set_no_rate, price_unit, apply_rent_batch (rent.rs); resolve_choice, set_film_policy (events.rs)
Read state the public fields (money, star, clock, tower, crowd, log, …) and population(); read them, never write them
Read a frame frame::frame_view(&sim): every value a renderer reads between two structural syncs (clock, money, rating, effects, then one record per person, per unit and per shaft with its cars) as one flat Vec<f64>, laid out in src/frame.rs. Read it by name: the slots in frame::header, frame::person, frame::unit, frame::transport and frame::car (frame::header::MONEY, frame::person::ROUTINE, …), and the record widths FRAME_HEADER, PERSON_FIXED, UNIT_RECORD, TRANSPORT_FIXED and CAR_RECORD; a routine code of UNKNOWN_ROUTINE means the engine has a routine the layout does not name yet, and a decoder should refuse it. frame::log_since(&sim, seq) returns the log entries numbered after seq. The JavaScript binding passes the same frame across as one typed array
Read the view The renderer-facing view (facade slice 6a, #1213), for a host that keeps no copy of the simulation: view::structure_view(&sim), the structural snapshot (the floor bounds, the mode and calendar, the scene seed, the rooms by slot, the floor and lobby tiles as runs by floor and x, the shafts with their skipped floors), read again when tower.revision moves and after a load, whose tower starts its revisions over; view::ViewReader::new() and reader.frame(&sim, full), the dynamic frame (the header, the people, the rooms whose fields changed since that reader’s last frame, every room on its first frame, after a structural change, on another tower or when full asks, and the cars; from half the rooms’ worth of marks, or when the tower’s mark log dropped marks that reader had not read, it compares every room instead, which sends the same rooms), read every rendered frame (the engine marks a room when its own commands and tick change it, so a host that writes a unit’s fields itself passes full on its next frame); view::meta_since(&sim.tower, since), the labels and subtypes changed after a meta revision, read when tower.meta_revision moves (a tower starts at meta revision 1, so None or 0 lists them all, and a since holds for one tower only); sim.tower_signature(), a pure fingerprint of the fields the tower half of undo’s signature reads, equal at two reads when those fields are (one walk over the units and shafts, a per-command read). Read them by name: the slots and widths in view::layout (view::layout::header::POPULATION, view::layout::RUN_RECORD, …), all of them with the kind, state, weather, mode and calendar codes as JSON from view::layout_json(), versioned by VIEW_LAYOUT_VERSION. The JavaScript binding’s viewStructure, viewFrame, unitMetaSince, towerSignature (a bigint) and viewLayout wrap these, with one frame reader per engine, whose frames the header’s frameSeq numbers
Gameplay events sim.gameplay.drain() after each frame; to_json() on each event; names and payloads are listed in conformance/events/catalog.json (gameplay::CATALOG_JSON, checked with check_event). The buffer holds GAMEPLAY_RING_CAP events and counts any it had to drop in sim.gameplay.dropped
Reference data catalog::catalog(mode): prices, sizes, caps and rules per facility kind, versioned by CATALOG_VERSION
Identity identity::identity() (engineIdentity() in the binding): schema, crate name and version, SAVE_VERSION and the oldest save version loaded, FRAME_LAYOUT_VERSION, CATALOG_VERSION with each mode’s digest, and the conformance lock digest. It holds no build hash: a hash of the sources cannot be compiled into them, so the packaging supplies it as build metadata (below). The crate version goes up with the conformance lock, SAVE_VERSION, the frame layout, view layout, catalog and identity record versions, and src/dualrun/engine.d.ts (compat-record.json, guarded by src/tests/engineCompat.guard.test.ts); the guard does not see the Rust pub API, so a change there is a review call (CONTRIBUTING.md, Versioning)
Snapshot hash canonical::digest(&snapshot::state_view(&sim)): the hashed view the conformance lock pins (with the conformance feature, scenario::state_view names the same function)
Save load::encode_simulation_vctower(&sim, app_version, saved_at_ms): the .vctower bytes the web game writes. A host that holds the save JSON (sim.serialize()) gets the same file from load::stamp_save(&mut save, app_version, saved_at_ms) then load::encode_vctower(&save)
Load load::decode_vctower(text), then load::deserialize(&save), then load::mark_founder_from_loaded_file(&mut sim, &save), which marks a file with no build stamp as a founder’s tower the way the web game does (skip it and a host drops that status on its next save)
1994 saves import_tdt(bytes, filename) and export_tdt(save_json)
Conformance (tier 2, the conformance feature) scenario::Scenario::parse and scenario::run_scenario(&scenario, suite), compared with expected.json (as consumer/ does for every scenario), or cargo run --features conformance --bin conformance from this directory for the whole suite. suite is the conformance directory under any name (before stage 4 these runners took the repository root; a scenario that starts from a fixture now refuses a root, with a message naming the mistake, and a new-game scenario reads nothing from the suite): a scenario names its fixture conformance/fixtures/..., and suite::fixture_path reads that first step as the suite itself and refuses a name that leaves it

Two behaviors a host should know:

  • A save holds the tower and leaves out the live crowd and the elevator dispatch. A loaded game restarts both, so it does not track the game that wrote the save. Two loads of the same save do track each other exactly.
  • The loader repairs. deserialize coerces damaged or forged fields the way the web game’s loader does; it refuses only what it cannot read (decode_vctower refuses a file that is not a .vctower).

Build metadata (supplied by the host’s packaging)

Section titled “Build metadata (supplied by the host’s packaging)”

The identity record is everything the engine knows about itself. Which sources a build came from is not among those facts, so the packaging supplies it, and nothing in the engine reads it or requires it:

{ "schema": 1, "sourceHash": "<64 lowercase hex digits>" }

sourceHash is the SHA-256 over the engine’s Rust sources and the conformance event catalog (engineSourceHash in src/wasmhost/packageHash.ts). A packager outside the web build computes the same value as follows. Take every file under the crate directory except target/, pkg/, hidden names, names ending in ~ and Markdown files, plus every file under conformance/events/ at the repository root and every file a plain include_str!("...") or include_bytes!("...") names. Label each file with its path relative to the crate directory, using / (the conformance files are labeled ../conformance/events/...). Sort the labels by UTF-16 code unit. For each file in that order, feed the SHA-256 the label, a zero byte, the file with each CRLF read as LF (a file containing a zero byte is taken as it is), and a zero byte. The hex digest, in lowercase, is the value. That makes the hash a fact about the sources; a compiled binary’s own digest is a separate one (the package id in BUILD.json). The web build writes the object, as UTF-8 JSON, into a custom section named verticopolis.build of each .wasm (scripts/wasm-build.ts, right after wasm-bindgen and before any digest is taken), and the page reads it back from the compiled module (src/wasmhost/engineLoad.ts). A native Rust or Godot host supplies the same object its own way, for example a file its build step writes next to the library or a constant it links in, and a frontend that shows both shows them side by side. A missing or malformed object is reported as unavailable, never as a failure of the engine.

  • No semver promise yet. The crate is on no Cargo registry (its version is in Cargo.toml); a consumer pins a commit or an engine-v<version> tag. Each crate release also publishes the WASM build to GitHub Packages as @maniator/verticopolis-engine, whose README is packages/verticopolis-engine/README.md. The version goes up with the conformance lock, the save, frame layout, view layout, catalog and identity record versions and the binding declaration (src/compat-record.json, held by src/tests/engineCompat.guard.test.ts; the Rust pub API is a review call). Registry releases are #893.
  • The lock is the contract. Any change to simulation behavior lands with a regenerated conformance/expected.json in the same pull request, and that diff is what a consumer must take on a pin bump. A host that replays the lock at its pinned commit runs the same simulation as the web game at that commit.
  • Tier 1 above changes only on purpose, in a pull request that updates this page and consumer/ with it.
  • The conformance harness (scenario, suite, tdt::referee and src/bin/) is tier 2, behind the conformance feature: usable to replay the lock, and changed only with the suite it replays.
  • Everything else is internal (tier 3), even where it is pub, and the Rust API reference hides it (#[doc(hidden)]). It is public so the tests can reach it. Calling a per-phase step (collect_rent, pay_maintenance, start_fire, maybe_random_event, and the like), a raw edit (set_cars and resize_transport change a shaft without charging; they exist to replay another engine’s edits) or writing a field skips the rules a command applies, and any of them can change in any commit.
  • Not covered yet (stages in #980): tier 2 is behind its feature and tier 1 is pinned by name, but tier 3 is still pub, hidden from the reference but not from the compiler (narrowing it to pub(crate), and getters for the fields a host reads, are the rest of stage 4), and the web host still runs its own TypeScript fixed-step driver (the Rust one in fixed_step is matched to it by recorded frame sequences, and the web host does not call it yet; #867).