miaoda-game-brawler-core
v0.3.2
Published
Engine-agnostic beat-'em-up combat brain: an attack-string runner with startup/active/recovery frame windows and cancel-buffered combos (it says when to enable a combat2d hitbox), an accumulated-knockback hurt/knockdown state machine (light hits stagger,
Maintainers
Readme
miaoda-game-brawler-core
Engine-independent, frame-based beat-'em-up combat flow: attack timing and combos, accumulated stagger/knockdown, and shared enemy attack slots. Use it above miaoda-game-combat2d-core, which still decides whether active hitboxes connect.
pnpm add miaoda-game-brawler-coreCombo timing
const runner = new ComboRunner([jab, cross, heavy]);
if (attackPressed) runner.press();
const frame = runner.tick();
hitbox.enabled = frame.hitboxActive;
if (frame.justActivated) combat.addHitbox({attackId: frame.attackId, ...box});Each attack uses startup, active, and recovery frames. Inputs inside its cancel window advance the combo, with a configurable frame buffer. Each active window receives a unique attackId so combat hit deduplication resets correctly.
Hurt flow and crowd pacing
const hurt = new HurtState({knockdownThreshold: 40, hurtFrames: 12, downFrames: 40, getupFrames: 16});
const hit = hurt.hit(event.damageKnockback);
if (hit.knockedDown) body.push(directionX * 200, 0, 320);
hurt.tick();
const tokens = new AttackTokenPool(2);
if (tokens.tryAcquire(enemy.id)) enterAttack();
tokens.release(enemy.id, 30);
tokens.tick();Knockback accumulates and decays between hits; crossing the threshold enters knockdown, then down and get-up frames. superArmor suppresses ordinary stagger but not threshold knockdown. Attack tokens cap simultaneous enemy commitments and use frame-based cooldowns.
ComboRunner, HurtState, and AttackTokenPool expose detached snapshots of all timing state. This package does not detect hits, apply HP damage, move bodies, steer enemies, or render animations.
Coordinated belt crowd
BrawlerCrowdCoordinator owns tactical roles, slot leases, and attack eligibility. It is measured in fixed simulation ticks; the game supplies candidate world positions and validity, then applies each returned target through its steering/movement owner.
import { BrawlerCrowdCoordinator, classicBeltCrowdPreset } from 'miaoda-game-brawler-core';
const crowd = new BrawlerCrowdCoordinator(classicBeltCrowdPreset({ maxAttackers: 2 }));
const result = crowd.update({
target: { x: player.x, depth: player.y },
enemies: enemies.map((enemy) => ({ id: enemy.id, position: { x: enemy.x, depth: enemy.y } })),
slots: authoredSlots.map((slot) => ({
slotId: slot.id,
world: { x: player.x + slot.offset.x, depth: player.y + slot.offset.depth },
valid: !wallQuery.blocks(slot),
})),
});
for (const assignment of result.assignments) {
steering.setTarget(assignment.enemyId, assignment.target);
ai.setRole(assignment.enemyId, assignment.role, assignment.attackEligible);
}
for (const waiting of result.waiting) telemetry.waiting(waiting.enemyId, waiting.code);Assignments and diagnostics are detached. The coordinator does not move actors, query walls, run FSMs, or resolve attacks. Its attack eligibility is the single slot-level permission for this coordinated crowd; do not combine it with an unrelated second attack-slot authority. Use HurtState for simple stagger/knockdown, and the advanced reaction resolver when airborne or bounded-juggle rules are needed.
Use crowd.inspect() for detached runtime telemetry (assignments plus the latest valid/occupied/unassigned diagnostics). Use crowd.snapshot only for persistence; diagnostics are intentionally excluded from save data.
Atomic grapples and throws
GrappleCoordinator owns the only authoritative holder/victim relationship. Actors remain free of writable isGrabbing flags; the host presents returned phases and applies the detached throw outcome to movement and combat systems.
import { GrappleCoordinator, classicBeltGrapplePreset } from 'miaoda-game-brawler-core';
const grapples = new GrappleCoordinator();
const move = classicBeltGrapplePreset();
const begin = grapples.tryBegin({
holder: { id: player.id, position: player.ground, grabForce: player.grabForce },
victim: { id: enemy.id, position: enemy.ground, canBeGrabbed: !enemy.invulnerable },
definition: move,
alignment: arena.canAlign(player, enemy, move.desiredSpacing),
});
if (begin.ok) {
host.presentGrapple(begin.session);
for (const event of begin.events) host.presentGrappleEvent(event);
const strikeEvents = grapples.requestStrike(begin.session.id, `${player.id}:grab-strike`);
strikeEvents.forEach(host.presentGrappleEvent);
grapples.requestThrow(begin.session.id).forEach(host.presentGrappleEvent);
for (const event of grapples.advance(begin.session.id, move)) {
if (event.type === 'thrown') {
stats.applyDamage(event.outcome.victimId, event.outcome.damage);
beltBody(event.outcome.victimId).push(event.outcome.velocity.x, event.outcome.velocity.depth, event.outcome.velocity.height);
}
host.presentGrappleEvent(event);
}
} else {
telemetry.grappleRejected(begin.code, begin.guidance);
}Call advance from the fixed simulation owner, not from animation completion. The coordinator validates competition, range, eligibility, and host alignment atomically; it does not move engine nodes, apply HP, create hitboxes, or play animation. release/removeActor releases both participants together and is idempotent. Snapshots contain session IDs and logical state only; restore with the same static definitions and present the returned semantic events yourself.
Advanced reactions and bounded juggling
Use HitReactionResolver instead of HurtState when hits need airborne follow-ups, bounce limits, downed-hit rules, or shared co-op juggle budgets. One resolve call checks and consumes resources atomically.
import { HitReactionResolver, classicBrawlerReactionPreset } from 'miaoda-game-brawler-core';
const reactions = new HitReactionResolver(classicBrawlerReactionPreset({
scope: 'team', maxJuggle: 8, maxGroundBounces: 1, maxWallBounces: 0,
}));
const decision = reactions.resolve({
victimId: enemy.id,
attackerId: player.id,
teamId: player.teamId,
bodyPhase: enemy.body.z > 0 ? 'airborne' : enemy.downed ? 'downed' : 'grounded',
invulnerable: enemy.invulnerable,
grappled: grapples.inspect().some((session) => session.victimId === enemy.id),
groundBounceAvailable: arena.hasFloorBelow(enemy),
request: {
requestedPhase: attack.launch ? 'launch' : 'stagger',
hitstunTicks: attack.hitstunTicks,
velocity: attack.velocity,
juggleCost: attack.juggleCost,
canHitDowned: attack.canHitDowned,
tags: attack.reactionTags,
},
});
if (decision.accepted) {
stats.applyDamage(enemy.id, attack.damage);
const velocity = decision.outcome!.velocity;
enemy.body.push(velocity.x, velocity.depth, velocity.height);
animation.play(decision.outcome!.animationTag);
} else telemetry.reactionRejected(enemy.id, decision.code, decision.guidance);Returned decisions are detached. Apply damage and BeltBody.push exactly once in the host. Never check juggle eligibility and subtract it in separate calls. Reset a victim after the configured clean landing/get-up boundary with reactions.reset(victimId). Do not apply both HurtState and HitReactionResolver to the same hit: choose the simple posture model or the advanced reaction model per actor/profile.
All authoritative actor, slot, session, cooldown, and juggle-entry ordering uses locale-independent UTF-16 code-unit order. Juggle victim/source identities use collision-free tuple keys, so IDs containing punctuation or control characters remain independent. Hosts should still use stable IDs across save and replay.
Canonical fixed-tick composition
The fixed-step owner calls the pure systems in this order. The host owns the bridge objects and applies each detached result once.
fixedStep.step(1, ({ dt }) => {
input.commit();
const crowdResult = crowd.update({ target: player.ground, enemies: enemyInputs(), slots: hostSlotCandidates() });
applyCrowdTargets(crowdResult.assignments); // steering only; no body integration here
for (const request of actionRequests()) actionRuntime.request(request.id);
const actionFrame = actionRuntime.tick();
applyActionFrame(actionFrame); // timeline/tags, not animation completion
for (const event of grapples.tick((session) => grappleDefinition(session.moveId))) present(event);
combatField.updateAll(bodyPositions());
for (const hit of combatField.resolve(iframes)) {
const decision = reactionProfile(hit.target).resolve(reactionContext(hit));
if (decision.accepted) applyDamageAndVelocity(hit, decision.outcome!);
}
for (const body of bodies) body.step(dt);
});fixedStep is the only frame scheduler. Crowd coordination chooses targets, grapple coordination owns paired relationships, combat produces raw contacts, reaction resolution chooses one semantic result, and BeltBody integrates motion. The engine adapter renders after this tick and never makes animation completion authoritative.
Avoid these compositions: assigning slots independently inside every enemy FSM; using a crowd attack permit plus an unrelated second attack permit; storing writable isGrabbing and isGrabbed flags; moving engine nodes from grapple callbacks; checking and consuming juggle budget in separate calls; applying both HurtState and the advanced resolver to one hit; or ticking these systems once per rendered frame when a fixed-step owner is required.
