jengine-rts
A real-time strategy game: one flow field per destination, orders, fog of war, production queues and an opponent.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-rts |
| Version | 0.1.0 |
| Entry point | jengine_rts::RtsPackage |
| Script namespace | Rts |
| Listed by | projects/arcade-classics, projects/strategy-classics |
Using it
Section titled “Using it”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.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
rts_match | RtsMatch | package only: needs a v2 scene |
Script commands
Section titled “Script commands”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.
| Call | Flat 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 |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/reach.sceneprojects/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.
| Module | What it is |
|---|---|
field | The map as a grid, and flow fields over it. |
content | Units and buildings as rows in the project’s data tables. |
sim | The match: orders, movement, fighting, gathering, fog, construction and production, on a fixed 1/30 tick. |
ai | An opponent that plays through the same commands the player does. |
scenario | Maps drawn as ASCII and the missions played on them, as data rows. |
commander | The 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).
Movement is the field plus two forces
Section titled “Movement is the field plus two forces”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.
The tick is fixed
Section titled “The tick is fixed”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.
The component
Section titled “The component”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}Script commands
Section titled “Script commands”| Call | Does |
|---|---|
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.
What scripts read
Section titled “What scripts read”RtsStateevery 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.RtsMaponce: the terrain as one string a row, and the ore.RtsEvents.events:shot,died,razed,trained,queued,placed,built,delivered,denied,defeated,over.
The Command & Conquer rules (Rust Dunes)
Section titled “The Command & Conquer rules (Rust Dunes)”Every rule is a field in the tables, off by default:
| Table / field | Rule |
|---|---|
building constructs | a 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 power | made (+) 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 storage | credits a side can hold; while any building stores, harvest past it is lost (silos_needed) |
building free_unit, unload_rate | a refinery brings its harvester, and takes a load at a rate |
requires, factions, buildable, order | the 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_air | a defence; wall is never picked as a target unless ordered at |
building repairs | vehicles standing at it are mended, for credits; Rts.repair mends a building, Rts.sell sells one for half |
unit weapon, armor | damage times the weapons table’s multiplier for that pair |
unit splash, min_range, air, hits_air, hits_ground, suicide, auto_harvest | artillery, aircraft (fly over cliffs; only hits_air reaches them), saboteurs, harvesters that go back to work by themselves |
map rows | the map drawn one character a cell: . sand, , dune, # rock (the only ground to build on), ^ cliff, */@ crystal, 1-4 starts |
map worms, regrow | sandworms 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 objective | destroy (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.
The commander
Section titled “The commander”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.
More commands
Section titled “More commands”| Call | Does |
|---|---|
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.