jengine-stealth
Guards who see in cones and hear in circles, alert states that decay, and a level whose line of sight is exact because the grid is the map.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-stealth |
| Version | 0.1.0 |
| Entry point | jengine_stealth::StealthPackage |
| Script namespace | Stealth |
| 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-stealth", "source": { "type": "path", "path": "../../packages/jengine-stealth" } } ]}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 Stealth == nil then return end.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
qh_mission | Mission | package only: needs a v2 scene |
hl_floor | HlFloor | 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 |
|---|---|
Stealth.hl_start(e, floor, mask) | qh_hl_start |
Stealth.hl_input(e, mx, my, ax, ay, buttons) | qh_hl_input |
Stealth.hl_auto(e, mode) | qh_hl_auto |
Stealth.start(e, which) | qh_start |
Stealth.input(e, dx, dy, gait) | qh_input |
Stealth.noise(e, x, y, carried) | qh_noise |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/halls.sceneprojects/shooter-classics/assets/scenes/hotline.scene
From packages/jengine-stealth/README.md.
Guards who see in cones and hear in circles, alert states that decay, and line of sight walked over the grid the player is reading.
Used by projects/arcade-classics (Quiet Halls) and
projects/shooter-classics (HOTLINE, through the hotline module - see
“Hotline” below).
| Module | What it is |
|---|---|
level | The map as an ASCII drawing, and every way one can be unplayable while looking fine. |
vision | What a guard can see and hear. Pure, and the most important thing in the game to be right. |
guard | The alert machine — patrol, suspicious, hunting, searching. |
run | The hero, the noise, and the tick that joins them. |
content | Levels and guard types as data tables. |
hotline | The same eyes with guns behind them: a top-down floor where one hit kills, guards who shoot and charge, doors that swing, and an autopilot. |
Sight is a grid walk, not a raycast
Section titled “Sight is a grid walk, not a raycast”This is the game the arcade’s roadmap picked to force Physics.raycast, and
Physics.raycast does now work in all four runtimes because of it — it was the
last script function that did not (BUGS #146, test-lab scene 92). It is
deliberately not what decides whether a guard can see you.
A stealth game is a game about predicting what a guard can see. The player looks at a corner and decides whether stepping out is safe, and then has to be right. That makes determinism worth more here than physical accuracy, and rapier loses on three counts:
- it answers against merged tilemap colliders, whose seams do not line up with the tiles the player is reading;
- it answers in floating point against a body that has drifted a fraction of a unit;
- and it can answer differently on two machines.
“I thought I was hidden” is the worst sentence a stealth game can produce,
and each of those can produce it. So sight is a walk over the grid, and the
answer is the same every time, on every runtime, for everybody. What
Physics.raycast is for here is the thing the grid does not know: a dynamic
obstacle the physics world owns.
Two rules that came out of that, both chosen rather than fallen into
Section titled “Two rules that came out of that, both chosen rather than fallen into”A corner hides you completely. The walk is supercover, not Bresenham, because Bresenham steps diagonally between two walls that meet at a corner — so a guard sees through a crack the player can see is shut. Supercover visits both tiles at a corner, which means either being solid blocks the look, not only both. The permissive version is closer to real line of sight and is the wrong call: “a guard cannot see past a corner” is learnable in one level, and “unless the corner is a full right angle” is a rule nobody works out. It also errs the safe way — a guard seeing less than expected is a pleasant surprise.
Standing in cover hides you, and a guard in cover can still see out. Sight tests the target’s tile and skips the eye’s. Getting inside a crate is the fundamental verb of the genre, and a version that only blocked sight through cover would make every cover tile scenery, leaving nowhere to wait in a level built around waiting. Blinding a guard beside a crate would make cover a place guards go to stop working.
The cost is that sight is one-way: a guard in a bush can see you while you cannot see it. That is true of bushes, and the game only ever asks guard-to-player. What is symmetric is the part in between — the walls — checked over every pair of a hundred tiles, because a wall that blocked one way and not the other would be a real bug with nothing on screen to explain it.
Noticing takes time, and that is the whole feel of the game
Section titled “Noticing takes time, and that is the whole feel of the game”The obvious guard sees you and instantly knows. That guard is unplayable twice over: every mistake is already fatal by the time it is visible, and the edge of a cone becomes a cliff, so the game is about pixel positions instead of about reading a room.
So a guard fills a suspicion meter, faster the more exposed you are, and it drains when nothing is seen. Everything the player experiences as tension is that number moving: the glance that nearly caught you, the corner you reached in time, the guard who walks off still half-sure. At full exposure the player gets about a second and a half; edging along a far wall buys three times that.
exposure weights range above angle, deliberately — so the answer to being
seen is to retreat, not to sidestep.
Four states, because the player has to predict them
Section titled “Four states, because the player has to predict them”| State | Does | Leaves when |
|---|---|---|
Patrol | walks its route, glancing about at each waypoint | suspicion rises |
Suspicious | turns toward it and stands still | suspicion fills, or drains away |
Hunting | walks to where it last had you | it arrives and finds nothing |
Searching | looks around near there | a timer runs out |
Each transition is the way round it is for a reason, and the reasons are in the tests:
- Hunting does not end when you break line of sight. It ends by arriving. A guard that gave up the moment it lost sight of you would make every chase a matter of stepping behind one crate.
- Searching returns to
Patrol, not toSuspicious. A guard that could never fully calm down turns one mistake into a lost run, and restarting becomes the only recovery mechanic — which is the same as not having one. - Suspicious stands still. That stillness is the player’s window to move. A guard that advanced on every flicker would never give one.
- A noise can never make a guard certain, only suspicious. A sound says something is there, not what or exactly where — and if noise could fill the meter, a thrown distraction would be a way to get caught from another room.
- A patrolling guard glances, it does not spin. Turning full circle at every waypoint sounds thorough and makes a room unplayable: seven tiles of sight sweeping a room eight across means nowhere in it is safe for more than a few seconds, so there is nothing to plan around and the only tactic left is luck.
The alarm is a meter, not a life count
Section titled “The alarm is a meter, not a life count”Being seen once should be survivable and being seen repeatedly should not. A life count makes the first sighting free and the last one arbitrary; a meter that rises while guards hunt and falls while they do not makes recovery an activity — break line of sight, wait, and watch it come down. That waiting is the most stealth-like thing in the game and it only exists because the number is continuous.
A level is a table row, and its failures are about routes
Section titled “A level is a table row, and its failures are about routes”Almost everything that can be wrong with a stealth level is a route rather
than a tile, and none of it crashes. Each is a player walking the level again
convinced they have missed something. So Level::parse refuses:
- an exit that cannot be reached at all, even with every door open;
- an exit behind a door whose only keycard is behind the same door;
- a patrol waypoint inside a wall;
- a patrol that cannot walk its own route — both waypoints on floor, the level looks right, and the guard stops at a wall partway round, so the patrol the level appears to describe never happens;
- a guard whose route starts within four tiles of
@. A guard’snearradius ignores facing, so one placed beside the start notices the player before they have touched anything. Three levels were drawn with this mistake before it became a rule.
content::check adds the pairing rule across the two tables — every route
must name a guard that exists — plus the guard numbers that are playability
rules rather than difficulty knobs: one that notices in under a second, one
that never calms down, one that chases slower than it patrols.
And whether a level can actually be snuck through
Section titled “And whether a level can actually be snuck through”None of the above can say that. A level whose only route is watched the whole
way is not a stealth level, it is a chase, and it does not look any different
on the page. So tests/halls.rs plays each of the arcade’s levels with a
crude player: it creeps, and when noticed it heads for the nearest cover that
is not toward whatever noticed it.
Building that player found more than the levels did — the doorway nudge, the glancing patrol, cover that did not hide you, and the start-clearance rule above. The lesson is in the shape of the test rather than in any one bug: the questions “is this traversable” and “is this playable” are different questions, and only one of them can be answered by looking.
The tile that makes the design work
Section titled “The tile that makes the design work”o — cover — is the only tile that answers “can I walk here” and “can I see
through here” differently, and it is where most of the interest in a layout
comes from. A corridor of cover is a corridor you can cross while a guard is
looking straight down it.
~ — hard ground — is the other half: a wall made of noise. It is how a
level says “not through here in a hurry” without a wall.
Component ("version": 2 scenes)
Section titled “Component ("version": 2 scenes)”"qh_mission": { "tables": { "levels": "data/qh_levels", "guards": "data/qh_guards" }, "which": 0, "sheet": "atlases/halls.png", "tile": 40.0}Script commands
Section titled “Script commands”| Call | Does |
|---|---|
Stealth.start(e, which) | build a level and begin |
Stealth.input(e, dx, dy, gait) | −1..1 each; gait 0 creep, 1 walk, 2 run |
Stealth.noise(e, x, y, carried) | a noise the script made — a thrown bottle |
Published state
Section titled “Published state”QhState carries the phase, the level’s name and index, time and best time,
the alarm as a fraction, sightings, keycards, score, the hero’s position in
both world and tile space, how much noise they are making, and a
guards array with each guard’s position, angle, state, suspicion, range and
half-angle.
That array is the one thing that crosses the bridge every frame, and it has to: the cones are the interface. A stealth game where you cannot see what a guard can see is a guessing game, so the game draws a wedge per guard from those numbers. The package deliberately has no opinion about how — a scaled sprite, an arc of lines, a shader — because that is a rendering decision.
QhEvents carries start, spotted, lost, noise, card, door,
caught and escaped. noise fires every tick the hero moves, which is
sixty a second; the game rate-limits the footstep sound itself, because that
is a presentation decision too.
Hotline - the guards, with guns
Section titled “Hotline - the guards, with guns”The hotline module is the second game on these eyes (Shooter Classics’
HOTLINE, a Hotline Miami). Quiet Halls asks can a guard see me; Hotline
asks the next question - and then what. It is a separate component with its
own script commands, so Quiet Halls plays exactly as before.
| Module | What it is |
|---|---|
hotline::floor | A floor as an ASCII drawing (walls, three floors, doors, windows, furniture, guards by weapon, weapons on the floor, lamps, the way out), refusing one that cannot be played: an open edge, a door not on a line, a guard within six tiles of the start, anything unreachable. |
hotline::sim | The floor simulated at a fixed 60 Hz step, with no clock and no randomness it was not handed, so a restart resets the floor exactly. |
hotline::score | Combo, boldness, variety and speed, and a grade measured against the floor. |
hotline::pilot | An autopilot that plays through the player’s own controls. |
hotline::path | A* over the floor’s cells, and whether a straight walk is clear. |
What the guards do
Section titled “What the guards do”They see with this package’s vision - the cone, and the supercover walk
over the grid, so a corner still hides you completely - and they hear in a
circle walls do not stop. Seeing you, a guard shoots if it holds a gun
(after the floor’s reaction, which is most of the difficulty curve) and
charges if it holds anything else, winding up before the blow so a
player who stands their ground strikes first. Losing you, it walks to where
it last saw you and looks about; hearing a gunshot, it comes to look. A
guard knocked down drops its weapon and, getting up without one, goes for
the nearest weapon on the floor. Dogs are fast, bite, and die to anything.
One hit, and three softer verbs
Section titled “One hit, and three softer verbs”Anything that reaches the hero kills it (the BEAR mask takes one hit). Guards die to a blade, a bat, a pipe or a bullet; a punch, a thrown weapon or a door swung into them only knocks them down for three seconds, and a guard on the floor is finished by standing over it.
Doors swing
Section titled “Doors swing”A door is a leaf on a hinge in the middle of the wall line. Walking into it
swings it out of the way, as fast as you walked, and a leaf swinging faster
than DOOR_BASH knocks down whoever is on the other side. It swings until
it lies against the wall’s face. Sight and bullets meet the leaf where it
hangs, not the door’s cell - the first version tested the cell, and a hero
standing in a doorway shot every bullet into a door it could see past. So
what can be seen can be shot, and a door half open hides exactly the wedge
behind it.
Masks change one rule
Section titled “Masks change one rule”Mask by index: none, Tiger (punches kill), Owl (your guns are silent),
Hare (faster), Wolf (start with a knife), Bear (take one hit), Fox (doors
kill), Moth (thrown weapons kill).
The autopilot speaks keys
Section titled “The autopilot speaks keys”hotline::pilot answers with letters - W A S D move, L lock on, F
attack, E pick up or throw, C finish - which is exactly what a keyboard
can say. The attract demo feeds them back in; a hub test holds the keys they
name; and tests/hotline.rs plays every floor that way, with the frame
of latency real input adds and taps every other frame, restarting on a
death. A retry plays a little differently (it waits a moment at the start,
and may take another guard first), because the floor itself never changes.
Building it found the door leaf above, a pilot that dithered between two
guards about as far (it now keeps its target), and one that emptied a
shotgun at a gunman fourteen tiles away.
Component ("version": 2 scenes)
Section titled “Component ("version": 2 scenes)”"hl_floor": { "table": "data/hotline_floors", "sheet": "sprites/hotline/actors.png", "tile": 48.0 }The sheet is a packed .atlas (listed in the scene’s atlas_refs) with
regions hero, thug, gunman, dog, their _down / _dead poses,
w_<weapon>, door and bullet; the package draws every actor, weapon,
door and bullet from it as BulkEntity sprites. unlit: true draws them
Unlit. The floor itself is the game’s to draw (HlMap below).
Script commands (Stealth.hl_*)
Section titled “Script commands (Stealth.hl_*)”| Call | Does |
|---|---|
Stealth.hl_start(e, floor, mask) | build floor floor (from 0, in table order) with a mask and begin |
Stealth.hl_input(e, mx, my, ax, ay, buttons) | move and aim in world axes (y up); buttons 1 attack, 2 pick up / throw, 4 lock on, 8 finish |
Stealth.hl_auto(e, mode) | 0 off, 1 the autopilot plays, 2 it only publishes its advice |
The commands share the qh_ prefix with Quiet Halls’ (qh_hl_start), so
the namespace stays Stealth and Quiet Halls keeps Stealth.start.
Published state
Section titled “Published state”HlState: the phase (play, dead, clear, done), the floor, the
hero (position, facing, weapon, ammo, armour, what killed it), the score
(points, combo and how long its window has left, best combo, bold kills,
ways of killing, the end card’s total, bonuses and grade), the lock-on, the
exits, a compact actors list, and the autopilot’s advice. HlEvents:
the step’s shot, swing, kill, down, spark, pickup, throw,
bash, creak, alert, armour, dead, clear and done, in world
coordinates. HlMap, rewritten on every start: the floor’s look rows, its
size, its top-left corner, its lamps and its exit cells.