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.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-brawler |
| Version | 0.1.0 |
| Entry point | jengine_brawler::BrawlerPackage |
| Script namespace | Brawler |
| Listed by | projects/arcade-classics |
Using it
Section titled “Using it”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.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
brawl_game | BrawlGame | 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 |
|---|---|
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 |
Scenes that use it
Section titled “Scenes that use it”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).
| Module | What it is |
|---|---|
floor | The walkable band, and the camera that locks for a wave and never scrolls back. |
actor | Everyone on the floor, and the hit rule — the one that makes this a beat-em-up. |
crowd | How six enemies share one player: surround, and take turns. |
sim | The brawl, one 60 Hz tick at a time. |
content | The cast, the frame data, the streets and their waves, as data tables. |
y is doing two jobs
Section titled “y is doing two jobs”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.
The crowd takes turns
Section titled “The crowd takes turns”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
attackersof 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
lockis 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_widthfrom the component each frame, and the limits move with it.
Content
Section titled “Content”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.
Using it
Section titled “Using it”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.
cargo test -p jengine-brawler56 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.