Skip to content

About

Minecraft Bedrock add-on (TypeScript Script API): a Corrupted Knight that picks Slash, Heavy, Charge or Spin from your distance, angle, its state and cooldowns. State machine, telegraphed attack timelines, unit-tested core.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Dynamic Combat Mob: Corrupted Knight

CI Release License: MIT

A Minecraft Bedrock add-on (behavior + resource pack, TypeScript Script API) built as a small technical showcase: one enemy that reads the fight and picks its attack from the player's distance, relative angle, its own combat state and its attack cooldowns.

Try it: download dynamic_combat_mob.mcaddon from the latest release and open it to import both packs into Minecraft.

Overview

The Corrupted Knight is a single custom mob with four attacks: Slash, Heavy Attack, Charge and Spin Attack. It does not run a fixed combo or roll random attacks. It runs the same loop over and over: measure where the target is, pick the attack that fits that situation, play it through a telegraphed timeline, recover, then measure again. The next attack depends on where the player stands after the previous one, so the fight changes when the player moves.

Behavior is split into small modules with one job each: target tracking, situation evaluation, attack selection, attack execution and an explicit state machine. Every tunable number lives in one config file. The decision logic (selector, cooldowns, state machine, vector math, attack timeline) has no runtime dependency on @minecraft/server, so it is covered by plain unit tests.

The project is deliberately small. There are no other mobs, items, quests or UI frameworks, only one enemy built carefully.

Features

  • Context-driven attack selection. Distance, front/side/behind angle, current state and per-attack cooldowns decide the attack (AttackSelector).
  • Rotation-aware position detection. "Behind" is computed with a dot product between the knight's facing vector and the direction to the player, not with world-axis comparisons (CombatMath).
  • Explicit state machine. IDLE → COMBAT → ATTACKING → RECOVERY → COMBAT, plus DEAD. Illegal transitions throw (CombatStateMachine).
  • Attack timeline. Each attack has a wind-up (telegraph), a damage window and a recovery (punish window), and damage only happens inside the damage window (AttackController).
  • Modular attacks. Each attack is a separate object with its own stats and hooks. There is no giant switch.
  • Committed attacks. Heavy and Charge lock their direction when the wind-up starts, so sidestepping the telegraph dodges them.
  • Per-attack cooldowns. If the preferred attack is cooling down, the selector falls through to the next attack that fits.
  • Animation integration. One synced entity property (dcm:action) drives a client animation controller with idle, walk, slash, heavy and charge (each with a wind-up pose), spin and recovery animations.
  • Debug HUD. /scriptevent dcm:debug shows state, target, distance, relative position, selected attack with its reason, and live cooldowns.

Architecture

                 ┌───────────────────────────┐
                 │ KnightRegistry            │  lifecycle events, one central tick loop
                 └─────────────┬─────────────┘
                               ↓
                 ┌───────────────────────────┐
                 │ Target detection          │  nearest player, scanned every 10 ticks
                 └─────────────┬─────────────┘
                               ↓
                 ┌───────────────────────────┐
                 │ CombatController          │  measures distance + relative angle
                 └─────────────┬─────────────┘
                               ↓
                 ┌───────────────────────────┐
                 │ AttackSelector            │  ordered rules + cooldowns → AttackDecision
                 └─────────────┬─────────────┘
                               ↓
                 ┌───────────────────────────┐
                 │ AttackController          │  WIND-UP → ACTIVE → RECOVERY, cooldowns
                 └─────────────┬─────────────┘
          ┌──────────────┬─────┴────────┬──────────────┐
          ↓              ↓              ↓              ↓
        Slash          Heavy         Charge          Spin
          └──────────────┴─────┬────────┴──────────────┘
                               ↓
                 ┌───────────────────────────┐
                 │ CombatStateMachine        │  single source of truth for "what am I doing"
                 └─────────────┬─────────────┘
                               ↓
                 ┌───────────────────────────┐
                 │ CorruptedKnight adapter   │  entity property → animations, movement lock
                 └───────────────────────────┘
scripts/
├── main.ts
├── config/CombatConfig.ts         every tunable value (ticks, blocks, damage, angles)
├── utils/CombatMath.ts            yaw/forward vectors, dot product, FRONT/SIDE/BEHIND
├── state/CombatStateMachine.ts    states + allowed transitions
├── combat/
│   ├── CombatController.ts        per-knight brain
│   ├── AttackSelector.ts          context → AttackDecision
│   ├── AttackController.ts        attack timeline + cooldown ownership
│   ├── CooldownTracker.ts
│   └── attacks/
│       ├── Attack.ts              Attack interface + shared hit helper
│       ├── SlashAttack.ts
│       ├── HeavyAttack.ts
│       ├── ChargeAttack.ts
│       └── SpinAttack.ts
├── entity/
│   ├── CorruptedKnight.ts         entity adapter (properties, events, rotation)
│   └── KnightRegistry.ts          lifecycle + the single update loop
└── debug/
    └── DebugHud.ts                optional actionbar HUD

