towpathengineering notes

How the village stays honest

towpath looks like a cozy toy and is built like one, but under the thatch it's an experiment in making a game's important state verifiable without ever showing a player anything chain-shaped. These notes cover what runs where, the tradeoffs behind each choice, and the questions engineers ask when they read the source.

Ground rules that shaped everything: no server anywhere, no blockchain jargon in the UI ever, real-money things are cosmetic only, and copy tells players the truth even when a euphemism would be smoother.

The shape of the thing

The client is a static Vite build: React, react-three-fiber for the canal, zustand for state. There is no backend. The build is published to Polkadot's Bulletin chain with bulletin-deploy and served through the dot.li host at towpath.paseo.li, where it runs sandboxed inside a host container that provides identity, signing, storage and chain access.

Everything below the surface splits into four layers:

LayerLives inJob
Rulesengine/ (Rust, no_std)The one true implementation of the game's economy
Clientsrc/Rendering, input, services; executes the rules via wasm
Chaincontracts/verifier/Replays player logs on chain so nobody can cheat
Tuningsim/ (Rust)Monte Carlo simulation that chose the economy's numbers

One implementation, three deployments

The game rules live in a single Rust crate, towpath-engine: no_std, zero dependencies, integer arithmetic only. Money is centicoins (coin × 100), time is unix seconds, and the away-time decay is the exact rational (5/4)ⁿ rather than a float.

That one crate compiles three ways:

Why integers and no_std? The contract target is a RISC-V machine with no float hardware, and a verifier wants bit-exact answers anyway. Making the whole engine integer-only means the wasm client, the contract and the sim cannot disagree by a rounding error, because there is nothing to round.

Why is the wasm the only executor? Two implementations of one economy will drift apart, and every drift is either a dupe bug or a player losing something. The cross-check suite holds the TypeScript codec mirror to the Rust codec byte for byte, and everything else flows through the engine.

The world is the log

A towpath village is not a save file. It's an append-only log of player actions: plant, harvest, sell, dig, place, remove, repaint, claim. The world you see is a fold over that log, computed by the engine, settled up to the current chain time. Villager labor is deliberately not in the log: it's derived deterministically from the actions and the clock, so the log stays small and only records human intent.

This buys three things at once:

Time comes from the chain, not the device: the client reads Asset Hub's timestamp at boot and keeps an offset. Winding your system clock forward makes the screen lie to you locally, but the log's timestamps get checked against block time when they reach the chain, so clock cheating can't mint anything durable.

Three tiers of remembering

Nothing here has a server, so durability is a deliberate stack of chain services with different lifetimes:

TierHoldsLifetimeWhy it's the right tool
Verifier contractFolded state, a hash chained over every published segment, the latest Bulletin keyPermanentSmall, bounded, and the part that must survive anything
Bulletin chainThe full action logWeeks, renewableCheap bytes for the full story; re-published on every share
Statement storeChat, presence, the villages directoryAbout two daysThe now layer: things that should fade anyway

The recovery path stitches them together: sign in on a fresh device, ask the contract for your latest Bulletin key, fetch the log, fold it, and check the result matches the contract's stored state byte for byte. Statements having expired costs you nothing but stale chat.

The checkpoint contract is the verifier. Submitting a checkpoint hands the contract the actions since last time; it re-runs them through the same engine and refuses anything that doesn't fold: wrong prices, impossible timing, actions from the future. Player accounts pay their own checkpoint gas, which is what lets the whole thing scale without towpath running a sponsor wallet that would become a bottleneck and a honeypot.

Identity without jargon

Sign-in goes only through the host container. The host knows who you are; towpath resolves your username to the account your collectibles live on, entirely behind the curtain. There is no address input in the game, by rule.

Two reasons, and both are load-bearing. First, an address field is jargon soup for the audience this game wants. Second, it's a spoofing hole: type anyone's address and wear their collectibles. Host-only identity closes both at once. A ?owner= URL parameter exists for development and never surfaces in the interface.

An economy chosen by simulation

The numbers players feel (wage rate, bed price ladder, decay curve, sell prices) weren't guessed. A zero-dependency Rust Monte Carlo simulator models player archetypes from check-in-daily to vanish-for-a-fortnight, and six candidate rule sets ran against them. The winner shipped:

The tradeoff worth naming: wages plus seed costs mean an away-player's coin balance can genuinely go down while produce piles up unsold. The sim says it self-corrects on the next visit, and the welcome basket explains it instead of hiding it. Honest copy is a design constraint here, not a courtesy.

