Skip to content

jengine-brawler

A beat-em-up: a floor where y is depth, attacks that must line up in it, grabs and throws, weapons, and a crowd that surrounds rather than queueing.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-brawler
Version0.1.0
Entry pointjengine_brawler::BrawlerPackage
Script namespaceBrawler
Listed byprojects/arcade-classics

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

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

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

Scene keyRust type
brawl_gameBrawlGamepackage 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
Brawler.new_game(e)brawl_new_game
Brawler.hero(e, id)brawl_hero
Brawler.next_stage(e)brawl_next_stage
Brawler.input(e, dx, dy, attack, jump, special)brawl_input
Brawler.pressure(e, attackers, cooldown)brawl_pressure
  • projects/arcade-classics/assets/scenes/mile.scene

From packages/jengine-brawler/README.md.

A beat-em-up: a street with a walkable floor, attacks that have to line up in it, grabs and throws, weapons off the ground, and a crowd that surrounds you instead of queueing.

Used by projects/arcade-classics (Bitter Mile).

ModuleWhat it is
floorThe walkable band, and the camera that locks for a wave and never scrolls back.
actorEveryone on the floor, and the hit rule — the one that makes this a beat-em-up.
crowdHow six enemies share one player: surround, and take turns.
simThe brawl, one 60 Hz tick at a time.
contentThe cast, the frame data, the streets and their waves, as data tables.

A brawler’s world is a band: wide along x, shallow in y, and y is both where an actor is drawn and how far into the street they are standing.

That is the genre. It means an attack can be well within reach along the street and still miss, because the two of you are on different lines:

pub fn connects(a: &Actor, b: &Actor, mv: &MoveDef) -> bool {
// in front, within reach...
// ...lined up in depth, within the move's tolerance...
// ...and at a similar height, so a jump is a real evasion.
}

Make depth large and the floor stops mattering — the game becomes a very wide fighting game where every punch hits everybody. Make it small and nothing ever connects. It is the single most sensitive number here, which is why tests/depth.rs pins it and its neighbours rather than leaving them to be tuned by eye.

It also means this package cannot draw itself correctly on its own. Whoever stands lower on the band is nearer the camera and must be drawn in front — and draw order belongs to the renderer, not to a package. So the game sets y_sort on the actor prefab (SpriteComponent::y_sort), and the engine sorts the crowd by position every frame. The engine gained that flag for this game; before it, the only order available was the authored z_order, and two actors on one floor drew in whatever order the ECS returned them in.

Six enemies that all walk in and all swing is unplayable, and worse, boring: there is no rhythm to learn. Golden Axe and Double Dragon both do the same thing instead, and crowd::plan is that, as a pure function of positions:

  • everyone claims a slot around the player — both sides first, so two enemies pin you between them, then the diagonals, and never one directly on your own line, because an enemy you cannot walk around is a corner you cannot get out of;
  • only attackers of them may commit at once. The committed walk at you; the rest hover at their slot and wait;
  • a swing costs a cooldown, so the attacker drops back and the turn passes to someone else.

attackers is the difficulty dial that matters — far more than any enemy’s health or damage. One is a tutorial, two is the arcade, four is a wall.

It is deterministic down to the tie-breaks, because it runs every frame: anything unstable in here shows up on screen as the whole crowd changing its mind at once.

The camera has more rules than it looks like it needs

Section titled “The camera has more rules than it looks like it needs”
  • It locks while a wave that says lock is standing, and the player is pinned inside the view, so the fight cannot be walked away from.
  • It never scrolls back. Walking left must not pull the street backwards, or an enemy behind the player would leave the screen and stop being fightable.
  • It clamps to the street every frame, including right after the view width changes — the runtime writes half_width from the component each frame, and the limits move with it.

Everything is rows: brawl_heroes, brawl_enemies, brawl_moves, brawl_stages, brawl_waves, brawl_pickups. A move is frame data (startup / active / recovery) plus what it does on contact, and a string of them is a combo — each move naming the one it chains into.

Content::load refuses content that would leave the game unplayable in a way no screenshot explains: a wave naming an enemy that does not exist, a wave past the end of its own street (it would simply never arrive), a move that chains into itself, a floor with no depth, a street with no waves. Those are the mistakes that otherwise read as bugs in the wave system or as a balance problem.

A brawl_game component on one entity in a v2 scene, and a script that feeds it input and draws what it publishes:

Brawler.input(game, dx, dy, attack, jump, special)
local st = Entity.get_custom(game, "BrawlState", "phase")

It publishes BrawlState, BrawlActors (everyone, with depth for scaling a sprite by how far back it stands), BrawlPickups and BrawlEvents. See the crate docs for the full list.

Terminal window
cargo test -p jengine-brawler

56 tests. depth.rs is the hit rule and the constants around it — including one that pins a hero’s jump height against the height a hit reaches, because raising the jump is exactly the kind of tuning that would silently make jump attacks whiff at the apex. crowd.rs is the scheduler’s properties. brawl.rs plays streets from a DataRegistry built out of JSON, so the field names a project’s .data files have to use are covered too — a row that quietly deserializes to its defaults looks like a balance problem forever.