Skip to content

jengine-rts

A real-time strategy game: one flow field per destination, orders, fog of war, production queues and an opponent.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-rts
Version0.1.0
Entry pointjengine_rts::RtsPackage
Script namespaceRts
Listed byprojects/arcade-classics, projects/strategy-classics

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

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

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 Rts == nil then return end.

Scene keyRust type
rts_matchRtsMatchpackage 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
Rts.new_match(e, seed)rts_new_match
Rts.select(e, x0, y0, x1, y1, add)rts_select
Rts.select_all(e, role)rts_select_all
Rts.order(e, x, y)rts_order
Rts.attack_move(e, x, y)rts_attack_move
Rts.stop(e)rts_stop
Rts.hold(e)rts_hold
Rts.build(e, kind, x, y)rts_build
Rts.train(e, building, unit)rts_train
Rts.rally(e, building, x, y)rts_rally
Rts.speed(e, x)rts_speed
Rts.spawn(e, team, kind, count)rts_spawn
Rts.auto(e, on)rts_auto
Rts.mission(e, id, seed)rts_mission
Rts.skirmish(e, map, factions, level, seed)rts_skirmish
Rts.make(e, kind)rts_make
Rts.cancel_make(e)rts_cancel_make
Rts.place(e, kind, x, y)rts_place
Rts.queue(e, unit)rts_queue
Rts.unqueue(e, unit)rts_unqueue
Rts.repair(e, building)rts_repair
Rts.sell(e, building)rts_sell
Rts.group(e, n, set)rts_group
Rts.select_kind(e, kind)rts_select_kind
Rts.reveal(e, on)rts_reveal
Rts.assist(e, on)rts_assist
Rts.bot(e, on)rts_bot
  • projects/arcade-classics/assets/scenes/reach.scene
  • projects/strategy-classics/assets/scenes/dunes.scene

From packages/jengine-rts/README.md.

A real-time strategy game: crowds that path without searching, orders that depend on what was clicked, fog, production queues and an opponent.

Used by projects/arcade-classics (Copper Reach, StarCraft-style: workers put buildings up, supply caps the army) and projects/strategy-classics (Rust Dunes, Command & Conquer-style: a construction yard, power, harvesters and refineries, a tech tree, defences, sandworms and a campaign). Both play on the same sim; everything Rust Dunes added is data that Copper Reach’s tables simply never name, so its game is untouched - tests/match_play.rs and tests/reach_scene.rs hold it, tests/dunes_rules.rs holds the new rules one by one.

ModuleWhat it is
fieldThe map as a grid, and flow fields over it.
contentUnits and buildings as rows in the project’s data tables.
simThe match: orders, movement, fighting, gathering, fog, construction and production, on a fixed 1/30 tick.
aiAn opponent that plays through the same commands the player does.
scenarioMaps drawn as ASCII and the missions played on them, as data rows.
commanderThe C&C opponent, at three levels - and, advising, the player’s autopilot.

One search of the map, however big the crowd

Section titled “One search of the map, however big the crowd”

Per-unit pathfinding is where an RTS falls over: two hundred units ordered to one place is two hundred A* searches that all compute the same route, recomputed whenever anything nudges them off it. A flow field inverts the problem. One Dijkstra runs outward from the destination across the whole map, and every cell learns which neighbour is closer to the goal. A unit’s move is then a table lookup:

let (dx, dy) = field.flow_at(tile);

The cost is one pass over the map per destination, so two hundred units cost what one does, and a unit shoved off course — which is most of them, most of the time, in a crowd — is not lost, because the field has an answer for everywhere. tests/match_play.rs measures it: sixty units ordered together produce exactly one search, and two hundred units in two armies tick in about 4 ms in a debug build with the whole battle sharing a handful of fields.

The integration pass uses a bucket queue rather than a binary heap, because costs are small integers and every relaxation only lowers a value — which makes the whole thing linear in cells. A 128×128 map is a couple of milliseconds, and that is what makes a field cheap enough to throw away and rebuild the moment a building goes up. That invalidation is the other half of the design: a cached field was computed against walls that have since moved, so Grid::version drops the whole cache when the map changes.

Two details that are easy to get wrong and obvious once seen: a diagonal step between two blocked cells is refused (a unit that slips through a wall’s corner has walked through the wall), and an order that lands inside a building is moved to the nearest open cell (otherwise the field has a goal nothing can reach, and the crowd walks into the wall and stays there).

Separation, so a crowd does not converge into one point and become a single very strong unit, and arrival, so it stops. There is no per-unit path anywhere in the package. Neighbours come from a spatial hash rebuilt each tick — with a few hundred units that is far cheaper than keeping one up to date, and it cannot go stale.

1/30 of a second, accumulated from the frame’s dt. An RTS is thresholds — is it in range, is it on the patch, did the blow land — and a variable step makes every one of them frame-rate dependent, which is how a fight comes out differently on a fast machine. It also makes a match replay exactly from its seed, which is the only way to reproduce a bug someone reports.

Fog is written into a tilemap, not published

Section titled “Fog is written into a tilemap, not published”

A 64×48 map is three thousand cells, and handing that to a script every frame as JSON would cost more than the rest of the game. The package finds the entity named by fog_entity and writes its tilemap layers directly — packages hold &mut World, so this is an ordinary component write. The terrain goes in once when the match starts; the fog goes in every tick.

What that does not have to cover is units: a sprite draws over every tilemap, so fog could never hide one — and does not need to, because an enemy in the fog is never published in the first place. A building that has been seen stays on the map once the fog closes over it, because buildings do not move and forgetting them would be stranger than remembering.