The art pipeline

Every 3D asset is a Python script that builds a Blender scene from primitives and exports a GLB. No .blend file is ever saved; the scripts are the source of truth, rebuildable byte for byte, and cheap to iterate because they render their own preview turnarounds headless. Batches of assets get built by parallel agents, one isolated Blender process each.

The cel look is applied at runtime: the client swaps every GLB material for a shared three-step toon material, keyed by color. Material names are an API, since the livery system recolors the boat by targeting materials by name. 2D art (resident sprites, UI nine-slices, portraits) is generated with strict canvas and chroma-key specs, then cut by script.

Perf discipline is draw calls, not polygons: every same-material chunk of an asset gets merged into one mesh, with budgets per asset class. The whole model payload for the village is around 3 MB.

Looking down the rough cobbled church path between the pub and shop roofs to the church and lychgate.
Every model here is a Python script; every placement is one row in a layout table.

Which chain does what

ChainUsed for
Individuality chainPocket collectibles live here as 32-byte attestation hashes; towpath reads them and maps hash bytes to grants deterministically
BulletinHosts the deployed app itself and every published village log
Asset HubThe clock (its timestamp anchors growth) and the PolkaVM verifier contract
Statement storeVillage chat, presence, and the neighbors directory, through the host

All of it on Paseo, the test network. The collectible-to-grant mapping is versioned and append-only, so a collectible never changes what it grants.

Engineering FAQ

Why no server? Even a small one would simplify things.

A server is a trust point, a cost center, and a single point of death for a game meant to outlive attention spans. Every job a server would do here has a chain service with the right shape: durable bytes, permanent state, ephemeral messaging. The price is designing for expiry windows and payload budgets, which turned out to be a feature: it forced the world-is-a-log design.

Why a custom fixed-width codec instead of JSON or SCALE?

The engine is no_std with zero dependencies, and its bytes travel as contract calldata where every byte is gas. A hand-rolled fixed-width little-endian codec is deterministic, allocation-light, trivially mirrored in TypeScript, and held honest by cross-language round-trip tests. JSON is too big and float-happy; SCALE would drag in dependencies the crate exists to avoid.

Two hash functions? The engine uses fnv256 but the contract uses keccak.

Different jobs. The engine chains claimed-collectible hashes into a commitment inside no_std with no crypto dependencies, where collision resistance against adversaries isn't the requirement. The contract chains published log segments with keccak because that hash is the security boundary, and the host provides it natively. The chain binds the weak commitment inside the strong one.

Why does the contract not settle labor to block time?

So stored state stays a pure function of the log. The client can reproduce the contract's state byte for byte without knowing which block the checkpoint landed in. Block time still bounds the fold: actions from the future revert the call.

What stops someone scripting a perfect player?

Nothing stops a bot from playing legally, and that's fine: the economy is single-player and the caps are structural. What the verifier stops is illegal state: you cannot submit a log that mints coins, skips time, or claims collectibles you don't own, because the chain refolds everything and reverts what doesn't replay. Bots can only be diligent, not rich.

Why localStorage for the working copy? That's fragile.

It's a cache, not the record. The log is tiny, rebuilds the world from genesis in microseconds through the wasm engine, and the durable copies live on Bulletin and in the contract. Losing localStorage costs you nothing once you've shared your village.

How does mock mode work?

?mock=1 swaps every chain service for a localStorage double with the same interface: fake collectibles, an emulated contract that runs the same wasm fold, cross-tab chat over storage events. The whole loop, including publish, visit, checkpoint and recover, works offline. It's how the game is developed on a narrowboat with metered internet, and how the end-to-end tests run in CI without touching a network.

How will trading and trade-ups stay fair?

The same trick the verifier already uses: commit first, then take entropy from a block hash that doesn't exist yet. A trade-up commits its inputs in one block and derives its roll from a later block's hash, so neither the player nor towpath can steer the outcome, and anyone can recompute the roll afterwards. Real-value trading stays inside the cosmetic tier as a hard rule; the pool is one global population shared by every player, kept separate from event collectibles, which are non-transferable mementos. The full recipe ships as a technical note with the feature.

Why are the UI panels PNG nine-slices instead of CSS?

The corner roses, vines and nameplates are painted art, and border-image lets the browser do the slicing natively with zero runtime cost. This documentation site uses the very same PNGs and slice parameters as the game, which is why it looks like this.