State Machine

stateDiagram-v2
    [*] --> IDLE
    IDLE --> COMBAT: player detected (scan) or hit by a player
    COMBAT --> ATTACKING: AttackSelector returns an attack
    COMBAT --> IDLE: target lost, nobody else in range
    ATTACKING --> RECOVERY: damage window over (or charge connected)
    RECOVERY --> COMBAT: recovery finished → re-evaluate immediately
    ATTACKING --> IDLE: target died → attack cancelled
    RECOVERY --> IDLE: target died
    IDLE --> DEAD
    COMBAT --> DEAD
    ATTACKING --> DEAD
    RECOVERY --> DEAD
Loading
  • An attack always finishes once started, even if the player walks away, so ATTACKING cannot jump back to COMBAT. This keeps telegraphs honest.
  • The one exception is the target dying. A player entityDie event cancels the running attack: the charge dash stops, the action resets to none, and the cooldown already spent is kept. The knight then drops to IDLE. Dead players are skipped by target search and by hit checks, because a dead player's entity handle stays valid until respawn.
  • During ATTACKING and RECOVERY the knight's locomotion component group is swapped out (dcm:lock_movement), so it cannot walk or turn on its own.
  • After RECOVERY the controller re-evaluates in the same tick instead of continuing a combo.

Dynamic Attack Selection

Every 2 ticks in COMBAT, the controller builds a CombatContext { state, distance, relativePosition, tick } and asks the selector. The rules are ordered from most to least situational. The first rule whose condition matches and whose attack is off cooldown wins:

# Attack Condition Why
1 Spin target behind and distance ≤ 3.5 Turning around would leave the knight open, so it sweeps 360°.
2 Charge 6 ≤ distance ≤ 14 Gap closer for players who keep their distance.
3 Slash distance ≤ 3 Quick: it snaps to face the target, so front and side both work (and behind while Spin cools down).
4 Heavy target in front and distance ≤ 5 Slow and committed: only chosen when the player is already in the swing arc.
- none anything else Keep walking in. Typical cases: a 5–6 block gap, a side position at medium range, or everything relevant on cooldown.
  • Distance picks the band: melee, medium or far.
  • Angle comes from dot(forward, directionToTarget) on the horizontal plane. A dot ≥ 0.5 (±60°) is FRONT, a dot ≤ -0.35 (beyond ~110°) is BEHIND, and anything between is SIDE. The yaw comes from the entity rotation, so the same world position can be FRONT or BEHIND depending on where the knight looks.
  • State: the selector only returns an attack in COMBAT.
  • Cooldowns start when an attack starts. A cooling-down attack is skipped, so the next fitting rule is used instead. For example, a close player with Slash cooling down gets a Heavy if they are in front.

Example fight: the player stands close in front and gets a Slash. Knockback puts them at 3.5 blocks in front, so the next attack is a Heavy. They retreat to 8 blocks and get a Charge. They walk around the recovering knight and get a Spin.

Attack Timeline

START ──wind-up──▶ DAMAGE WINDOW ──▶ RECOVERY ──▶ END
        telegraph   hits ≤ once/player   punish window
Attack Wind-up Damage window Recovery Cooldown Damage* Hit shape
Slash 6 t 3 t 10 t 30 t 5 3 blocks, front half circle around the committed facing
Heavy 20 t 4 t 24 t 90 t 11 5 blocks, ±60° cone, facing locked at wind-up start
Charge 16 t up to 22 t 26 t 120 t 8 dash along the locked direction, hits within 1.6 blocks, stops on contact
Spin 8 t 8 t 14 t 80 t 7 3.5 block radius, all directions, ends facing the target

*Base values. Bedrock scales mob damage to players by difficulty; on Easy a 5 becomes 3.5.

