Skip to content

jengine-fighter

A fighting game: frame data as data tables, hit and hurt boxes, an input buffer with motion inputs, and a deterministic match that runs on frames.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-fighter
Version0.1.0
Entry pointjengine_fighter::FighterPackage
Script namespaceFighter
Listed byprojects/arcade-classics

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

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

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

Scene keyRust type
fighter_matchFighterMatchpackage 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
Fighter.new_match(e)fight_new_match
Fighter.pad(e, who, dir, buttons)fight_pad
Fighter.bot(e, on, skill)fight_bot
Fighter.demo(e, on)fight_demo
Fighter.speed(e, x)fight_speed
  • projects/arcade-classics/assets/scenes/dojo.scene

From packages/jengine-fighter/README.md.

A fighting game: frame data, hit and hurt boxes, an input buffer, and a match that runs on frames.

Used by projects/arcade-classics (Iron Dojo).

ModuleWhat it is
inputThe buffer. A fighting game cannot read the pad; it reads the last twenty frames of it.
movesFrame data - startup, active, recovery - as rows in the project’s data tables.
simThe match, one frame at a time at 60 Hz, deterministic.
botAn opponent that plays by distance and emits Pad frames like anyone else.

Everything here counts frames. A fighting game is an argument about frame counts - 4-frame jab, -8 on block, three frames of input buffer - and a simulation running on dt would make every one of those numbers approximate. Match::step advances exactly one frame; the caller accumulates real time and calls it as often as it owes.

Two consequences, both load-bearing:

  • The match is deterministic. The same two sequences of pad inputs give the same match, every time - which is what a replay, a training-mode playback and a rollback netcode all need, and what lets the tests assert on exact frames.
  • The order inside a frame matters. Hits are resolved on the frame the move is on, and only then does the move advance. The other order tests the next frame’s hitbox against this frame’s world, which makes every move land one frame early - a 4-frame jab that hits on frame 4 is a 3-frame jab, and every number in the table is then a lie. tests/match_rules.rs counts the frames one at a time for exactly this reason.

Both attackers are tested in the same frame, so two moves that connect together trade - deciding one first and letting it interrupt the other would make the order of the two fighters matter, and it must not.

Directions are numpad notation from the fighter’s own point of view, and the buffer mirrors as it stores, so a dragon punch is 623 in either corner:

7 8 9 up-back up up-forward
4 5 6 = back none forward
1 2 3 down-back down down-forward

A motion matches when its directions appear in order inside the window, with anything in between - which is how a real quarter-circle is recognised, because a stick passes through corners the player never meant to hit. Order still matters: 623 (dragon punch) must not come out as 236 (fireball), and tests/buffer.rs holds that line.

A button press is remembered for a few frames and spent when it fires, so a button pressed three frames before the recovery ends still comes out, and one press cannot fire two moves.

A moves row: startup, active, recovery, damage, hitstun, blockstun, pushback / block_pushback, guard (mid / low / overhead / unblockable), knockdown, crouching / airborne, the hitbox, how far the move carries the fighter (advance), what it cancels_into, and its input (the motion and button).

Frame advantage falls out of the row rather than being authored:

// blockstun, less what the attacker has left after the first active frame
jab.frame_advantage_on_block() // +3: safe, and your turn
heavy.frame_advantage_on_block() // -8: punishable by anything under 8 frames

A fighters row carries health, walk speeds, the jump arc, the standing and crouching hurtboxes, and how wide the body is for pushing.

Blocking is holding away from the opponent at the moment the hit lands - not a state you enter - and the height has to match: a low must be blocked crouching, an overhead standing. Blocking costs ground rather than health, and in the corner the pushback moves the attacker back instead, which is what makes a corner worth having.

fighter_match on one entity, in a "version": 2 scene.

"fighter_match": {
"tables": { "fighters": "data/fighters", "moves": "data/moves" },
"left": "karate", "right": "karate", "half_width": 560.0,
"rounds_to_win": 2, "round_seconds": 60, "bot": true, "bot_skill": 0.6
}
CallDoes
Fighter.new_match(e)both fighters back to their marks, rounds at zero
Fighter.pad(e, who, dir, buttons)one frame of input: numpad direction, button mask
Fighter.bot(e, on, skill)the second fighter is driven by the bot
Fighter.demo(e, on)both are, for an attract mode
Fighter.speed(e, x)run at x times speed

Buttons are a mask: 1 light punch, 2 heavy punch, 4 light kick, 8 heavy kick.

  • FightState every frame: phase, round, clock, gap, and p1 / p2 - position, facing, health, state, the move being performed and which frame of it, whether its hitbox is out, combo, rounds won.
  • FightBoxes: the hit and hurt boxes in world space, for a training mode that draws them.
  • FightMoves: the move list with its frame data, for a pause screen.
  • FightEvents.events: round_start, fight, move, hit, blocked, round_over, match_over.