Skip to content

jengine-gunner

A top-down run-and-gun: a battlefield that scrolls under you, aim decoupled from movement, grenades that arc over cover, and prisoners worth going back for.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-gunner
Version0.1.0
Entry pointjengine_gunner::GunnerPackage
Script namespaceGunner
Listed byprojects/arcade-classics, projects/shooter-classics

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

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

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

Scene keyRust type
gun_warGunWarpackage only: needs a v2 scene
gun_skirmishGunSkirmishpackage only: needs a v2 scene
gun_arenaGunArenapackage 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
Gunner.gun_new_game(e)gun_new_game
Gunner.gun_who(e, id)gun_who
Gunner.gun_next_map(e)gun_next_map
Gunner.gun_input(e, dx, dy, fire, grenade, lock)gun_input
Gunner.skirmish_new_game(e)skirmish_new_game
Gunner.skirmish_carry(e, score, lives)skirmish_carry
Gunner.skirmish_stage(e, stage)skirmish_stage
Gunner.skirmish_revive(e)skirmish_revive
Gunner.skirmish_reached_end(e)skirmish_reached_end
Gunner.skirmish_aim(e, dx, dy, fire, lock)skirmish_aim
Gunner.arena_setup(e, setup)arena_setup
Gunner.arena_clear(e)arena_clear
Gunner.arena_input(e, seat, mx, my, ax, ay, fire, select)arena_input
  • projects/arcade-classics/assets/scenes/delta.scene
  • projects/arcade-classics/assets/scenes/rain1.scene
  • projects/arcade-classics/assets/scenes/rain2.scene
  • projects/arcade-classics/assets/scenes/rain3.scene
  • projects/shooter-classics/assets/scenes/arena.scene

From packages/jengine-gunner/README.md.

A top-down run-and-gun: a front that creeps north whether you do or not, aim that comes apart from movement, and grenades that go where bullets cannot.

Used by three games: projects/arcade-classics’ Brass Delta, seen from above, and Steel Rain, seen from the side - and projects/shooter-classics’ Frag Arena, a top-down Quake III deathmatch (the deathmatch module, which the online room projects/shooter-classics/server/games/arena runs too).

ModuleWhat it is
armsAiming and weapons, in no particular view. Shared by both games.
fieldBrass Delta’s ground, and the view that crawls up it.
warBrass Delta’s tick, at a fixed 60 Hz.
skirmishSteel Rain’s fight: what jengine-platformer does not do.
sideThe gun_skirmish component, which reads a platformer hero and never writes it.
contentBoth games’ data tables. A map is an ASCII drawing.
deathmatchFrag Arena: the gun_arena component - a deathmatch for up to eight at a fixed 60 Hz, weapons and items on timers, pads, teleporters, and bots at three levels.

arms exists because the two games share almost nothing about the world - one has water and huts, the other gravity and ledges - and share exactly the rules about pointing and shooting. So those live there once:

  • where you are pointing is not where you are walking, and a lock holds it while you move;
  • a weapon has a rate, and pulling faster than the rate does nothing;
  • a spread is several shots laid out evenly about one direction.

When the top-down game was refactored onto it, its 27 tests passed unchanged - which is the only evidence worth having that an extraction is real rather than a second copy with the same comments.

The side-on game is two packages over one hero

Section titled “The side-on game is two packages over one hero”

Steel Rain has no package of its own. It is:

  • jengine-platformer, unmodified, deciding where the hero is - the running, the jump, the coyote time, the slopes, the ledges;
  • gun_skirmish here, deciding whether the hero is alive - the aim, the weapons, the pods, everyone shooting back, the boss.

The arrangement holds because of one restraint: this package reads the hero’s Transform by name each tick and never writes it. There is no &mut Transform anywhere in side.rs, and the_skirmish_reads_the_hero_and_never_moves_it fails if one appears. It also reads PlatformerState.facing rather than deciding which way the hero faces, so the sprite, the aim and the platformer all quote one number.

You face where you walk — until you hold the lock, and then you keep facing where you were while you move. That single modifier is the difference between Commando and Ikari Warriors, and it is what lets a player back out of a corridor still shooting what is in it.

if self.commando.walking && !input.lock {
self.commando.ax = dx / len;
self.commando.ay = dy / len;
}

The game publishes aim_x / aim_y separately from the position for one reason: the sprite has to be rotated by the aim, not by the walk. Do it the other way and the picture contradicts the rules every time the lock is used, which is worse than drawing no direction at all.

Rule two: a grenade goes where a bullet cannot

Section titled “Rule two: a grenade goes where a bullet cannot”
walk throughbullet throughgrenade clears
openyesyes—
covernonoyes
wallnonono
waternoyesno
hutnonoyes