Technical Decisions

  • Hybrid locomotion. Pathfinding stays with the engine (minecraft:behavior.move_towards_target in the dcm:mobile component group). Script only decides combat actions. Writing pathfinding in script would cost more and behave worse.
  • Movement lock via component groups. The locked group sets minecraft:movement to 0 and drops all movement/look goals. Movement is not defined in the base components, because removing a group would delete a base component of the same name.
  • Facing lock. Vanilla body/look controllers keep turning a mob toward its target. Slash, Heavy and Charge re-apply their committed yaw during wind-up and damage window, so a telegraphed attack really can be dodged.
  • Animation through one enum property. The script sets dcm:action only when it changes, and the client animation controller does the rest. This also covers players who join mid-fight, because the property is synced to clients.
  • Target stickiness. The knight keeps its current target while it is valid. It only re-targets after losing that target, which is checked against a larger radius than detection. This avoids ping-ponging between players in multiplayer.
  • Pure core, thin adapter. Everything that decides is free of engine imports. Everything that touches the engine sits in the attacks, CorruptedKnight and KnightRegistry.

Performance

  • One loop. KnightRegistry owns the only system.runInterval. It starts when the first knight is tracked and stops when none are left, so an empty world costs nothing.
  • No world scans per tick. Knights are discovered through entitySpawn / entityLoad / worldLoad and dropped on entityRemove / entityDie. Target search is dimension.getPlayers({ location, maxDistance, closest: 1 }), which queries players only, at most once every 10 ticks per knight, and only while it has no valid target.
  • Event-driven aggro. entityHurt, filtered to the knight type, engages an idle knight immediately when a player hits it, without waiting for a scan.
  • Cheap idle and combat ticks. An idle knight does a tick comparison and returns. In combat, distance and angle are re-evaluated every 2 ticks with allocation-free scalar math (no vector objects).
  • Hit queries only inside damage windows. Attacks query nearby players only during their few active ticks.
  • No redundant network traffic. Entity properties and movement-lock events are sent only when the value changes.
  • Opt-in HUD. The debug HUD runs 4 times per second, only for players with the dcm_debug tag. With nobody opted in it costs one tag query.

Setup

Requirements: Node.js LTS and Minecraft Bedrock 1.26.50+.

npm install
npm run build          # compile + bundle scripts/ → dist/scripts/main.js
npm run local-deploy   # build and copy both packs into Minecraft (watch mode)
npm run mcaddon        # dist/packages/dynamic_combat_mob.mcaddon
npm test               # unit tests (selector, angles, state machine, timeline, cooldowns)
npm run typecheck && npm run lint

The deploy target is set in .env (MINECRAFT_PRODUCT, CUSTOM_DEPLOYMENT_PATH).

Trying it in game

  1. Create a world (a flat world works best), enable Dynamic Combat Mob BP, and play in Survival. Creative and spectator players are ignored on purpose.
  2. Run /scriptevent dcm:debug to toggle the decision HUD.
  3. Use the Spawn Corrupted Knight egg, or run /summon dcm:corrupted_knight.
  4. Test each rule:
    • Stand close → Slash.
    • Back off past 6 blocks → Charge.
    • Walk behind it while it recovers → Spin.
    • Stand 3–5 blocks in front → Heavy.
    • Watch the cooldown column as attacks rotate.

Testing

  • Unit tests (tests/combat.test.ts, Vitest) cover:
    • Test cases A–E: close → Slash, far → Charge, behind → Spin, medium front → Heavy, and cooldown respect with fall-through.
    • FRONT/SIDE/BEHIND classification under different rotations.
    • Legal and illegal state transitions.
    • Damage window timing: damage never at attack start, and early end on contact.
  • In-engine checks. The scenarios (A–E, multiplayer, target loss, chase) were run on a Bedrock Dedicated Server 1.26.52 with GameTest simulated players. The test harness itself is not part of this repository.

Known Limitations

  • Placeholder art. The model is a simple blocky knight with a generated flat-color texture, and the animations are basic keyframes. Animations were not checked in the client renderer, only for valid JSON and controller wiring.
  • Two targeting systems. The vanilla nearest_attackable_target goal (which drives pathfinding) and the script's target are chosen independently, both as "nearest player". With several players at similar distances they can briefly disagree about whom to walk toward.
  • Only the target is evaluated. A second player standing behind the knight does not trigger Spin. Spin does hit everyone in its radius, though.
  • No arena. There is no pre-built arena structure; any open, flat area works.
  • Script-applied damage. Attacks use applyDamage, so armor and difficulty apply, but there is no custom hit-stun or shield-blocking logic.

About

Minecraft Bedrock add-on (TypeScript Script API): a Corrupted Knight that picks Slash, Heavy, Charge or Spin from your distance, angle, its state and cooldowns. State machine, telegraphed attack timelines, unit-tested core.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages