Enemies & Allies

Register reusable enemy templates, write custom AI strategies, and define combat allies that fight alongside the player.

Enemies

An enemy template is a reusable set of combat stats tied to a mob entity type. Once registered, biome JSON can reference it by id with "enemy": "<id>" instead of spelling out the same stats in every biome that uses that mob. Templates are registered with CrafticsAPI.registerEnemy().

Enemy templates can also be loaded from JSON datapacks with no Java code required. Both approaches produce the same result: a named entry in the enemy registry that biomes can reference.

Java API

Build an EnemyEntry with its fluent builder and pass it to CrafticsAPI.registerEnemy():

EnemyEntry entry = EnemyEntry.builder("mymod:desert_husk", "minecraft:husk")
    .ai("minecraft:skeleton")   // optional: use a different AI key
    .hp(10)
    .attack(3)
    .defense(1)
    .range(1)
    .speed(2)
    .build();

CrafticsAPI.registerEnemy(entry);
Builder Method Type Default Description
builder(id, entityTypeId) String, String required Registry key (e.g. "mymod:desert_husk") and the Minecraft entity type to render (e.g. "minecraft:husk"). Both are required.
ai(String) String entityTypeId Key used to look up the AI strategy in AIRegistry. Defaults to the entityTypeId, so most enemies need no explicit AI key unless you want to reuse another mob's strategy.
hp(int) int 6 Base health.
attack(int) int 2 Base attack.
defense(int) int 0 Base defense.
range(int) int 1 Attack range in tiles.
speed(int) int 0 Combat move speed in tiles per turn. 0 means use the entity type's built-in default speed. Set a positive integer to override it with a fixed value.

JSON Datapack Schema

Place JSON files at data/<namespace>/craftics/enemies/<name>.json. The id and entity fields are required. All stat fields are optional and fall back to the same defaults as the Java builder. The entity value must be a valid, loaded entity type or the file is skipped with a warning.

Field Type Required Default Description
id string yes Registry key for this template, e.g. "mymod:desert_husk".
entity string yes Minecraft entity type to render, e.g. "minecraft:husk". Must be a loaded entity type.
ai string no entity AI registry key. Resolved at runtime; unknown keys fall back to the entity type's default strategy.
hp int no 6 Base health.
attack int no 2 Base attack.
defense int no 0 Base defense.
range int no 1 Attack range in tiles.
speed int no 0 Move speed in tiles per turn. 0 means use the entity type default.

Example JSON

// data/mymod/craftics/enemies/desert_husk.json
{
  "id": "mymod:desert_husk",
  "entity": "minecraft:husk",
  "ai": "minecraft:skeleton",
  "hp": 10,
  "attack": 3,
  "defense": 1,
  "range": 1,
  "speed": 2
}

Once loaded, a biome JSON can reference this template with "enemy": "mymod:desert_husk" instead of repeating all the stat fields inline.

Tip: The entity field controls only how the mob looks in the arena. You can render a husk but give it skeleton AI, or render any modded mob entity as long as it is loaded by the time the datapack is applied. The stat block is completely independent of the entity's vanilla behavior.

Enemy AI

Craftics uses a strategy interface, EnemyAI, to decide what each enemy does on its turn. Built-in strategies cover all vanilla hostile mobs. Addon mods can register custom strategies for new mob types (or override behavior for existing ones) with CrafticsAPI.registerAI().

Registration

CrafticsAPI.registerAI("mymod:custom_zombie", (self, arena, playerPos) -> {
    // Decide what this enemy does this turn and return an EnemyAction.
    return new EnemyAction.MoveAndAttack(path, self.getAttackPower());
});

The first argument is the entity type id (or any custom string you used as the ai key in an enemy template). The second argument is an EnemyAI instance, which is a functional interface.

The EnemyAI Interface

public interface EnemyAI {
    EnemyAction decideAction(CombatEntity self, GridArena arena, GridPos playerPos);

    default Set<GridPos> computeThreatTiles(CombatEntity self, GridArena arena) {
        return null; // null = use the generic speed+range danger diamond
    }
}

decideAction is called once per enemy turn. It receives the enemy's own CombatEntity, the full GridArena (tile data, occupancy, obstacle positions), and the player's current GridPos. Return any EnemyAction record to describe what the enemy does.

computeThreatTiles is optional. Override it when the enemy's real attack reach does not match the generic danger diamond computed from speed + range. Return a set of GridPos values that Craftics will highlight red as the danger indicator for the player. Return null (the default) to use the generic formula.

AIUtils Helpers

The AIUtils class provides pathfinding and geometry helpers so you do not have to implement common patterns from scratch.

Method Description
seekOrWander(self, arena, playerPos) Universal fallback: move toward the player using pathfinding, or wander to a random adjacent tile if no path exists. Returns a Move or MoveAndAttack action, never Idle unless the enemy is completely boxed in.
wander(self, arena) Move to a random adjacent walkable tile. Use for neutral or passive mobs that are not targeting anyone.
getAdjacentTiles(arena, pos) Returns all walkable, unoccupied tiles cardinally adjacent to pos.
findBestAdjacentTarget(arena, self, playerPos, maxSteps) Finds the closest tile adjacent to the player that this entity can path to within maxSteps.
findBestAdjacentTarget(arena, self, playerPos, maxSteps, entitySize) Size-aware variant: checks that the full entity footprint fits at each candidate tile.
canPlaceFootprint(arena, anchor, entitySize) Returns true if a sized entity can occupy all footprint tiles at anchor (in-bounds, walkable, not enemy-occupied).
hasCardinalLOS(arena, from, to, maxRange) Returns true if from and to share an axis and every tile between them is clear and walkable.
getFleeTarget(arena, self, threat, maxSteps) Finds a tile 1-2 steps away from threat in the primary flee direction. Returns null if the enemy is stuck.

