Skip to content

jengine-crawler

A first-person grid dungeon crawler: stepwise movement, a view assembled from depth slices, doors and levers, traps, and fights fought from the front rank.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-crawler
Version0.1.0
Entry pointjengine_crawler::CrawlerPackage
Script namespaceCrawler
Listed byprojects/arcade-classics, projects/rpg-classics

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

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

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

Scene keyRust type
crawl_gameCrawlGamepackage 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
Crawler.new_game(e, seed)crawl_new_game
Crawler.walk(e, dx, dy)crawl_walk
Crawler.turn(e, steps)crawl_turn
Crawler.use(e)crawl_use
Crawler.attack(e)crawl_attack
Crawler.wait(e)crawl_wait
Crawler.rest(e)crawl_rest
Crawler.resolve(e, won)crawl_resolve
Crawler.reveal(e, radius)crawl_reveal
Crawler.load(e, json)crawl_load
  • projects/arcade-classics/assets/scenes/warren.scene
  • projects/rpg-classics/assets/scenes/crawl.scene

From packages/jengine-crawler/README.md.

A first-person grid dungeon crawler: stepwise movement, doors and levers, things in the dark, and a view built out of depth slices.

Used by projects/arcade-classics (Umber Warren), packages/jengine-goldbox (Gold Box), and projects/rpg-classics (Seven Stairs, a Wizardry: a party the game keeps itself, fought in menus, through three floors of stairs both ways).

ModuleWhat it is
viewThe picture, as a list of quads. Pure geometry, no engine.
mapLevels as ASCII drawings in a data table, and what each cell does.
crawlThe crawl: movement, doors, chests, pits, stairs, and whatever lives down there.

So it does what Eye of the Beholder and Dungeon Master did: the view is a fixed set of depth slices. Every cell the party can see occupies a known rectangle on screen, decided by how far ahead it is and how far to the side, and the picture is those rectangles drawn far to near.

let quads = view::build(&dungeon, x, y, facing, &frame);

Each quad carries its face (front wall, left wall, right wall, floor, ceiling, or a thing in the cell), its rect, a draw layer within its depth, and a shade that falls off with distance — which is the whole of the lighting and most of what makes a corridor read as deep. A game places a sprite per quad and is done.

The geometry follows one convention, and everything else falls out of it:

  • Planes, not cells. Plane p is the cell boundary p + ½ cells ahead; cell d runs from plane d − 1 to plane d, and the party’s own cell from plane −1 (the edge of the picture) to plane 0. The frame’s cell_w × cell_h is plane 0 — the wall directly in front — and each plane back is falloff smaller. So the wall closing off a cell is drawn on the plane where that cell’s floor stops, and the two meet.
  • Every corner is projected on its own plane. A boundary b cells to the side sits at b * width(near) on the near plane and b * width(far) on the far one. That difference is what makes an off-centre wall lean toward the middle of the screen; computed from one plane, the outermost wall of a room leaned the wrong way.
  • Every quad carries its corners, a perspective weight q per corner (1 on the near plane, far / near on the far one) and a shade per corner, and the package draws them as Mesh2D entities from a pool it owns (draw): the texture is mapped to the trapezoid in perspective by the renderer, and nothing crosses the script bridge. The x, y, w, h box is kept beside the corners for anything that still places sprites. Until the engine could draw a textured polygon the box was all there was, and the side-wall art carried the wedge in its transparent corners - which tied the art’s taper to falloff and is gone.
  • Faces belong to the open cell that sees them. Every cell in view emits its floor and ceiling, a side wall where a solid cell stands on the side that faces the party, the wall ahead if the next cell is solid, and whatever stands in it. A solid cell emits nothing of its own, which is what keeps a wall beside the party from being drawn face-on and filling the screen with brick — and it is why a wall across a room is drawn all the way across, rather than only in the middle.
  • What is in view is what a ray can reach: forward and outward through open cells, never back toward the centre. A pillar hides the cells behind it and not the ones beside those; and a cell reached that is wholly hidden is painted over by the nearer face, because every solid cell beside a cell in view has its face drawn.
  • Within a depth, the order is the layer: floor and ceiling, side walls, the wall ahead, things, monsters. Depth alone is not an order — a chest and the floor it stands on share one.

A chest stands on the floor half-way through its cell, as wide as it is tall; a pit and the stairs lie in the floor and take its shape. A door that has been opened keeps its frame (arch), drawn on the plane the door stood on and over whatever is in the doorway. The party’s own cell flares out past plane 0, so a game crops the picture to plane −1 (cell / falloff) with a frame, the way the genre’s window did.

The picture is drawn by the package: crawl_game.sheet names the art per face and per thing (the defaults are Umber Warren’s regions), view_origin is where the corridor’s middle is on screen, and each quad is a Mesh2D at z_base + its index, unlit (the shade is the lighting) and marked BulkEntity. "draw": false turns that off for a game that reads CrawlView.quads and places its own sprites.

Because none of this touches the engine, the part that is impossible to check by eye — and very easy to get subtly wrong — is a unit test rather than a screenshot. cargo run -p jengine-crawler --example picture prints the quads for a diagnostic dungeon, and projects/arcade-classics/tools/render_crawler_picture.py draws them, in debug colours and through the arcade’s sheet.

