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).
Depending on the engine
Section titled “Depending on the engine”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).
The host API
Section titled “The host API”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.
deserializecoerces damaged or forged fields the way the web game’s loader does; it refuses only what it cannot read (decode_vctowerrefuses 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.
Stability
Section titled “Stability”- No semver promise yet. The crate is on no Cargo registry (its version
is in
Cargo.toml); a consumer pins a commit or anengine-v<version>tag. Each crate release also publishes the WASM build to GitHub Packages as@maniator/verticopolis-engine, whose README ispackages/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 bysrc/tests/engineCompat.guard.test.ts; the RustpubAPI is a review call). Registry releases are #893. - The lock is the contract. Any change to simulation behavior lands
with a regenerated
conformance/expected.jsonin 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::refereeandsrc/bin/) is tier 2, behind theconformancefeature: 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_carsandresize_transportchange 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 topub(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 infixed_stepis matched to it by recorded frame sequences, and the web host does not call it yet; #867).