EnemyAction Types

EnemyAction is a sealed interface. Return one of these records from decideAction. The records marked with an asterisk carry additional fields described below the table.

Record Fields Description
MovepathMove along a list of grid positions. The enemy does not attack.
AttackdamageAttack the player from the current position.
MoveAndAttackpath, damageMove, then attack.
MoveAttackMoveapproachPath, damage, retreatPathMove in, attack, then reposition in the same turn using remaining movement.
FleepathMove away from the player. Does not attack.
IdleDo nothing this turn.
TeleporttargetInstantly relocate to a tile with no movement animation.
TeleportAndAttacktarget, damageTeleport, then immediately attack.
PouncelandingPos, damageLeap over one tile gap to land adjacent and attack.
Explodedamage, radius, blastEffectsAoE explosion centered on self. blastEffects is a list of BlastEffect(effectType, turns, amplifier) applied to the player on hit. Use the two-arg constructor for no status effects.
StartFuseBegin a fuse countdown (creeper-style). Pair with Detonate on the next turn.
DetonateResolve a pending fuse explosion.
RangedAttackdamage, effectNameAttack from range without moving (potion throw, arrow, etc.).
Swooppath, damageSweep along a line; deal damage if the player is in the path.
AttackMobtargetEntityId, damageAttack another mob (predator-prey behavior) instead of the player.
MoveAndAttackMobpath, targetEntityId, damageMove, then attack another mob.
AttackWithKnockbackdamage, knockbackTilesAttack and push the player N tiles away from the attacker.
MoveAndAttackWithKnockbackpath, damage, knockbackTilesMove, then attack with knockback.
MimicDashdirX, dirZ, damageCharge in a cardinal direction until blocked; shove players or allies in the path sideways.
MimicTantrumpath, damageHop through an ordered list of tiles; stop and deal damage if any hop lands on the player.
SummonMinionsentityTypeId, count, positions, hp, atk, defBoss action: spawn minions at the given positions.
AreaAttackcenter, radius, damage, effectNameHit all entities within radius tiles of center.
CreateTerraintiles, terrainType, durationPlace or transform terrain tiles. duration of 0 is permanent.
LineAttackstart, dx, dz, length, damageHit every tile along a line from start in direction (dx, dz).
ModifySelfstat, amount, durationBoss action: temporarily change the enemy's own stat. duration of 0 is permanent.
ForcedMovementtargetEntityId, dx, dz, tilesPush a target entity in direction (dx, dz). Use targetEntityId = -1 to target the player.
BossAbilityabilityName, resolvedAction, warningTilesTelegraph a warning this turn; the resolvedAction executes next turn.
CeilingAscendRise off the grid for one turn (spider ceiling mechanic).
CeilingDroplandingPos, damageDrop from ceiling onto a tile near the player and attack.
SpawnProjectileentityTypeId, positions, directions, hp, atk, def, projectileTypeBoss action: create traveling projectile entities.
ProjectileMovepath, impacts, impactPosAdvance a projectile; impacts = true means it collides at path end.
CompositeActionactionsExecute multiple actions in sequence in a single turn.

Supported Mob Types (Built-in)

These mobs already have registered AI strategies. You can use them in enemy templates and biome JSON without writing any code.

Example: Custom Ranged AI

import com.crackedgames.craftics.combat.ai.EnemyAI;
import com.crackedgames.craftics.combat.ai.EnemyAction;
import com.crackedgames.craftics.combat.ai.AIUtils;

// A simple ranged AI: attack in place if in range, else close the distance.
EnemyAI archerAI = (self, arena, playerPos) -> {
    int dist = self.getGridPos().manhattanDistance(playerPos);
    int range = self.getRange();

    if (dist <= range && AIUtils.hasCardinalLOS(arena, self.getGridPos(), playerPos, range)) {
        // Already in range with a clear line: shoot.
        return new EnemyAction.RangedAttack(self.getAttackPower(), "arrow");
    }

    // Out of range or blocked: move toward the player.
    return AIUtils.seekOrWander(self, arena, playerPos);
};

CrafticsAPI.registerAI("mymod:dungeon_archer", archerAI);

Allies

Combat allies are mobs recruited from the player's hub that fight alongside them in the arena. Allies have their own combat stats and can optionally scale with the owner's gear, accept a heal item during combat, or run a custom per-round effect. Allies are registered with CrafticsAPI.registerAlly().

Like enemies, allies can be defined in JSON datapacks. Datapack allies always use the default melee AI. Allies that need a custom AI strategy, a per-round hook, or programmatic stat logic must be registered through the Java API.

Java API

AllyEntry entry = AllyEntry.builder("minecraft:wolf")
    .hp(12)
    .attack(4)
    .defense(1)
    .range(1)
    .speed(3)
    .recruitMode(AllyEntry.RecruitMode.TAMED)
    .scalesWithOwnerGear(true)
    .healItem(Items.BONE, 6)
    .build();

