jengine-horde
A four-player arcade dungeon: hundreds of chasers following flow fields, generators that must be broken, and health that drains all the while.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-horde |
| Version | 0.1.0 |
| Entry point | jengine_horde::HordePackage |
| Script namespace | Horde |
| Listed by | projects/arcade-classics, projects/shooter-classics |
Using it
Section titled “Using it”List it in the top-level "packages" array of project.jengine:
{ "packages": [ { "name": "jengine-horde", "source": { "type": "path", "path": "../../packages/jengine-horde" } } ]}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 Horde == nil then return end.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
horde_game | HordeGame | package only: needs a v2 scene |
horde_hunt | HuntGame | 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 |
|---|---|
Horde.hunt_map(e, rows, seed) | horde_hunt_map |
Horde.hunt_cell(e, x, y, ch) | horde_hunt_cell |
Horde.hunt_spawn(e, kind, x, y) | horde_hunt_spawn |
Horde.hunt_player(e, x, y, aim, cone, range, close, lit, alive) | horde_hunt_player |
Horde.hunt_lights(e, lights) | horde_hunt_lights |
Horde.hunt_noise(e, x, y, loud) | horde_hunt_noise |
Horde.hunt_hurt(e, id, dmg, kx, ky) | horde_hunt_hurt |
Horde.hunt_clear(e) | horde_hunt_clear |
Horde.new_game(e) | horde_new_game |
Horde.join(e, slot, hero) | horde_join |
Horde.next_level(e) | horde_next_level |
Horde.input(e, slot, dx, dy, fire, potion) | horde_input |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/vaults.sceneprojects/shooter-classics/assets/scenes/blackout.scene
From packages/jengine-horde/README.md.
A four-player arcade dungeon: generators that keep making more, health that drains whatever you do, and hundreds of chasers all pathing at once.
Used by projects/arcade-classics (Salt Vaults), and - through its
hunt module - by projects/shooter-classics (Blackout).
| Module | What it is |
|---|---|
maze | The floor, and the flow fields the horde follows. |
content | The four, what comes out of the walls, and the floors — as data tables. A floor is an ASCII drawing. |
horde | One tick of all of it, at a fixed 60 Hz. |
hunt | Chasers that hear and look instead of always knowing where you are (component horde_hunt). |
The horde is a scheduling problem, not an AI one
Section titled “The horde is a scheduling problem, not an AI one”Gauntlet’s monsters were greedy: step toward the nearest player, get stuck on a wall, shuffle. That is part of how the arcade game felt, and it is also why half the horde spent the level jammed in a corner.
Here there is one Dijkstra flow field per living hero, and a monster’s whole decision is one array lookup:
if let Some(field) = fields.nearest(mx, my) { if let Some((nx, ny)) = field.step(mx, my) { /* walk toward it */ }}The economy is the point, and it is the thing worth testing:
- a field is rebuilt only when its hero crosses a tile, which for a
walking hero is a few times a second. Standing still costs nothing, and
tests/field.rsasserts exactly that — a cache that rebuilt per frame would look identical and cost a hundred times more; nearestis nearest by path, not by line of sight. A hero two tiles away through a wall is further than one six tiles down the corridor, and if the horde disagreed it would walk into the wall and stay there;- steps are straight, never diagonal. A diagonal looks better and lets a monster cut the corner of a wall — and a horde that walks through corners cannot be held in a doorway, which is most of this game’s tactics.
Everything is a clock
Section titled “Everything is a clock”drain takes health from every player every second, whatever they do. It
is why a floor cannot be waited out, why food is worth crossing a room for,
and why “shoot the food by accident” is a real loss rather than a joke. It
accumulates fractionally: taking drain * dt off an integer rounds to zero
every tick and the clock silently stops, which tests/floor.rs pins.
A generator’s cap is the other one — how many of its own may be alive at
once. It is the only thing between this and a slideshow.
Scale, and how it reaches the game
Section titled “Scale, and how it reaches the game”The crowd lives in this package: spawned, moved and killed in Rust, never across the script bridge. It is published as flat arrays, because there can be hundreds of them and a hundred little tables a frame is the part that would actually cost something:
HordeCrowd = { n: 240, x: [...], y: [...], art: [...], hurt: [...], arts: ["g_grunt", ...], tints: [[r,g,b,a], ...] }art is an index into arts, so a monster costs four numbers on the wire
rather than a string. tests/floor.rs runs three hundred of them off a
single sweep of the field.
Content
Section titled “Content”horde_heroes, horde_monsters, horde_generators, horde_items,
horde_levels. A floor is an ASCII drawing; the legend is in
content::LEGEND.
Content::load refuses a floor with no exit, nowhere to start, nothing to
break, or — the quiet one — a generator letter its generators list does
not explain, because that letter silently becomes an ordinary wall and
the floor is easier than it was drawn, forever.
Hunting: chasers that do not know where you are (horde_hunt)
Section titled “Hunting: chasers that do not know where you are (horde_hunt)”A Gauntlet horde always knows where every hero is - one field per hero,
and every monster walks down it. Right for an arcade crowd, wrong for
horror: something that knows where you are through three walls is a homing
missile, not a monster. horde_hunt is a second component (Salt Vaults’
horde_game is untouched by it) whose hunters know only what they have
seen or heard:
- Sight is a line walked over the grid (Amanatides-Woo; walls, locked
doors and shut doors block it, and two walls meeting at a corner are a
wall), as far as the kind’s
sightwhen the player is lit - torch on, a muzzle flash, standing in a flare’s light - and onlydark_sightwhen they are dark. Turning your torch off is hiding. - Noise spreads by path: a gunshot of
loudunits is heard by a hunter within that walking distance (times itshearing), read off the noise cell’s flow field - a hunter two cells away through a wall is nine cells away by foot and hears nothing. It goes to where the noise was. - A hunter that loses sight goes to where it last saw you, searches
round there for
search_time, and gives up (idle, wandering near its new home). - A
shyhunter moves only while the player cannot see it - in the torch’s cone (aim,cone,range), the glow round the player (close), or under one of thelights- and is held still while looked at. - A spitter keeps
keep_awayfrom you and spits (spit,spit_every,spit_speed,spit_range) when it sees you; a brutecharges withincharge_range.
Fields are cached by goal tile (24 kept), so every hunter chasing the
player shares one, rebuilt when the player crosses a tile, and a pack sent
to one gunshot shares another. Doors (+) open for whoever comes within a
cell and a quarter, and block sight while shut; L is locked until the
game sets the cell to +.
"horde_hunt": { "cell": 48, "origin": [-1152, -768], "walls": true, "kinds": [ { "id": "crawler", "hp": 22, "radius": 13, "walk": 46, "run": 172, "sight": 430, "dark_sight": 120, "bite": 6, "bite_every": 0.7 } ] }cell is world units a cell and origin the bottom-left corner of cell
(0, 0). With walls: true the package keeps a collider per merged run
of wall (and one per shut door, moved aside while it stands open) in the
world, so a vision, particles and anything else reading colliders see the
floor - off by default.
| Call | Does |
|---|---|
Horde.hunt_map(e, rows, seed) | lay a floor (rows top first: # v = wall, + door, L locked, else floor); clears the hunters |
Horde.hunt_cell(e, x, y, ch) | change one cell (from the bottom-left, 0-based) - unlock a door |
Horde.hunt_spawn(e, kind, x, y) | put a hunter down (ids count up from 1 per map) |
Horde.hunt_player(e, x, y, aim, cone, range, close, lit, alive) | what the player shows the world this frame |
Horde.hunt_lights(e, {{x, y, r}, ...}) | the lights the player sees by (flares) |
Horde.hunt_noise(e, x, y, loud) | a sound, loud units of walking distance far |
Horde.hunt_hurt(e, id, dmg, kx, ky) | damage a hunter, push it; it now knows where you are |
Horde.hunt_clear(e) | remove every hunter and spit |
It publishes HuntCrowd (flat arrays: n, id, kind (1-based into
kinds), x, y, face, mind (0 idle, 1 alert, 2 hunting, 3
searching), seen (the player sees it), hurt, hp, frozen),
HuntShots (n, x, y of the spit in flight), HuntState (counts by
mind, near - the walking distance to the nearest hunter, -1 for none, for
a heartbeat - and doors, a 0/1 string in the order of the door cells
by row from the bottom) and HuntEvents (spot, hear, charge - a
hunter with a charge_range starting its run at the player, once a charge,
so a game can warn of it - bite, spit, spit_hit, splat, died,
door, each { t, id, kind, x, y, v }, v the damage of a bite or spit).
cargo test -p jengine-horde44 tests (tests/hunt.rs pins each hunting rule: a noise heard by path
and not through the wall, a dark player unseen four cells off, the search
that gives up, the shy one held by a look, a shut door blocking sight, the
spitter’s distance, one field for a crowd, a charge announced once). Two bugs the older ones caught that no screenshot would have: a generator’s
interval and cap were read from hardcoded numbers rather than from its
row, so tuning the data table did nothing at all; and a generator stands on
a wall tile (it is masonry until broken), so shots died on the stonework
before the generator could be hit — they were indestructible.