Take the cover row away and there is nothing to be pinned behind and no reason to throw anything. A grenade is in the air the whole way: it consults nothing it passes over, lands, and clears the cover under it — which is also how a hut comes off a prisoner.

tests/rules.rs pins the whole grid, and both halves of the pair: the bullet that dies on the sandbags and the grenade that takes them away.

It creeps north on its own, hurries when the player pushes into the top of the view, and never retreats. That last one is not a nicety: an enemy that falls off the bottom is dropped, and a view that went back would bring it with it. Enemies wake as the front reaches them, so a map does not send its whole garrison at you from the first second.

gun_commandos, gun_enemies, gun_maps. The maps in Brass Delta are built from eight-row bands — a band is one tactical idea (a sandbag line, a crossing, a camp, a redoubt) and a map is a sequence of them getting harder.

Content::load refuses a map with nowhere to start, a letter the enemies list does not explain (it would quietly become open ground), and a prisoner standing outside a hut — freeing one means blowing the hut off them, so a prisoner in the open is one nobody can ever rescue.

The deathmatch: rule one, taken to its end

Section titled “The deathmatch: rule one, taken to its end”

Frag Arena is a twin-stick: the aim is a stick of its own, which is rule one of arms - where you point is not where you walk - with nothing left of the walk in it. Every gun is an arms::Trigger, so the rate that makes a railgun a railgun and the spread laid evenly about the aim that makes a shotgun aimable are the same lines Brass Delta shoots with.

What is new is the timers. A mega health back every thirty-five seconds and a quad damage every minute are not scenery: who is standing on them when they come back wins. So the top bot level times them - it goes for an item that is not there yet because it will be by the time it arrives - and the levels differ only in a data row (reaction, aim wander, turn rate, lead, sidestep, timing), ranked by the_levels_rank_rookie_veteran_nightmare (rookie 36, veteran 111, nightmare 240 frags over fifteen free-for-alls).

stops feetstops shots
# wallyesyes
: railyesnoshoot over it, walk round it
~ pita fallnoa death; your frag, or whoever shoved you in

1..9 are the ends of a jump pad ("pad 1 2": through the air, over walls and pits - rockets and splash pass beneath you, a hitscan gun does not) or a teleporter ("tele 3 4", which telefrags whoever stands at the far end). A link’s start is a ride, not a floor: stepping on it takes it, so the bots’ distance fields reach a link’s start only through its far end, and a map whose landing cell is another link’s start is refused rather than half-walkable.

"gun_arena": { "tables": { "weapons": "data/arena_weapons", "items": "data/arena_items",
"levels": "data/arena_levels", "maps": "data/arena_maps" }, "speed": 1.0 }
CallDoes
arena_setup(e, setup)a new match: { map = "forge" or 0.., fighters = { { name, human, level } }, frag_limit, time_limit, seed, advise, speed } - advise (a bot level) publishes what that bot would do in each person’s seat (an autopilot); speed fast-forwards (tests)
arena_input(e, seat, mx, my, ax, ay, fire, select)a person’s sticks (y down the map), trigger, and gun (select a slot, -1 previous, -2 next)
arena_clear(e)no match
  • ArenaMap (once a match): map, id, name, seed, width, height, items ([item, x, y]), links ([kind, x0, y0, x1, y1]), names, humans, levels, frag_limit, time_limit.
  • ArenaState (every frame): Match::view - phase (warmup, fight, over), seed, clock, left, countdown, sudden, winner, f (a row a fighter: [x, y, aim_x, aim_y, hp, armor, weapon, ammo, frags, deaths, flags, air, respawn, quad, vx, vy, beam, owned], flags 1 alive 2 shielded 4 quad 8 just fired 16 hurt 32 a person’s), s (shots [weapon, x, y, vx, vy]), items (a 1/0 a spot) and, with advise, advice ([mx, my, ax, ay, fire, select] a seat).
  • ArenaEvents.events (every frame, replaced): fire, tracer, rail, boom, hit, frag, pickup, item, pad, land, tele, spawn, fall, fight, lead, sudden, quad_off, over - everything since the last frame. Read it every frame.

The view is the same shape the online room sends, so one Lua draws both. Positions are in tiles, y down the map; a game draws them however it likes. The simulation is deterministic (its own random numbers, a fixed step, no clocks, no hash maps): the_same_seed_plays_the_same_match.

Terminal window
cargo test -p jengine-gunner

27 tests of the run-and-gun, 22 of the side-on game and 21 of the deathmatch. The two that matter most for the first are the pair that reads like one sentence: cover_stops_a_bullet and a_grenade_clears_the_cover_a_bullet_could_not; for the deathmatch, the_top_bots_time_the_mega_and_are_on_it_when_it_comes_back.