CrafticsAPI.registerAlly(entry);
Builder Method Type Default Description
builder(entityTypeId) String required The Minecraft entity type this entry describes, e.g. "minecraft:wolf".
hp(int) int 6 Base health.
attack(int) int 1 Base attack.
defense(int) int 0 Base defense.
range(int) int 1 Attack range in tiles.
speed(int) int 2 Movement tiles per turn.
recruitMode(RecruitMode) enum TAMED How the ally is collected from the hub. See the RecruitMode table below.
ai(AllyAI) AllyAI MeleeAllyAI Combat behavior. Defaults to AllyEntry.DEFAULT_AI, which is a standard melee strategy.
scalesWithOwnerGear(boolean) boolean true When true, the ally's attack gains bonuses from the owner's armor and trim.
roundHook(AllyRoundHook) AllyRoundHook null Optional callback invoked at the start of each combat round. Signature: (CombatEntity self, EnvironmentDef environment). Use for aura effects, per-round stat changes, or environment-reactive triggers.
healItem(Item, int) Item, int null, 0 Binds a heal item: using this item on the ally during combat restores the given amount of HP.

RecruitMode Values

Value Description
TAMED The mob must be tamed and owned by the hub's player (wolves, cats, horses, and similar). Default.
BUILT Any mob of this type present in the hub yard qualifies, with no taming or ownership requirement. Use for golems and constructed mobs.
IN_COMBAT_ONLY Never recruited from the hub. The entry exists only to define combat stats for a mob tamed mid-battle.

JSON Datapack Schema

Place JSON files at data/<namespace>/craftics/allies/<name>.json. The entity field is required. Datapack allies always use the default melee AI and cannot have a per-round hook.

Field Type Required Default Description
entity string yes Minecraft entity type, e.g. "minecraft:wolf". Must be a loaded entity type.
hp int no 6 Base health.
attack int no 1 Base attack.
defense int no 0 Base defense.
range int no 1 Attack range in tiles.
speed int no 2 Movement tiles per turn.
recruit_mode string no "TAMED" Recruitment requirement. Accepts "TAMED", "BUILT", or "IN_COMBAT_ONLY" (case-insensitive). Unknown values fall back to TAMED with a warning.
scales_with_owner_gear boolean no true Whether the ally's attack gains bonuses from the owner's armor and trim.
heal_item string no Item id that heals this ally when used on it in combat, e.g. "minecraft:bone". Ignored if the item is not loaded.
heal_amount int no 0 HP restored by heal_item. Only meaningful when heal_item is set.

Example JSON

// data/mymod/craftics/allies/iron_golem.json
{
  "entity": "minecraft:iron_golem",
  "hp": 20,
  "attack": 6,
  "defense": 3,
  "range": 1,
  "speed": 1,
  "recruit_mode": "BUILT",
  "scales_with_owner_gear": false
}

Example: Custom Ally with a Round Hook

Round hooks require the Java API. The hook receives the living ally as a CombatEntity and the arena's EnvironmentDef. It is called once per round for each living ally of this type.

import com.crackedgames.craftics.api.CrafticsAPI;
import com.crackedgames.craftics.api.registry.AllyEntry;

CrafticsAPI.registerAlly(AllyEntry.builder("minecraft:cat")
    .hp(8)
    .attack(2)
    .defense(0)
    .range(1)
    .speed(3)
    .recruitMode(AllyEntry.RecruitMode.TAMED)
    .scalesWithOwnerGear(false)
    .roundHook((self, environment) -> {
        // Round hook receives the ally itself and the arena environment.
        // Use self to read or modify the ally's own combat state each round.
    })
    .build());
Note: Datapack allies always use the default melee AI (MeleeAllyAI) and cannot have a custom roundHook. If your ally needs custom combat behavior or per-round effects, register it through CrafticsAPI.registerAlly() in your onCrafticsInit() method instead.

Field Ally Providers

Craftics' battle party is built from real mobs standing in the hub. You Shift+Right-Click a wolf to tag it, and when combat starts it is snapshotted, removed from the hub, fielded as an ally, and put back afterwards. That model assumes the ally exists in the world before the fight.

A mod whose party is data on the player cannot use it. There is no wolf to tag - there is a list of creatures the player is carrying, and they should appear in the arena because the player owns them. A FieldAllyProvider is the hook for that: Craftics asks, the addon answers with specs, and they are fielded alongside any real hub pets.

Registering a provider

CrafticsAPI.registerFieldAllyProvider("mymod:party", (world, player, freeSlots) -> {
    List<FieldAlly> out = new ArrayList<>();
    for (Creature c : MyModApi.partyOf(player)) {
        out.add(FieldAlly.builder("mymod:creature")
            .stats(AllyEntry.builder("mymod:creature")
                .hp(c.hp()).attack(c.attack()).defense(c.defense()).range(1).build())
            .aiKey("mymod:" + c.species())   // per-species AI, typing and spawn hook
            .spawnNbt(c.toNbt())             // what it IS
            .displayName(c.nickname())       // what it is called in combat
            .build());
    }
    return out;
});
Builder MethodRequiredDescription
builder(entityTypeId) yes The entity type to render the ally as.
stats(AllyEntry) yes Combat stats. Required, unlike a hub pet - there is no real mob to derive them from.
aiKey(String) no AI, attack typing and spawn-customizer key. Defaults to the entity type id. This is what lets one entity type field many different creatures.
spawnNbt(NbtCompound) no Merged onto the mob at spawn, same rules as an enemy's.
displayName(String) no Name shown in combat. Without it, every ally sharing an entity type derives the same name from that type - a six-creature party would read as six copies of one thing.

