Skip to content

jengine-city

A city builder - zones that grow by demand, power and water networks, traffic, land value, a budget and disasters.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-city
Version0.1.0
Entry pointjengine_city::CityPackage
Script namespaceCity
Listed byprojects/strategy-classics

List it in the top-level "packages" array of project.jengine:

{
"packages": [
{ "name": "jengine-city", "source": { "type": "path", "path": "../../packages/jengine-city" } }
]
}

Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the editor’s Rebuild viewport banner). Native and web builds link it on their own.

A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field for a package component and keeps it as inert custom data.

A script can check that the package is linked with if City == nil then return end.

Scene keyRust type
city_gameCityGamepackage only: needs a v2 scene

Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.

CallFlat name
City.start(e, scenario, difficulty, seed, form)city_start
City.demo(e, seed)city_demo
City.load(e, text)city_load
City.save(e)city_save
City.stop(e)city_stop
City.speed(e, speed)city_speed
City.tool(e, tool)city_tool
City.pointer(e, x, y)city_pointer
City.press(e, x, y)city_press
City.release(e, x, y)city_release
City.cancel(e)city_cancel
City.overlay(e, name)city_overlay
City.tax(e, zone, rate)city_tax
City.fund(e, dept, percent)city_fund
City.bond(e, take)city_bond
City.disasters(e, on)city_disasters
City.trigger(e, kind)city_trigger
City.autopilot(e, on)city_autopilot
City.assist(e, on)city_assist
City.daylight(e, on)city_daylight
City.clock(e, hour)city_clock
City.rename(e, name)city_rename
  • projects/strategy-classics/assets/scenes/city.scene

From packages/jengine-city/README.md.

A SimCity-style city builder: residential, commercial and industrial zones that grow and decay by demand, power and water networks, traffic over the road graph, land value, pollution and crime, a budget, disasters, milestones and scenarios. It was written for Brickhaven (projects/strategy-classics, game id city); any game that wants a zoned, simulated city under it can use it, and anything that wants only part of it (a traffic model over a grid, a flood-filled utility network) can call its modules directly.

The simulation is city::City - plain data with no ECS in it, stepped by City::update(dt) and changed by City::apply(tool, from, to), the same call every tool goes through. It serializes whole, so a save is serde_json::to_string(&city) and a load is from_str + after_load(). The package around it draws a city with ordinary engine components and takes the player’s commands from scripts.

List it in the project’s project.jengine:

{ "name": "jengine-city", "source": { "type": "path", "path": "../../packages/jengine-city" } }

A scene using its component must be a v2 scene ("version": 2). The scene authors the tilemap the land is drawn into (named by city_game.land, with layers terrain, ground, road, rail, wires, overlay and pipes, and an autotile on terrain), an ambient_light (the day and night turn it), the atlas sprites/city/buildings.atlas in atlas_refs, and any number of particle emitters named CitySmoke1, CitySmoke2, … which the package moves onto fires and the smokiest chimneys in view. Brickhaven’s generator, projects/strategy-classics/tools/gen_city.py, writes all of it, and the tile and region layouts it uses are mirrored in draw.rs (a test checks the atlas against them).

  • Zones and growth (growth). Each zoned tile is a lot that grows from empty to density 4. Every five days a lot weighs its zone’s demand against what it sees: power, a road or rail within 3 tiles, a commute that works, water, land value, pollution, crime and nearby services. Without water a lot stops at density 1; tall homes and offices need valuable land. A lot cut off from power or its road for about four months is abandoned a level at a time.
  • Demand (the RCI bars): people come where there are jobs, shops follow people and industry, industry follows workers and the outside market. Taxes over 7% push demand down and under 7% up - at 20% nothing grows. Past 25,000 residents homes need a stadium, past 6,000 jobs commerce needs an airport and industry a seaport.
  • Power and water (networks) are flood fills from every plant and pump, so plants on one grid share their capacity; a grid that asks for more browns out at its far end. Power passes along lines and through every zone and building; water passes along pipes (an underground layer), which serve lots within 2 tiles. Pumps need power and pump more beside water.
  • Traffic (traffic): a shortest-path fill over roads and rails from every job (and every home), costing a congested road more. Commuters walk down it from home to work and load every road tile they pass; trips over a road’s capacity are next month’s congestion. A lot whose commute is longer than 56 tiles has nowhere to go. Rail carries trips at half the cost and puts no car on a road.
  • Fields (fields): service coverage over a radius scaled by funding; pollution from industry, plants, ports and busy roads, blurred and soaked up by trees and parks; crime from density, cut by police and land value; land value from water, trees, parks, schools, hospitals and the city’s middle, less pollution, crime and traffic.
  • Money (budget): taxes per zone, funding per department (police, fire, health and schools, transport - starved roads crumble), upkeep, up to five bonds at rising interest, paid a twelfth each month. A year with an empty treasury loses the game.
  • Disasters (disaster): fire that spreads between buildings and forest and is put out by fire coverage, floods rising off the shore, tornadoes, and a nuclear meltdown that poisons the ground. With disasters on they come by themselves; any can be set off on purpose.
  • Milestones and scenarios (scenario): Village, Town (2,000), City (10,000), Capital (50,000), Metropolis (100,000), each unlocking buildings. A free sandbox (Metropolis wins), and four goals with deadlines: Founding (3,000 in three years), Boomtown (50,000 in twenty), Gridlock (a jammed city whose congestion must be halved) and After the Storm (a city to rebuild).
  • The bot (bot): a mayor who lays a street grid in blocks and does what a player would through the same tools - claims blocks by demand, keeps a plant ahead of the load, pumps water, places services where coverage is thin, clears rubble, re-lays torn streets, keeps the treasury afloat. It builds the grown scenarios’ cities, plays Brickhaven’s demo, and can just say its next action, which is how a test plays a scenario by clicks.
