emberline.ai — the opponent
Registers Bus.services.ai. One entry point:
local brain = Bus.services.ai.brain(fighterDef, difficulty)
local input = brain.think(selfRuntime, opponentRuntime, Bus.state)
think is called once per fixed 60 Hz fight frame for the CPU side and
returns one frame of raw input — the same table a keyboard would produce.
The brain never moves anybody, never reaches into the fight, and never reads
a value it could not have seen.
It plays with its hands
A special is not a request. The brain spends six frames writing 2 3 6 onto
the stick and presses a button on the last one, so the fight's own motion
parser is what turns it into a fireball — and the brain can be hit out of the
motion exactly like a player. Charge moves only come out if the brain has
actually been holding back for Rules.CHARGE_FRAMES, which it does while it
is spacing and blocking, so the charge is earned rather than granted.
Reaction, not clairvoyance
Every reaction reads a snapshot of the opponent taken tier.reaction frames
ago. That one number is what difficulty is made of: a novice is a third of a
second behind and its anti-airs are late; the Warden is five frames behind,
which is a very good human. Nothing in here can see the present, so no tier
is ever psychic — the hard tiers are hard because they are fast and correct,
not because they cheat.
The frame it returns
| key | meaning |
|---|---|
forward back up down | the stick, relative to self's facing, held while true |
numpad | "1"–"9", the same stick in one field, "5" neutral |
light medium heavy throw | button presses — true only on the press frame |
intent | what the brain is doing, for HUD and debug |
move | the Rules.MOVE_SLOTS id it is going for, or nil |
wantBlock / blockHeight | advisory: "standing" or "crouching" |
facing | the facing the brain resolved, for a consumer that wants it |
left and right are always false, deliberately. A controller that accepts
both resolves forward/back against its own idea of facing and then ORs
the absolute keys on top; the one frame the two disagree — a crossup, a round
reset — left and right are both held, they cancel, and the fighter stands
still with no error anywhere. One authority for the stick removes that whole
class of bug. Derive absolute keys from your own facing; it is the only facing
that can be right.
A fight that blocks by holding away needs nothing but back. A fight that
prefers explicit commands can read move and wantBlock and ignore the
stick. Both work.
What it reads off a fighter runtime
The combat slice owns the runtime shape, so every read is tolerant and every
missing field degrades to a safe default rather than raising. Position is
found at .x / .pos.x / .position.x / .position[1]; facing at
.facing / .dir / .face, as a number or "left"/"right", or from the
two x values; the current move at .move (a Rules.MoveDef), or .moveId /
.moveSlot, or .clipName — a fighter driving its sprite sheet by clip
is still naming its move, because Rules.CLIPS and Rules.MOVE_SLOTS share
their attack names. That last one is what everything reactive hangs off: with
no resolvable move there is no onBlock to punish, no hit level to block at
the right height, and no slot to remember.
Frame data comes from the FighterDef the roster registers. FALLBACK in
this module is consulted only for a slot a def has not filled, so the
brain still plays while the roster is mid-write.
What it does
- Spacing, not a straight line. It works two ranges at once — the edge of what it can hit with and the edge of what the opponent can hit with — and dips inside its own to threaten, then backs out past theirs to be safe. A short-armed fighter has to earn its way in; a long-armed one sits outside and pokes.
- Whiff-punish. A recovering normal in front of it gets hit, with the strongest move whose startup fits the window it can see.
- Blocked-heavy punish. While in blockstun it reads the move's
onBlock, picks the punish then, and fires the frame blockstun ends. If it blocked from a hair too far out it walks in first — the recovery is still running and walking is faster than hoping. - Anti-air. It reacts to a jump it has seen, predicts where the jumper will be from two snapshots of horizontal speed plus gravity, and meets them with the rising special. When it is not confident it backs off and blocks high instead of throwing out a losing uppercut.
- Habit reading. It counts each use of a move at each range bucket. Twice is a habit, and a habit may be blocked before the move comes out — the only way a 7-frame low is ever blocked.
- Throwing a turtle. A block is answered with a throw, from inside the throw's real range, walking in on purpose when it is outside. From tier 3 it sometimes takes the overhead door instead.
- Meter. Spent on a confirm, on a desperate life bar, or as a read.
- Not mashing. Every commitment sets a lockout of the move's own length plus a per-tier gap, so attack density stays under ~1.7/second at every tier. Low tiers additionally drop inputs and hesitate on purpose.
Difficulty
Bus.state.difficulty (1–4) is read every frame, and the state handed to
think wins over the global, so the ladder can raise difficulty mid-session
and two brains at two tiers can share one process. Ai.TIERS is the table.
The sound it owns
The taunt. Combat fires a fighter's voice on its moves; a taunt is not a move,
so nothing else is firing here. When the player has backed out past 3.4 units
and refused to engage for 70 frames, the opponent says something about it:
voice.fighter(id, "taunt"), a crowd_gasp, and a ai.taunt event carrying
{ fighter, x, y, difficulty }. Every call goes through Bus.call, so a
slice that has not landed is a silent no-op.
Proving it
require("emberline.ai").selftestAveraged(5) runs the brain against a small
fight simulation built into this module — real frame data, a real motion
parser, Rules.BLOCK_TABLE, real hitstun and blockstun, a travelling
projectile — and reports the four behaviours this slice is judged on. One run
of a fight is a sample, not a number, so claims should come off the averaged
form. Typical:
| tier | anti-air | blocked heavy → punish | throws that land | repeated low blocked | atk/s |
|---|---|---|---|---|---|
| 1 novice | 12% | 37% | 91% | 0% | 0.68 |
| 2 rival | 30% | 81% | 96% | 2% | 0.90 |
| 3 veteran | 82% | 90% | 79% | 14% | 0.98 |
| 4 warden | 86% | 88% | 90% | 83% | 0.67 |
Tier 4 beats tier 1 in every round played.
Ai.selftest() returns the single-seed detail, and Ai.Sim.run(ctrl1, ctrl2, frames, opts) will run any two controllers against each other if you want to
measure something else.
Scoped to this part · feeds back into the world's score.