The party cap is advisory

Providers receive freeSlots: how many slots remain under the player's own party cap after real hub pets are counted. It may be zero or negative.

Craftics does not truncate what you return. The cap is written for tamed wolves and scales off the player's Pet affinity; a mod with a six-creature party owns its own rules, and silently cutting that to one would look like a Craftics bug rather than a design decision. Respect freeSlots if it suits your design, ignore it if it does not.

Provider allies never touch the hub

They are fielded as temporary allies: they fight the battle and are gone, never carried between levels and never materialised into the hub afterwards. This is not a limitation, it is the only correct behaviour - a provider ally was never a hub entity, so putting one "back" would spawn a real creature into the world that your mod is still tracking in its own party, leaving the player with two of it.

If you want a creature's damage or state to persist across a run, keep that in your own state and reflect it in the stats you hand back next time.

Ordering and failure

Providers are asked after real hub pets are collected, so freeSlots reflects what the hub already took and a provider can never displace a pet the player explicitly tagged.

Every registered provider is asked and the results are concatenated, so two mods can both field allies without knowing about each other. The key exists so a provider can be replaced rather than duplicated. A provider that throws is logged and skipped - one broken addon costs its own allies, not everyone else's, and never the fight.

Spawn customizers run on provider allies too. The spawn NBT is applied and then the spawn customizer for the ally's aiKey fires, exactly as on the enemy path. That is the route a data-driven party arrives through, so a creature that needs initialising through its own mod's API gets initialised here.

Reserves and switching

A party larger than the field is the point of having a party. Override reserves to send creatures into the fight on the bench - carried along, given no tile and no mob in the world, and fielded only when the player swaps one in.

CrafticsAPI.registerFieldAllyProvider("mymod:party", new FieldAllyProvider() {
    @Override
    public List<FieldAlly> provide(ServerWorld world, ServerPlayerEntity player, int freeSlots) {
        return MyModApi.partyOf(player).active().stream().map(MyMod::toFieldAlly).toList();
    }

    @Override
    public List<FieldAlly> reserves(ServerWorld world, ServerPlayerEntity player) {
        return MyModApi.partyOf(player).benched().stream().map(MyMod::toFieldAlly).toList();
    }
});

Reserves are built from the same FieldAlly as field allies and follow the same rules once they take the field - same stats, same aiKey, same spawn NBT, still temporary. reserves defaults to an empty bench, so a provider written before this existed keeps working and reads correctly: it has no reserves, rather than an unanswered question.

Asking for a switch

Craftics does not draw the menu. Read the bench, show it however you like, and ask for the swap:

for (BenchedAlly benched : CrafticsAPI.benchedAllies(player)) {
    // benched.index(), .displayName(), .hp(), .maxHp(), .aiKey()
}

CrafticsAPI.switchFieldAlly(player, outgoingAllyEntityId, benched.index());

This is the same split the combat tools use: register a tool, open your own screen from its onUse, and call back in. Craftics owns what a switch means - it costs 1 AP, it happens on your turn, the incoming creature takes the tile the outgoing one vacated - and your mod owns the screen the player picked from.

A benched ally is addressed by index, not by entity id, because it has no entity: no mob in the world, no tile on the grid, nothing to be found by. That absence is what being benched is. Indices are only valid until the next swap reorders the bench, so read it again rather than holding them.

A creature keeps what it was carrying. Bench a wounded, poisoned ally and it returns wounded and poisoned, with any summon timer still running. A bench that healed would make swapping the cheapest heal in the game.

Only provider allies have a bench. Craftics' own hub pets do not and are not getting one. A hub pet is a real animal that was standing in your yard and is owed back to it afterwards; one that is neither in the yard nor in the fight is an animal in no place at all, and every end-of-fight path would have to be taught about it before that could be safe. Your creatures are data your mod already holds, so a bench costs them nothing.

A switch is refused - with a message to the player and no AP spent - when it is not their turn, when the ally is not theirs, when it is one of Craftics' hub pets, when someone is riding it, or when the incoming creature is too large for the tile being vacated.

The Bench, and Switching Off It

A field ally provider can declare reserves alongside the allies it fields: creatures carried into the fight with no tile and no mob in the world, fielded only when the player swaps one in.

A party larger than the field is the point of having a party. Six creatures where three fight is a different game from six all swinging at once, and choosing which three is the interesting part.

Declaring reserves

CrafticsAPI.registerFieldAllyProvider("mymod:party", new FieldAllyProvider() {
    @Override
    public List<FieldAlly> provide(ServerWorld world, ServerPlayerEntity player, int freeSlots) {
        return activeThree(player);     // what starts on the grid
    }

    @Override
    public List<FieldAlly> reserves(ServerWorld world, ServerPlayerEntity player) {
        return theRest(player);         // carried, benched, swappable
    }
});

reserves is a defaulted method returning an empty list, so a provider written before the bench existed keeps working and reads correctly: it has no reserves, not an unanswered question.

Reading the bench and switching

