npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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.