miaoda-game-fighter-core
v0.3.1
Published
Engine-agnostic deterministic 1v1 and 2-4 player fighter foundation with commands, arena targeting, match flow, snapshots, and rollback history.
Maintainers
Readme
miaoda-game-fighter-core
Engine-independent deterministic 1v1 fighting-game core. It normalizes the two participant identities, commits one logical input per fixed tick, and exposes JSON-safe snapshots for replay and future rollback.
The package does not own movement, animation, collision geometry, damage
storage, or networking. The host supplies CombatField hit events and actor
observations; FighterVersusResolver turns them into one detached semantic
outcome per target and tick.
import { FighterSession } from 'miaoda-game-fighter-core';
const session = new FighterSession([
{ id: 'player-1', side: 'left' },
{ id: 'player-2', side: 'right' },
]);
const result = session.step({
'player-1': { direction: 'right', buttons: { light: true } },
'player-2': { direction: 'neutral', buttons: {} },
});
// Pass result to later command/state systems. The session owns no engine nodes.
const saved = session.snapshot;
session.restore(saved);Inputs are supplied in any object order; participant results and snapshots use stable locale-independent UTF-16 fighter-ID order. The same ordering rule is used for command and contact tie-breaks so simulation does not depend on host locale or ICU data. Unknown IDs, malformed buttons, duplicate sides, and partial snapshots are rejected without advancing or partially mutating the session.
Two-to-four player arena session
Keep the focused FighterSession for 1v1 play. FighterArenaSession adds a
separate two-to-four player roster/input boundary with consecutive player
handles, free-for-all or fixed team identity, and exactly one normalized input
per participant and tick.
import { FighterArenaSession } from 'miaoda-game-fighter-core';
const arena = new FighterArenaSession('teams', [
{ handle: 0, id: 'red-1', teamId: 'red' },
{ handle: 1, id: 'red-2', teamId: 'red' },
{ handle: 2, id: 'blue-1', teamId: 'blue' },
{ handle: 3, id: 'blue-2', teamId: 'blue' },
]);
const tick = arena.step({
'red-1': { direction: 'right', buttons: { light: true } },
'red-2': { direction: 'neutral', buttons: {} },
'blue-1': { direction: 'left', buttons: { guard: true } },
'blue-2': { direction: 'neutral', buttons: {} },
});Handles are always 0..N-1; outputs and snapshots use handle order regardless
of participant declaration or input-record order. The session owns no fighter
controller, scoring, geometry, camera, spawn, or network state. Use one complete
host arena snapshot with FighterRollbackHistory, rather than independently
rolling back each fighter.
FighterArenaMatchCoordinator separately owns stock or timed-score match
state. The host reports confirmed knockout/fall events after its blast-zone or
health logic resolves; the coordinator applies stock loss, KO credit, fall
penalties, team aggregation, elimination, deterministic ranking, and explicit
sudden-death readiness.
import { FighterArenaMatchCoordinator } from 'miaoda-game-fighter-core';
const match = new FighterArenaMatchCoordinator({
participants: arena.inspect(),
rule: { kind: 'stock', stocks: 3 },
teamAttack: false,
});
match.start();
const result = match.step([
{ victimId: 'blue-1', cause: 'opponent', attackerId: 'red-1' },
]);Use canDamage(attackerId, targetId) before accepting same-team contacts.
resolveSuddenDeath() consumes the winner chosen by a separately simulated
sudden-death round; it does not invent geometry or tie-break gameplay.
FighterArenaResolver provides the remaining stateless arena boundary. Give it
one complete host observation to select the nearest eligible target for every
active fighter. Distance ties use participant handles, and horizontal ties or
missing targets preserve the current facing.
import {
FighterArenaResolver,
type FighterVersusContact,
} from 'miaoda-game-fighter-core';
const resolver = new FighterArenaResolver({
participants: arena.inspect(),
teamAttack: false,
});
const targets = resolver.resolveTargets([
{ fighterId: 'red-1', position: { x: 0, y: 0 }, facing: 1, active: true, targetable: true },
{ fighterId: 'red-2', position: { x: 2, y: 0 }, facing: 1, active: true, targetable: true },
{ fighterId: 'blue-1', position: { x: 8, y: 1 }, facing: -1, active: true, targetable: true },
{ fighterId: 'blue-2', position: { x: 12, y: 0 }, facing: -1, active: true, targetable: true },
]);
// Contacts normally come from CombatField plus the host's fighter observations.
declare const detectedContacts: readonly FighterVersusContact[];
const contacts = resolver.resolveContacts(arena.tick, detectedContacts);Contact arbitration validates the whole batch, filters same-team contacts when
team attack is disabled, then delegates accepted contacts to
FighterVersusResolver. It chooses at most one outcome per target; different
targets can therefore trade on the same tick. Attack priority and stable IDs
resolve competing strikes or throws. Damage application, assist attribution,
KO detection, and movement remain host concerns; pass confirmed KOs to the
match coordinator.
Phase 1 command parsing
FighterCommandParser consumes one consecutive normalized input sample at a
time and returns a detached command candidate. It does not start an action;
pass the candidate to the later fighter state/action layer.
import {
FighterCommandParser,
quarterCircleForward,
dragonPunchForward,
} from 'miaoda-game-fighter-core';
const parser = new FighterCommandParser([
quarterCircleForward('fireball', 'punch'),
dragonPunchForward('uppercut', 'punch', 10),
{ id: 'flash-kick', kind: 'charge', chargeDirection: 'back', chargeTicks: 30, button: 'kick' },
{
id: 'throw',
kind: 'button',
button: 'light',
simultaneousButtons: ['light', 'heavy'],
simultaneousWindow: 1,
},
]);
parser.push(100, { direction: 'down', buttons: {} }, 1);
parser.push(101, { direction: 'down-right', buttons: {} }, 1);
const command = parser.push(102, { direction: 'right', buttons: { punch: true } }, 1);
// command.commandId === 'fireball'Motion sequences are facing-relative, so the same definition works when the fighter faces left. History is bounded and snapshots contain history plus the next expected tick, but never command definitions or engine objects.
Phase 2 fighter control
FighterController composes ActionRuntime with logical locomotion,
interruption, landing, wakeup, and hitstop state. The host observes and supplies
the body phase; the controller never moves an engine body.
import { FighterController } from 'miaoda-game-fighter-core';
const fighter = new FighterController({
actions: [
{
id: 'jab',
action: {
id: 'jab',
steps: [
{ id: 'startup', duration: 3, tags: ['lock:move'] },
{ id: 'active', duration: 2, tags: ['lock:move', 'hitbox:jab'] },
{ id: 'recovery', duration: 5, tags: ['lock:move'] },
],
},
},
],
commandActions: { jab: 'jab' },
landingTicks: 2,
wakeupTicks: 12,
});
const tick = fighter.step({
input: { direction: 'neutral', buttons: { light: true } },
bodyPhase: 'grounded',
command: { commandId: 'jab', tick: 0, expiresTick: 2, kind: 'button' },
});
applyMovementConstraint(tick.constraint);
if (tick.action?.frame.sample?.tags.includes('hitbox:jab')) enableJabHitbox();Call applyHitstop(ticks) after an accepted hit. Frozen controller ticks still
advance the controller tick index for replay/network alignment, but action
frames, cooldowns, state timers, and pending command consumption do not move.
Use interrupt('hitstun' | 'knockdown', ticks) to clear the active action and
enter a deterministic timed reaction state.
Tick results and inspection are detached. Snapshots include controller and
ActionRuntime state and restore atomically against identical definitions.
Phase 3 versus resolution
import { FighterVersusResolver } from 'miaoda-game-fighter-core';
const versus = new FighterVersusResolver({
stage: { minX: 0, maxX: 960 },
allowAirBlock: false,
});
const outcomes = versus.resolve(tick, [{
event: combatHit,
attacker: { id: 'p1', x: 420, facing: 1, bodyPhase: 'grounded', guard: 'none', actionPhase: 'active' },
target: { id: 'p2', x: 500, facing: -1, bodyPhase: 'grounded', guard: 'crouching', actionPhase: 'neutral' },
attack: {
id: combatHit.attackId,
level: 'low',
hitstunTicks: 16,
blockstunTicks: 10,
hitPushback: 28,
blockPushback: 12,
},
}]);Attack definitions are data, not animation names. High/mid/low levels use the configured guard posture; throws bypass guard but respect throw invulnerability and the target's escape window. Recovery is classified as punish counter, while startup/active is classified as counter hit. Same-tick contacts are sorted by priority and stable IDs, so a target receives at most one outcome. Pushback is clamped to the configured stage bounds and returned as signed host deltas.
When the project already uses HitReactionResolver, pass it as
reactionResolver. Accepted hit decisions are forwarded atomically and a
rejected juggle/paired-state decision is retained as a zero-damage semantic
outcome for host telemetry.
Phase 4 air game and resources
FighterComboLedger keeps a bounded attacker-target combo entry. It scales
damage and hitstun, permits one confirmed wall bounce, exposes air recovery,
and converts an exhausted airborne juggle into a deterministic knockdown
outcome. Pair identities use collision-free tuple encoding, and reset() may
clear one pair, all entries for one attacker or target, or the complete ledger.
The host owns gravity, wall detection, and the actual recovery input.
FighterResourceState owns explicit super and guard meters. Gains clamp to the
configured maximum; spending is atomic and never produces a negative value.
Both classes expose detached versioned snapshots and reject malformed restores
before mutating state.
import { FighterComboLedger, FighterResourceState } from 'miaoda-game-fighter-core';
const combo = new FighterComboLedger({ maxJuggleHits: 4, damageScaleStep: 0.15 });
const resources = new FighterResourceState({
superMeter: { current: 0, max: 1000 },
guardMeter: { current: 100, max: 100 },
});
const airResult = combo.resolve({
tick: 30,
attackerId: 'p1',
targetId: 'p2',
bodyPhase: 'airborne',
damage: 80,
hitstunTicks: 18,
wallBounceRequested: true,
wallBounceAvailable: true,
});
resources.gainSuper(airResult.damage);calculateFrameAdvantage reports defender stun minus attacker recovery after
shared hitstop. Positive values mean the attacker acts first.
Phase 5 round flow
FighterRoundCoordinator owns fixed-tick timer, KO freeze, round scores,
match completion, next-round preparation, and rematch preparation. The host
supplies health observations and performs position/health resets only after a
next-round-ready or rematch-ready event.
const rounds = new FighterRoundCoordinator({
fighterIds: ['p1', 'p2'],
roundTicks: 60 * 60,
koFreezeTicks: 45,
roundsToWin: 2,
});
rounds.startRound();
const result = rounds.step({ health: { p1: 100, p2: 0 } });
// result.events contains `ko`; settlement follows after the fixed KO freeze.Use miaoda-game-fighter-cocos to map round/action/reaction tags to Cocos
animation, audio, and effects. Its callbacks are presentation outputs and must
not advance this coordinator.
Phase 6 rollback-ready history
FighterRollbackHistory retains a bounded sequence of inputs and complete
post-tick snapshots. A late corrected input restores the state immediately
before that tick and deterministically re-simulates through the previous end
tick. The adapter keeps simulation ownership in the host:
import { FighterRollbackHistory } from 'miaoda-game-fighter-core';
const rollback = new FighterRollbackHistory({
capacity: 12,
initialSnapshot: session.snapshot,
adapter: {
simulate: (tick, inputs) => {
if (session.tick !== tick) throw new Error('fighter tick mismatch');
session.step(inputs);
},
capture: () => session.snapshot,
restore: (snapshot) => session.restore(snapshot),
},
});
rollback.advance(predictedInputsForTick0);
rollback.advance(predictedInputsForTick1);
const correction = rollback.correctInput(1, confirmedInputsForTick1);
// correction.inputChanged reports an input replacement; stateChanged reports
// whether replay changed the checksum at the current end tick.Inputs, returned frames, and history snapshots are detached. Failed simulation
or re-simulation restores the host's prior logical state and leaves retained
history unchanged. fighterStateChecksum hashes canonical JSON together with
its tick boundary for compatibility and desync diagnostics; FNV-1a is not a
cryptographic integrity check. Transport, input prediction, peer authority,
confirmation policy, and network security remain application concerns.
For arena rollback, make TInput contain all participant inputs plus confirmed
world events, and make TSnapshot contain the arena session, match coordinator,
all fighter controllers/resources, and any other authoritative host state.
The adapter's restore callback must validate and restore that composite as one
transaction. Do not create one independent rollback history per fighter.