for (BenchedAlly b : CrafticsAPI.benchedAllies(player)) {
    // b.index(), b.entityTypeId(), b.aiKey(), b.displayName(), b.hp(), b.maxHp()
}

boolean ok = CrafticsAPI.switchFieldAlly(player, outgoingAllyEntityId, reserveIndex);

Reserves are addressed by index, not entity id. A benched ally has no entity - no mob, no tile, nothing to be found by. That absence is what being benched is, so an id would have to be invented for something that does not exist.

What a switch costs and when it is refused

A switch costs 1 AP and places the incoming creature on the tile the outgoing one vacated. switchFieldAlly returns false, with no AP spent, when:

A benched creature keeps everything

Bench a wounded, poisoned ally and it returns wounded and poisoned, with its summon timer still running. The combatant itself goes to the bench rather than being rebuilt from its definition, so every scrap of per-fight state rides along - including state added to Craftics later that nobody remembers to copy. A bench that healed would be the cheapest heal in the game.

No menu, and only provider allies

Craftics owns what a switch means; the addon owns the screen the player picked from. Read the bench, draw it however you like, ask for the swap - the same split the combat tools use.

Craftics' own hub pets deliberately have no bench. A hub pet is a real animal that was standing in your yard and is owed back to it; one that is neither in the yard nor in the fight is an animal in no place at all. Every end-of-fight path filters on exactly the flag that excludes it, and each would have to learn about a bench before one could be safe - where a miss duplicates the animal and a false positive destroys it, since the hub copy was discarded when the party was collected.

Trainers and Enemy Benches

The mirror of the player's bench. Any enemy can carry a team of creatures it fields one at a time, withdrawing and sending out as the fight turns against it.

This is deliberately not a boss mechanic. A route trainer with three creatures is the ordinary case and a gym leader is the same thing with better ones, so the bench lives on CombatEntity rather than on anything boss-shaped.

Giving an enemy a bench

A benched creature is described by an EnemyBench rather than by a live entity, because it has no entity yet - no mob, no tile, nothing to be found by. Stats are required for the same reason: there is no world entity to derive them from.

CombatEntity leader = /* the trainer on the field */;

leader.getBench().add(
    EnemyBench.builder("cobblemon:pokemon")
        .stats(AllyEntry.builder("cobblemon:pokemon")
            .hp(120).attack(14).defense(8).range(1)
            .build())
        .aiKey("cobblemon:onix")        // AI, typing and spawn hooks key off this
        .displayName("Brock's Onix")
        .spawnNbt(onixNbt)              // merged onto the mob when it is fielded
        .build());

aiKey is what lets one entity type field a whole team. A mod that renders hundreds of creatures through a single entity type would otherwise field them all blank and identical. AI, typing and spawn customizers are all looked up by the AI key first and the entity type second. Give each creature its own displayName too, or a team sharing an entity type reads as several copies of one creature.

Switching, and sending out

Two calls, for two different situations. Reach for the wrong one and it will refuse rather than improvise.

CallUse it whenThe bench entry
CrafticsAPI.switchEnemy(anyParticipant, trainer, outgoing, reserveIndex) Answering a bad matchup, with a creature still on the field. The incoming one takes the tile the outgoing one vacates. Swapped. The outgoing creature goes back to the bench in its place.
CrafticsAPI.sendOutEnemy(anyParticipant, trainer, reserveIndex, tile) Opening a trainer fight, or answering a knockout, with nothing to withdraw. You choose the tile. Removed. Nothing is coming back to take its place.
// The leader's next creature, after the last one fainted.
if (!leader.getBench().isEmpty()) {
    CrafticsAPI.sendOutEnemy(player, leader, 0, new GridPos(4, 2));
}

// Rotating out of a bad matchup, with one still standing.
CrafticsAPI.switchEnemy(player, leader, fielded, 1);

Both field the incoming creature through the same path a level's own enemies take, so its spawn NBT, its spawn customizer, its AI key and its typing land exactly as if it had started the fight on the grid.

When they refuse

Each returns false and changes nothing - the bench included - rather than improvising a placement the player did not expect. A switch that silently repositions is worse than one that says no.

A withdrawn creature keeps its damage and its status effects. It is captured from its live state rather than rebuilt from its definition, for the same reason the player's bench keeps the combatant itself: a switch that healed would be the cheapest heal in the fight, and a trainer would simply rotate its team instead of ever losing.

Clicking an ally

On its own Craftics does exactly one thing when a player clicks their own ally: if they are holding that ally's registered heal item, it heals. Everything else is refused with a message, and the turn is not spent.

That is far too narrow for an addon whose allies are the game. Clicking your own creature to open its moves, use an item on it, or give it an order is the most natural gesture there is, and there was no route in - grid clicks arrive on Craftics' own packet and go straight to the attack path, so no Fabric event sees them.

CrafticsAPI.registerAllyClickHandler((player, ally, held) -> {
    if (!"mymod:creature".equals(ally.getEntityTypeId())) return false;   // not ours

    if (held.isOf(MyItems.POTION)) {
        useItemOn(player, ally, held);
        return true;
    }
    openMoveMenu(player, ally);
    return true;
});

Commanding an ally

The player's side of the same idea. CrafticsAPI.commandAlly(player, ally, action) leaves a standing order that overrides the ally's own judgement on its next turn, and is consumed as it is read so it is obeyed exactly once. Any EnemyAction works, custom actions included.

