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.
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.
- 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, plusDEAD. 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:debugshows state, target, distance, relative position, selected attack with its reason, and live cooldowns.
┌───────────────────────────┐
│ 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
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
- An attack always finishes once started, even if the player walks away, so
ATTACKINGcannot jump back toCOMBAT. This keeps telegraphs honest. - The one exception is the target dying. A player
entityDieevent cancels the running attack: the charge dash stops, the action resets tonone, and the cooldown already spent is kept. The knight then drops toIDLE. Dead players are skipped by target search and by hit checks, because a dead player's entity handle stays valid until respawn. - During
ATTACKINGandRECOVERYthe knight's locomotion component group is swapped out (dcm:lock_movement), so it cannot walk or turn on its own. - After
RECOVERYthe controller re-evaluates in the same tick instead of continuing a combo.
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.
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.
- Hybrid locomotion. Pathfinding stays with the engine (
minecraft:behavior.move_towards_targetin thedcm:mobilecomponent 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:movementto 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:actiononly 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,
CorruptedKnightandKnightRegistry.
- One loop.
KnightRegistryowns the onlysystem.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/worldLoadand dropped onentityRemove/entityDie. Target search isdimension.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_debugtag. With nobody opted in it costs one tag query.
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 lintThe deploy target is set in .env (MINECRAFT_PRODUCT, CUSTOM_DEPLOYMENT_PATH).
- 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.
- Run
/scriptevent dcm:debugto toggle the decision HUD. - Use the Spawn Corrupted Knight egg, or run
/summon dcm:corrupted_knight. - 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.
- 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.
- 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_targetgoal (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.