A step, a turn, a swing or a wait is one tick, and the dungeon takes its turn after each one. A player can stand in a doorway and think for an hour, and the thing two cells away is still two cells away when they stop. That is the difference between this and a real-time crawler, and it is why a fight down here can be tested at all.

Monsters wake when the party comes within their sight and in their line of sight - Dungeon::line_of_sight, which is gameplay sight and not the picture’s looser rule - and having woken they do not forget — which is what makes walking away a decision rather than a reset.

The front two fight; the back two need the reach for it. A spear in the third slot reaches past the front rank, a short sword does not, and that is the whole of the party’s shape:

{ "name": "Vess", "reach": 3 }
######### . floor # wall + a shut door
#..C..M.# = locked A archway L a lever
#.#####.# > stairs C a chest O a pit
#.#...#.# M where a monster starts
#<..^...# < stairs up ^ a trap (floor until it goes off)

The tables are checked when they load: a level with no way down, a party that starts inside a wall and a monster nobody defined all fail loudly at startup instead of fifty steps in - and so do stairs up behind a key, a fight on a cell nobody can reach, and random fights with no groups to draw from.

Floors that remember, and stairs both ways

Section titled “Floors that remember, and stairs both ways”

> goes down and comes out of the next floor’s < (its start if it has none); < goes back up and comes out of that floor’s >, facing the way on rather than the wall behind the stair. From the top floor < leads out: the package says exit and leaves the party on the stair, and the game decides what is outside (a town). A floor the party comes back to is the floor it left - chests emptied, doors open, traps sprung, fights won - and the automap remembers it, in the save too (Save::seen, a string per floor).

A game with its own combat (menus, a party of six) does not want the package’s monsters swinging at it. It gets encounters instead, from the level’s row:

"encounters": [{ "at": [6, 11], "group": "chief", "text": "A KOBOLD CHIEF guards the stair!",
"art": "m_chief" }],
"encounter_rate": 0.11, "groups": ["rats", "kobolds"], "encounter_grace": 4

A fixed encounter waits on its cell (and stands in the picture, drawn with its art, so it can be seen down the corridor); stepping on it starts it. A random one comes with encounter_rate per step onto open floor, after encounter_grace quiet steps, its group drawn from groups - names the game defines. Either way the package says encounter, publishes it as CrawlState.encounter, and nothing moves until the game settles it with Crawler.resolve(e, won): won, a fixed one is gone for good; run from, the party is put back on the cell it stepped from. A fixed encounter with "goal": true on the last floor is that floor’s way out - it needs no stairs down.

^ is a trap: floor to the picture, the automap and ahead, until the party steps on it; then it goes off once, the save remembers, and the package says trap with what the level’s traps list calls it (cycled in the order the traps appear). What it does is the game’s.

A chest’s loot can be anything: gold:, key: and heal: are the package’s, and any other kind:value (chest:2, item:healing potion) is announced (loot with what and value) for the game to hand out.

A game that keeps its own party leaves tables.crawlers empty: the crawl then has nobody to hurt and is never lost by the package. tables.monsters may be empty too.

crawl_game on one entity, in a "version": 2 scene. The frame decides how the view is laid out, so a game can widen it without the package knowing anything about the screen.

"crawl_game": {
"tables": { "levels": "data/crawl_levels", "monsters": "data/crawl_monsters",
"crawlers": "data/crawl_party" },
"frame": { "cell_w": 470.0, "cell_h": 400.0, "falloff": 0.56, "gloom": 0.8 },
"seed": 11
}

"publish_view": false stops CrawlView.quads going out every frame - a game the package draws for does not need the picture as JSON, and it is the largest thing a script would otherwise be handed.

CallDoes
Crawler.new_game(e, seed)back to the top of the dungeon (a new seed if given)
Crawler.walk(e, dx, dy)a step: dy forward and back, dx sideways
Crawler.turn(e, steps)turn right by that many quarters
Crawler.use(e)use what is in front, or the stairs underfoot
Crawler.attack(e)the party swings at what is in front
Crawler.wait(e)stand still and let the dungeon come
Crawler.rest(e)heal a little, at the cost of four ticks
Crawler.load(e, json)restore a save
Crawler.resolve(e, won)settle the encounter in progress (see above)
Crawler.reveal(e, radius)show the party the floor within radius cells (0: all of it)
  • CrawlView.quads every frame: the whole picture, back to front, with the monsters folded in so one ordered list draws correctly.
  • CrawlState: where the party is, its health, gold and keys, what is in front of it, whether it is standing on the stairs (on_stairs, on_stairs_up), the encounter in progress (encounter), the number of levels — so a game can label its own buttons rather than working it out.
  • CrawlMap when something new is seen: the level with everything unseen blanked out, for an automap; a fixed encounter not yet won shows as E, and an unsprung trap as floor.
  • CrawlEvents.events: level, door, unlocked, locked, lever, loot, empty, pit, hit, slain, bitten, wait, rested, dead, escaped, exit, encounter, resolved, trap, revealed.