// The move the player picked from your own UI.
ally.setPendingAttackType("cobblemon:water");   // typing for this one move
ally.setPendingAccuracy(0.85);                  // and its accuracy

CrafticsAPI.commandAlly(player, ally, new EnemyAction.CustomAction(
    "mymod:surf", List.of(targetTile), 12, params));

Set the attack type and accuracy on the ally when you issue the order, not when it resolves. Both ride along with the order and are read on the ally's turn; the order decides what, never how it resolves, so a commanded move goes through the same damage, typing and accuracy handling an AI-chosen one does.

A blinded ally still swings wide at a commanded move: blindness scales whatever accuracy the order brought rather than replacing it, so an already-unreliable move gets worse rather than better.

Accuracy

Attacks can miss. Accuracy is a per-action multiplier living beside the attack type, on the same slot with the same lifecycle: set for the one action an AI is about to name, cleared before the next decision.

A creature whose movepool holds a wild haymaker and a reliable jab needs the haymaker to land less often, and nothing about the defender can express that.

Setting it

@Override
public EnemyAction decideAction(CombatEntity self, GridArena arena, GridPos playerPos) {
    Move move = pickMove(self);
    self.setPendingAttackType(move.typeId());   // what it is
    self.setPendingAccuracy(move.accuracy());   // how often it lands, 0.0 - 1.0
    return new EnemyAction.MoveAndAttack(path, move.power());
}
ConstantValueMeaning
AccuracyRoll.NO_OVERRIDE-1.0Use the default. Every attack in Craftics itself.
AccuracyRoll.DEFAULT1.0Always lands.
AccuracyRoll.FLOOR5Lowest hit chance, as a percentage - nothing is ever hopeless.
AccuracyRoll.CAP100Highest hit chance.
AccuracyRoll.BLINDED_MULTIPLIER0.5Applied to an ally blinded for the turn.

How it behaves

Custom Actions

EnemyAction is a sealed set of about forty shapes - Move, Attack, Pounce, Teleport, Explode and so on. An AI returns one and the turn machine carries it out. Composing those shapes covers a great deal, but not an action whose resolution is genuinely new.

For that, return an EnemyAction.CustomAction and register a handler for its id. Craftics recognises that one shape and hands resolution straight back to you.

Why this instead of an open interface. Sealing is load-bearing: the turn machine dispatches with pattern-matching switches, and the compiler checks they cover every shape. Unsealing so addons could add their own would give that checking up across dozens of sites, and the failures would be silent - an unhandled action would look like an enemy that just stands there. One extra member of the set keeps the guarantee for the other thirty-nine.

Registering a handler

CrafticsAPI.registerCustomAction("mymod:flamethrower", ctx -> {
    for (GridPos tile : ctx.tiles()) {
        CombatEntity victim = ctx.arena().getOccupant(tile);
        if (victim != null) ctx.damage(victim, ctx.damage());
    }
    ctx.message("§cThe flames wash down the lane!");
});

Returning one from an AI

@Override
public EnemyAction decideAction(CombatEntity self, GridArena arena, GridPos playerPos) {
    List<GridPos> lane = AIUtils.tilesInLine(self.getGridPos(), playerPos, 4);
    return new EnemyAction.CustomAction("mymod:flamethrower", lane, 6);
}

Getting a telegraphed wind-up for free

Wrap the custom action in a BossAbility and it inherits the entire charge-up system: the warning tiles are painted, the windup VFX plays across the player's turn - camera shake, per-tile marks, a gesture matched to the attack's shape - and the handler fires a turn later when it resolves.

return new EnemyAction.BossAbility(
    "mymod:flamethrower",                                        // names the VFX category
    new EnemyAction.CustomAction("mymod:flamethrower", lane, 6),  // what resolves next turn
    lane);                                                        // tiles to warn on

The ability name is matched against keywords to pick a windup gesture, so naming it something containing beam, line, slam, charge, summon or pull gets the matching telegraph automatically.

The Context

Handlers are handed a context rather than raw fields, and the damage and movement methods on it are not conveniences - they are the supported route.

MethodReturnsDescription
self()CombatEntityThe enemy performing the action.
arena()GridArenaThe arena, for tile and occupant queries.
world()ServerWorldFor particles and sounds.
playerPos()GridPosWhere the targeted player is standing.
tiles()List<GridPos>The tiles the AI named. Empty if none.
params()NbtCompoundParameters the AI attached. Empty compound if none.
damage()intThe damage figure the AI attached, or 0.
damage(target, amount)voidDeal damage through Craftics' own pipeline. Routes by whose action it is - see below.
damagePlayer(amount)voidDamage the player outright, rather than being inferred into it.
moveSelfTo(dest)booleanMove the actor, respecting grid rules.
heal(target, amount)voidHeal a combatant. An action is not always an attack.
applyEffect(target, type, turns, amplifier)voidApply a built-in status effect through the same path an item uses.
applyCustomEffect(target, effectId, turns, amplifier)voidApply an addon-registered effect by id.
message(text)voidSend a line to everyone in the fight.

Use ctx.damage(), not the entity's HP. It routes through the same path built-in attacks take, so resistances, attack typings, shields, status effects and death processing all apply. Writing HP directly skips every one of those, and the resulting bug looks like a balance problem rather than a missing call. moveSelfTo() is the same bargain for movement: it refuses solid tiles, teleports the world entity to match the grid, and drops the mob into a pit if it lands on one.