rts_match on one entity, in a "version": 2 scene. The map is generated from the seed — rock, rough ground, ore and two bases, mirrored through the centre so neither side starts on better ground.

"rts_match": {
"tables": { "units": "data/rts_units", "buildings": "data/rts_buildings" },
"width": 64, "height": 48, "tile": 32.0,
"fog_entity": "Fog", "fog_layer": "fog", "terrain_layer": "terrain",
"player_team": 0, "seed": 7
}
CallDoes
Rts.new_match(e, seed)a fresh map and two bases
Rts.select(e, x0, y0, x1, y1, add)a drag box, or a click when it has no size
Rts.select_all(e, role)every worker or fighter
Rts.order(e, x, y)the right-click: attack, gather or move, by what is there
Rts.attack_move(e, x, y)advance, fighting what turns up
Rts.stop(e) / Rts.hold(e)stop / stand and shoot
Rts.build(e, kind, x, y)put a building down and send a worker to it
Rts.train(e, building, unit)queue a unit
Rts.rally(e, building, x, y)where what it makes is sent
Rts.speed(e, x)run the match at x times speed
Rts.bot(e, on) / Rts.auto(e, on)the opponent plays / a bot plays the player’s side too
Rts.spawn(e, team, kind, count)a crowd, free and past the cap — for stress tests

Selection lives in the package rather than the game’s script because the package already holds the positions; a script doing it would ask the engine for every unit’s transform every time the mouse moved. The right-click is one command for the same reason: what it means depends on what is under it, and only the package knows.

  • RtsState every frame: both sides’ ore, supply and counts, the selection and what it can do, the ore patches, the winner — and what the pathfinder did (fields_built, fields_reused, moving), which is the number the whole design is about.
  • RtsUnits / RtsBuildings: only what the player’s team can see.
  • RtsMap once: the terrain as one string a row, and the ore.
  • RtsEvents.events: shot, died, razed, trained, queued, placed, built, delivered, denied, defeated, over.

Every rule is a field in the tables, off by default:

Table / fieldRule
building constructsa construction yard: makes structures for the build menu (Rts.make), one at a time, paid up front; a finished one is ready until placed (Rts.place)
building powermade (+) or drawn (-) once finished. Short of power, production runs at made/drawn (no slower than a quarter), defences that draw power go dark, and the radar (radar) stops drawing the minimap
building storagecredits a side can hold; while any building stores, harvest past it is lost (silos_needed)
building free_unit, unload_ratea refinery brings its harvester, and takes a load at a rate
requires, factions, buildable, orderthe tech tree and the build menu: what a side must own, which faction may, whether it is offered, where it sits
building damage/range/weapon/hits_aira defence; wall is never picked as a target unless ordered at
building repairsvehicles standing at it are mended, for credits; Rts.repair mends a building, Rts.sell sells one for half
unit weapon, armordamage times the weapons table’s multiplier for that pair
unit splash, min_range, air, hits_air, hits_ground, suicide, auto_harvestartillery, aircraft (fly over cliffs; only hits_air reaches them), saboteurs, harvesters that go back to work by themselves
map rowsthe map drawn one character a cell: . sand, , dune, # rock (the only ground to build on), ^ cliff, */@ crystal, 1-4 starts
map worms, regrowsandworms that swim the sand and swallow what drives on it (rock is safety; they sleep for the first minutes), and crystal that grows back while some is left to seed it
mission objectivedestroy (the last alliance standing; a side with no buildings is beaten), survive for seconds, harvest credits, or escort a unit kind to a cell

Placement is a rule, not a guess: Sim::can_build_at (open rock, within three tiles of the side’s own buildings, nobody standing there) is what Rts.place checks and what RtsPlace.rows publishes for the footprint preview, so a red ghost and a refused placement cannot disagree. The cursor puts a footprint round itself through commander::footprint_at.

Commander is a build order with judgement: power before what draws it, a silo before credits spill, whatever a lost harvester needs before anything else, the next structure saved for before the army - then waves that gather short of the enemy and go in together. Its levels differ in thinking speed, reserve, how far down the build order it goes, army size, when and how big its waves are, what it aims at (the nearest building; the harvesting; the economy round its guns) and whether level 3 waits for the numbers. With advise on it decides and does nothing, leaving its first wish in RtsState.advice for the game to turn into keys - which is how Rust Dunes’ tests play a mission to its end through real input. tests/dunes_missions.rs plays every shipped mission with it on the player’s side and fails one it cannot win.

CallDoes
Rts.mission(e, id, seed) / Rts.skirmish(e, map, "f1,f2,f3", level, seed)start a mission, or a skirmish (player first)
Rts.make(e, kind) / Rts.cancel_make(e) / Rts.place(e, kind, x, y)the yard’s build menu
Rts.queue(e, unit) / Rts.unqueue(e, unit)a unit at the least busy factory that makes it
Rts.repair(e, building) / Rts.sell(e, building)
Rts.group(e, n, set) / Rts.select_kind(e, kind)control groups; every unit of a kind
Rts.assist(e, on)the player’s commander advises (RtsState.advice)

RtsState adds menu (each item’s state, progress, cost and what it still needs), making, ready_kind, objective, radar, low_power and each side’s power_made/power_used/storage/harvested/faction; units carry air, cat, load and returning, buildings facing, firing, repairing, age and powered; visible worms are units of kind worm. The component’s terrain_tiles, detail_layer/detail_tiles, field_layer/field_tiles, fog_tiles and minimap_entity say which tiles the package writes where - Rust Dunes points them at dual-grid autotile terrains, and the minimap is a tilemap of its own the package fills five times a second while the side has a powered radar.