ComponentIs
city_gameWhere a city is played. land names the tilemap entity; size the map’s width and height in tiles (112); demo founds a sandbox city at once and lets the bot build it; seed its land; daylight runs a cosmetic day and night (four minutes a day: the ambient light dims and windows, headlights and stadium lights come on). The city itself lives beside it, never saved with the scene.

Every command takes the entity carrying city_game first. Tile coordinates are tile space: x right, y down, a tile one unit (pass a pointer’s exact position; it is floored).

CallDoes
City.start(e, scenario, difficulty, seed, form)A new city: sandbox, founding, boomtown, gridlock or storm; easy, normal or hard; seed 0 for a random one; land river, coast, lake, island or "" for any.
City.demo(e, seed)A sandbox city the bot builds at full speed (a title screen’s backdrop).
City.load(e, text) / City.save(e)Load a save, or ask for one: it appears in city.save the next frame.
City.stop(e)Put the city away.
City.speed(e, n)0 paused, 1 slow, 2 normal, 3 fast, 4 ultra (20 days a second).
City.tool(e, key)Pick a tool: query, bulldoze, road, rail, power, pipe, res, com, ind, coal, gas, nuclear, wind, pump, tower, police, fire, school, hospital, park, stadium, seaport, airport.
City.pointer(e, x, y)Where the pointer is, for the ghost, the cost and the hover readout.
City.press(e, x, y) / City.release(e, x, y)A drag: the tool is used from the press to the release (an L for networks, a rectangle for zones and the bulldozer, the footprint centred on the release for a building).
City.cancel(e)Drop a drag.
City.overlay(e, name)Show a data overlay: power, water, traffic, pollution, land_value, crime, police, fire, health, school, zones, pipes (the underground view), "" for none.
City.tax(e, zone, rate)Tax rate in percent for zone 0 (R), 1 (C), 2 (I), 0 to 20.
City.fund(e, dept, percent)Funding for 0 police, 1 fire, 2 health and schools, 3 transport.
City.bond(e, take)Issue a $10,000 bond (true) or repay one (false).
City.disasters(e, on) / City.trigger(e, kind)Disasters on or off; set one off: fire, flood, tornado, meltdown.
City.autopilot(e, on)Let the bot run the city.
City.assist(e, on)Let the bot only plan: its next action appears in city.plan.
City.daylight(e, on) / City.clock(e, hour) / City.rename(e, name)The day and night, and the hour it shows (0..24); the city’s name.

A command returns nothing, so everything is published in the custom bag city on the same entity (Entity.get_custom(e, "city", key)), each frame:

KeyHolds
activeWhether a city is running.
hudname, title, date, month, funds, net (last month), pop, speed, demand (R, C, I in -1..1), power and water (use, capacity), congestion, pollution, crime, land_value, the scenario’s goal, deadline and progress, burning, tornado, and result ({ won, text } once the game is decided).
mapw, h, tile (world units) and x, y: the map’s top-left corner in the world.
homeThe tile to point the camera at first.
toolThe tool’s key and name, dragging, and what the drag under the pointer would cost - or why it cannot be done.
hoverThe tile under the pointer: what, land_value, pollution, crime, traffic, powered, watered, police, fire, commute, zone, level.
budgetEvery ten frames: tax, funding (percent), bonds, and ledgers for the year at these rates, last_year and month.
toolsEvery tool’s key, name, cost, about, unlocked and the milestone that unlocks it.
stats, historyTotals for tests and graphs; population and funds by month.
eventsThis frame’s news: { kind, text, x, y } (fires, milestones, budgets, advice). Read it every frame.
messageThe last command’s answer, { text, ok }.
planWith assist on, the bot’s next action: { kind = "tool", tool, x0, y0, x1, y1 }, { kind = "tax", zone, rate } or { kind = "bond", take }.
focus, night, overlay, saveWhere the bot last built; how dark it is (0..1); the overlay shown; a save asked for with City.save.

The minimap is a texture the package writes, __city/minimap (two pixels a tile, the current overlay’s colours, the view outlined): show it with a .gui Image whose texture_id is set to that name once the city runs.

cargo test -p jengine-city holds the rules: tools and their prices, saving a city whole, power through lines and zones and a grid browning out, water from a powered pump reaching two tiles from a pipe, commutes too long to work, commuters jamming a road and rail taking them off it, pollution lowering land value, police (and their funding) cutting crime, a powered town growing, no power meaning no growth, high taxes stalling it, no water keeping lots low, fire spreading and a fire station stopping it, tornado, flood and meltdown, both lose conditions, the grown scenarios starting jammed and ruined, a session played from requests - and the_bot_builds_a_town, the bot passing 5,000 people within five years on three different maps without going broke.

cargo test -p jengine-core --test strategy_classics city plays the real scene through Brickhaven’s own Lua: a road and zones laid with the mouse, the budget window used, pause holding the calendar, a saved city written to the save slot, a Founding left alone running out of time, and the Founding won end to end - the bot planning, the script turning each plan into a click, and the test making the click.