One handler, either side

The same registered handler resolves whether an enemy or one of the player's allies performs it, so a move behaves identically in either hand. Register it once and use it for both.

ctx.damage() routes by the side ctx.self() is on. An enemy's action hits allies, and anything that is neither an ally nor the actor is taken to be the player. An ally's action hits creatures only and never falls through to the player - so a pet's own move cannot end up striking its owner. Say ctx.damagePlayer(amount) when you mean the player, from either side.

Attack-type effectiveness is applied for you on creature targets, so pass the move's base figure and let the chart do its work. It is a no-op when the actor never declared a typing.

// Works unchanged for an enemy Pokemon and for one of yours.
CrafticsAPI.registerCustomAction("mymod:earthquake", ctx -> {
    for (GridPos tile : ctx.tiles()) {
        CombatEntity victim = ctx.arena().getOccupant(tile);
        if (victim != null && victim != ctx.self()) ctx.damage(victim, 10);
    }
    // Named explicitly, so it lands whichever side threw the move.
    if (ctx.tiles().contains(ctx.playerPos())) ctx.damagePlayer(10);
    ctx.message("§6The ground shakes!");
});

What happens around the handler

The handler is the action: damage, movement, effects, particles and messages are all yours. Craftics still owns the turn rotation, death checks on anything you damaged, and the telegraph if you wrapped the action in a BossAbility.

An unregistered id costs that enemy its turn and logs once. That is deliberate - an addon can be uninstalled while a save still holds an AI naming its actions, and a missing handler should not wedge the fight waiting for something that will never resolve. A handler that throws is caught with the same outcome.

Spawn Customization

Craftics builds every combatant the same way: look the entity type up in the registry and create it bare. That is everything a zombie needs, because a zombie's entity type is its identity. It is not enough for anything whose identity lives somewhere else.

The case that forces the issue is a mod that ships one entity type for hundreds of creatures and records which creature it is in NBT or a component. Created bare, every single one spawns blank and identical. Variants, mobs that should arrive holding something, and pre-tamed allies all hit smaller versions of the same wall.

Two mechanisms solve it, and they cover different ground. Use the first where you can.

Spawn NBT (no Java required)

Any enemy or ally entry can carry NBT that is merged onto the mob the instant it spawns, before it takes its first turn. This is authorable from a datapack, so a pack with no code at all can field variant mobs.

In biome JSON, add an "nbt" field written as an SNBT string. That is the same syntax /summon takes, so it can be copied straight out of a working command:

{
  "type": "mymod:creature",
  "nbt": "{Variant:3,Tame:1b}",
  "ai": "mymod:charizard",
  "weight": 5,
  "hp": 14,
  "attack": 4
}

From Java, the same thing on an EnemyEntry or AllyEntry:

EnemyEntry.builder("mymod:charizard", "mymod:creature")
    .ai("mymod:charizard")
    .hp(14).attack(4).range(2)
    .spawnNbt(StringNbtReader.parse("{Species:\"charizard\",Level:36}"))
    .build();

A pool entry's "nbt" overrides the template's; otherwise the entry inherits it, so an enemy registered once with its tags can be dropped into any biome without restating them. A malformed SNBT string is logged and dropped rather than failing the biome, so one bad tag costs that entry its extras instead of taking the level definition down.

The arena flags survive the merge, deliberately. Applying NBT to a live entity reloads its whole serialized state, so an authored tag - or the entity's own defaults for keys the tag omits - would happily restore NoAI, NoGravity, Invulnerable, Silent and the command tags. Craftics sets those to pin a mob to its grid tile and keep it out of vanilla AI entirely. They are re-applied after every merge. Your NBT decides what the mob is; the arena decides how it is held.

Spawn Customizer (code hook)

For anything NBT cannot say - typically an entity that has to be initialised through its own mod's API rather than by writing tags onto it - register a SpawnCustomizer. It runs once on the live entity, after Craftics has created, positioned and tagged it.

CrafticsAPI.registerSpawnCustomizer("mymod:charizard", (world, mob, entity) -> {
    MyModApi.setSpecies(mob, "charizard");
    MyModApi.setLevel(mob, 36);
});

The lookup order is the whole trick. The key is matched against the combatant's aiKey first and its entity type id second. That is what makes one-entity-type-many-creatures work: give each creature its own aiKey and each gets its own initialisation, while they all share a single entity type. Keying on the entity type alone would hand all of them the same hook.

ParameterTypeDescription
world ServerWorld The arena world.
mob MobEntity The live entity, already positioned and tagged craftics_arena.
entity CombatEntity The Craftics combatant wrapping it, carrying its stats, grid position and aiKey. Stats and footprint are already set.

Both mechanisms run when both are present, NBT first, so a customizer always sees the tagged entity and can correct or extend it rather than fight it. Both apply to enemies and to summoned allies.

A customizer that throws is caught and logged. By the time it runs the mob is already spawned and placed, so letting the exception out would abandon the rest of the arena build - one enemy missing its extras beats a half-populated fight.

Attack Types

An attack type is a trait of an attack that decides how well it lands against a defender, and nothing else. It is a third idea alongside two that already exist, and keeping them apart is the point:

