Skip to content

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.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-horde
Version0.1.0
Entry pointjengine_horde::HordePackage
Script namespaceHorde
Listed byprojects/arcade-classics, projects/shooter-classics

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.

Scene keyRust type
horde_gameHordeGamepackage only: needs a v2 scene
horde_huntHuntGamepackage 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
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
  • projects/arcade-classics/assets/scenes/vaults.scene
  • projects/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).

ModuleWhat it is
mazeThe floor, and the flow fields the horde follows.
contentThe four, what comes out of the walls, and the floors — as data tables. A floor is an ASCII drawing.
hordeOne tick of all of it, at a fixed 60 Hz.
huntChasers 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.rs asserts exactly that — a cache that rebuilt per frame would look identical and cost a hundred times more;
  • nearest is 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.

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.

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.

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 sight when the player is lit - torch on, a muzzle flash, standing in a flare’s light - and only dark_sight when they are dark. Turning your torch off is hiding.
  • Noise spreads by path: a gunshot of loud units is heard by a hunter within that walking distance (times its hearing), 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 shy hunter 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 the lights - and is held still while looked at.
  • A spitter keeps keep_away from you and spits (spit, spit_every, spit_speed, spit_range) when it sees you; a brute charges within charge_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.

CallDoes
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).

Terminal window
cargo test -p jengine-horde

44 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.