ConceptLevellable?What it decides
Affinity Yes - players spend points in it How much harder a player's attacks of that kind hit.
DamageType No Which affinity a weapon scales from, plus its AP cost profile.
AttackType No - never A multiplier from what the attack IS versus what the defender IS.

They are orthogonal. A weapon can be SLASHING damage - so it scales from the player's Slashing affinity - while being typed mymod:fire, so it lands hard on a grass defender and poorly on a water one. Retyping the weapon changes nothing about what the player levels to improve it.

Registering a chart

Effectiveness is authored once per attacking type as a chart of what it is good and bad against. Only the interesting matchups are written down; anything unlisted is 1.0.

CrafticsAPI.registerAttackType(AttackTypeEntry.builder("mymod:fire")
    .displayName("Fire").colorCode("§c")
    .superEffectiveAgainst("mymod:grass", "mymod:ice", "mymod:bug")
    .notVeryEffectiveAgainst("mymod:water", "mymod:rock", "mymod:fire")
    .noEffectAgainst("mymod:stone_idol")
    .build());
Builder MethodMultiplierDescription
superEffectiveAgainst(String...)2.0Double damage against each listed defending type.
notVeryEffectiveAgainst(String...)0.5Half damage against each listed defending type.
noEffectAgainst(String...)0.0No damage at all. Absorbing - nothing later can bring it back up.
against(double, String...)customAny multiplier, for charts that want something other than the usual three.

Typing an attack and typing a defender

A weapon declares what it is:

WeaponEntry.builder(MyItems.FLAME_FANG)
    .damageType(DamageType.SLASHING)   // scales the Slashing affinity
    .attackType("mymod:fire")          // lands as Fire
    .attackPower(9).apCost(1).range(1)
    .build();

A defender declares what it is. The key follows the same aiKey-then-entity-type rule as spawn customizers, so a single entity type can still carry per-creature typings:

CrafticsAPI.setDefendingTypes("mymod:charizard", "mymod:fire", "mymod:flying");
CrafticsAPI.setDefendingTypes("mymod:squirtle",  "mymod:water");

A defender with more than one type multiplies its way through them, so being strong against one and weak against the other cancels to 1.0. Players see a line on the hit - "It's super effective!", "It's not very effective...", "It has no effect..." - so effectiveness is legible without a wiki.

Why a chart instead of per-mob resistance tables. Craftics' existing MobResistances lists resistances per mob id, which is right for a few dozen hand-authored mobs. A roster of a thousand creatures across eighteen types needs eighteen chart entries and one line per creature; the per-mob shape would need eighteen thousand cells. Both systems run, and their multipliers stack, so an addon can use either or both.

Typing works in every direction

A weapon's attackType covers the player attacking. That is one of four directions, and a chart that only applies to one of them is half a type system - a Fire enemy would hit your Water ally at full strength, and nothing would tell the player the matchup mattered.

Attacker → DefenderAttacking type comes fromDefending types come from
Player → enemy WeaponEntry.attackType() setDefendingTypes(mobKey, ...)
Enemy → ally setDefaultAttackType or the AI's per-action override setDefendingTypes(mobKey, ...)
Ally → enemy setDefaultAttackType or the AI's per-action override setDefendingTypes(mobKey, ...)
Enemy → player setDefaultAttackType or the AI's per-action override setPlayerDefendingTypesProvider

Typing a mob's own attacks

Set it once per creature and its melee, ranged and ability damage are all typed:

CrafticsAPI.setDefaultAttackType("mymod:charizard", "mymod:fire");

A creature with a movepool overrides it per action from inside its AI, immediately before returning the action:

@Override
public EnemyAction decideAction(CombatEntity self, GridArena arena, GridPos playerPos) {
    Move move = pickMove(self);
    self.setPendingAttackType(move.typeId());     // this action only
    return new EnemyAction.MoveAndAttack(path, move.power());
}

The override is cleared before every decision, not after every hit. A per-action type is only meaningful for the action the AI is about to name; leaving a stale one set would silently retype every later attack that never asked for one. So an AI that sets it must set it each turn, which is also the only way a movepool can work.

Typing the player as a defender

The player has no aiKey to look up, so their defending types come from a provider. A function rather than a fixed list, because a player's typing usually derives from something that changes during a run:

CrafticsAPI.setPlayerDefendingTypesProvider(player -> {
    Creature active = MyModApi.activeCreatureOf(player);
    return active == null ? List.of() : active.types();
});

It is called on every hit the player takes, so keep it cheap. Returning null or an empty list means untyped, which is the default and leaves incoming damage behaving exactly as it did before typings existed.

A mount intercepting a hit is judged as the mount. When a saddled ally takes a blow aimed at its rider, effectiveness is worked out against the animal's types, not the player's - the animal is the thing being hit.

How the multiplier is applied

A landed hit floors at 1 in every direction, matching how mob resistances already behave, so a heavily resisted attack still chips rather than silently doing nothing. A no-effect matchup is the single case that reaches zero, and immunity is absorbing - once a defender's type list zeroes the multiplier, nothing later in the list brings it back.

Effectiveness stacks with MobResistances rather than replacing it. The two answer different questions - one is keyed on DamageType, which also decides which affinity a weapon scales from, the other purely on the matchup - so an addon can use either or both.

The whole system is inert until something opts in. An untyped weapon, an unregistered type or an untyped defender all return 1.0, which is why it can sit in the damage path unconditionally without changing any existing behaviour.