Creed's Noob Guide to Scripted AI Maps - Robosturm/Commander_Wars GitHub Wiki

This guide shows how to make certain units follow mission-specific behavior. It works for units already on the map and units built later at factories.

When to use a map script

Use a map script for behavior that belongs to one mission.

Good uses include:

  • retreating to a specific HQ
  • protecting a certain unit
  • staying in formation
  • waiting until attacked
  • following waypoints
  • changing behavior after reaching a location
  • triggering reinforcements or dialogue

Use a unit script or a global AI change only when every copy of that unit type should behave that way on many maps.

Locate the script the map actually loads

A .map file stores the path to its script. The script does not have to be inside the same folder as the map.

For example, the remastered NC 01 It's War.map uses this script:

maps/AW1 Campaign AI Remaster/NC 01 It's War.js

Keeping that path inside the remaster folder avoids replacing the original campaign script.

Before changing anything, check:

map.getGameScript().getScriptFile()

That tells you which script the map really uses.

Develop from a sidecar and release with an embedded copy

Keep the .map file and its mission .js file next to each other while working.

Commander Wars first tries the script path stored in the map. When that path is missing, it can look for a script with the same filename beside the map.

The map also stores a copy of the loaded JavaScript inside the map file. That means changing only the script path can leave an old embedded copy behind.

Use a clear-then-set relink:

menu.loadMap(mapPath);
var map = menu.getMap();
var gameScriptObject = map.getGameScript();
gameScriptObject.setScriptFile("");
gameScriptObject.setScriptFile(
    "maps/AW1 Campaign AI Remaster/NC 01 It's War.js");
menu.saveMap(mapPath);

The empty string clears the old embedded script. The next line stores the new script path. On a fresh load, Commander Wars reads the sidecar script from disk.

To place the latest script inside the map for release:

  1. Start a fresh Commander Wars process.
  2. Load the development map.
  3. Let the map load the current sidecar script.
  4. Save the map again without changing the script field.

Saving stores the loaded script text inside the map.

Keep the sidecar script in the release folder too. Editing the sidecar later does not update the copy already stored inside the map. After every script change, reload and save again before releasing the map.

Test from the real game installation folder. Relative maps/... paths use the game's virtual filesystem.

Preserve the original scenario

Start from the original mission script whenever one exists.

Keep its existing mission features, including:

  • immediateStart
  • opening dialogue
  • victory dialogue
  • victory rules
  • fog and weather settings
  • CO-power settings
  • campaign build lists
  • campaign progression hooks

Add the new unit behavior to that script. Do not replace the whole mission with a tiny unrelated script.

The predefined-unit hook

Commander Wars can call this function when it wants a scripted unit to choose an action:

this.GenericPredefinedUnitMapScriptBehaviour = function(
    ai, action, unit, enemies, enemyBuildings, map)
{
    return MissionAi.handleUnit(action, unit, enemies, map);
};

The arguments mean:

ai: the AI currently taking its turn action: an empty action object your script fills in unit: the unit making a decision enemies: enemy units enemyBuildings: enemy properties map: the current map

Return true only after building a valid action.

Return false when your script does not control that unit. Commander Wars can then use its normal AI.

The engine checks action.canBePerformed() afterward. Returning true with an unfinished action does not help.

AI modes

GameAi_Normal: Uses normal AI GameAi_Offensive: Uses aggressive built-in behavior GameAi_Defensive: Attacks any nearby targets, then holds GameAi_Hold: Stays in place and fires when possible from the tile it is holding GameAi_Patrol: Follows a patrol route GameAi_PatrolLoop: Repeats a patrol route GameAi_TargetEnemyHq: Moves toward an enemy HQ and attacks along the way GameAi_Scripted: Lets JavaScript choose the action

Use GameAi_Scripted for custom behavior.

Use GameAi_Hold for a defender that should stay still but shoot.

Move the unit back to a built-in mode when its scripted job is finished.

Give units persistent roles

Starting coordinates only identify a unit before it moves. Give the unit a role during gameStart, then store that role on the unit.

var ROLE_RETREATING_ROCKET = "MISSION_ROLE_RETREATING_ROCKET";

function tagUnit(unit, role)
{
    if (unit === null)
    {
        return;
    }
    unit.getVariables().createVariable(role).writeDataBool(true);
    unit.setAiMode(GameEnums.GameAi_Scripted);
}

Read the role like this:

function hasRole(unit, role)
{
    var value = unit.getVariables().getVariable(role);
    return value !== null && value.readDataBool();
}

Do not use createVariable just to check whether a role exists during an AI decision.

AI lookahead and other one-sided hooks can run a different number of times on different clients. Creating variables while reading changes the game state and can break synchronized games.

The old transported-unit hash problem was fixed but you should still practive sync-safe coding practices.

Simple rule:

  • Write with createVariable(...).writeDataX(...).
  • Read with getVariable(...) and check for null.

Find a unit with a role after it moves

For a small campaign map, scanning the map is fine when one unit needs to find another unit.

function findRoleUnit(map, owner, role)
{
    for (var y = 0; y < map.getMapHeight(); ++y)
    {
        for (var x = 0; x < map.getMapWidth(); ++x)
        {
            var unit = map.getTerrain(x, y).getUnit();
            if (unit !== null && unit.getOwner() === owner && hasRole(unit, role))
            {
                return unit;
            }
        }
    }
    return null;
}

For very large maps or many groups, store identifiers in script variables instead of scanning every tile again and again.

Coordinate factory-built reinforcements without writing a general AI

You do not need to replace the normal AI just because the mission builds units.

Let the normal AI buy units. Let the map script temporarily give useful units mission jobs.

The reusable coordinator only needs to answer a few questions:

  • What can this unit do?
  • Which mission objectives are active?
  • Which role slots are still empty?
  • Which unit can safely reach each role?
  • When should that role end?
  • When should the unit return to normal AI?

The map still provides the mission knowledge: chokepoints, support positions, routes, capture goals, safety limits, mission phases, and the mix of units you want.

Use turnStart and not a map-level buildedUnit hook

Unit and CO scripts can use buildedUnit. A map game script does not directly receive that callback.

This will not work in a map script:

this.buildedUnit = function(unit, player, map)
{
    // A map game script is not called here.
};

Scan the AI player's deployed units at turnStart instead:

this.turnStart = function(turn, player, map)
{
    if (player === MissionDirector.AI_PLAYER)
    {
        MissionDirector.reconcileAssignments(turn, map);
    }
};

A newly built unit has already used its production turn. At the start of its next turn, this scan finds it before the AI acts.

The same scan also handles:

  • units placed on the map from the start
  • units that survived a save and reload
  • joined units
  • replacement units

turnStart runs symmetrically, so writing role variables there is safe.

Keep the predefined-unit AI hook read-only for role variables.

For units loaded inside transports, inspect them through getLoadedUnitCount() and getLoadedUnit(index). Otherwise, wait until they unload and enter the normal map scan.

Separate the reusable coordinator from the mission plan

Use one reusable coordinator. Give each map its own mission plan.

Reusable coordinator Mission-specific plan
Scan live AI units Objective coordinates
Read and write assignments Chokepoints and support tiles
Check unit capabilities Number of units wanted for each role
Validate assignments Role priority
Fill open roles Activation and completion rules
Release stale assignments Safety and aggression rules
Send units to role handlers Fallback behavior
Break ties in a stable way Optional unit-ID preferences

This keeps the reusable code focused on pathfinding, threat checks, and role bookkeeping. The mission file still decides what matters on that map.

Describe each objective before writing behavior

Write down the plan for every important area first.

Field Plain question
Objective ID What number identifies this objective?
Active condition When does this objective start?
Success condition What counts as finished?
Failure condition When is it no longer possible?
Anchor tiles Which tiles matter most?
Support tiles Where should support units stand?
Approach lanes Which routes can reach the area?
Role slots How many screeners, indirects, capturers, or escorts are needed?
Eligibility Which units can fill each role?
Safety rules Which threats or damage levels are unacceptable?
Action priority Should the unit fire, retreat, block, capture, move, or wait first?
Fallback Should it get a new job, defend nearby, or return to normal AI?

A simple plan can look like this:

var OBJECTIVE_NONE = 0;
var OBJECTIVE_WEST_CHOKE = 1;
var WEST_SCREEN_COUNT = 2;
var WEST_INDIRECT_COUNT = 1;

var MissionPlan =
{
    objectives:
    [
        {
            id: OBJECTIVE_WEST_CHOKE,
            anchorTiles: WEST_CHOKE_TILES,
            supportTiles: WEST_SUPPORT_TILES,
            screenCount: WEST_SCREEN_COUNT,
            indirectCount: WEST_INDIRECT_COUNT
        }
    ]
};

Keep coordinates, activation rules, and fallback rules inside the mission plan. Keep scanning and assignment mechanics in the reusable coordinator.

Store one role and one objective on each assigned unit

A few permanent units can use simple true/false variables. Reinforcements are easier to manage with integer role IDs and integer objective IDs.

var MissionDirector =
{
    AI_PLAYER: 1,
    VAR_ROLE: "MISSION_DYNAMIC_ROLE",
    VAR_OBJECTIVE: "MISSION_DYNAMIC_OBJECTIVE",
    ROLE_NORMAL: 0,
    ROLE_SCREEN: 1,
    ROLE_INDIRECT_SUPPORT: 2,
    ROLE_CAPTURE: 3,
    OBJECTIVE_NONE: 0
};

MissionDirector.readInt = function(unit, variableId, fallback)
{
    var value = unit.getVariables().getVariable(variableId);
    return value === null ? fallback : value.readDataInt32();
};

MissionDirector.assign = function(unit, role, objective)
{
    var variables = unit.getVariables();
    variables.createVariable(MissionDirector.VAR_ROLE).writeDataInt32(role);
    variables.createVariable(MissionDirector.VAR_OBJECTIVE)
        .writeDataInt32(objective);
    if (role === MissionDirector.ROLE_NORMAL)
    {
        unit.setAiMode(GameEnums.GameAi_Normal);
    }
    else
    {
        unit.setAiMode(GameEnums.GameAi_Scripted);
    }
};

Use writeDataInt32. Commander Wars currently does not mark writeDataString changes as modified for hash serialization. Integer assignments are safer for saves and synchronized play.

Call MissionDirector.assign only from symmetric hooks such as gameStart, turnStart, and actionDone.

Inside GenericPredefinedUnitMapScriptBehaviour, only read variables. Do not create or change them there.

Classify by capabilities first

Check what a unit can do instead of forcing every unit into one exclusive class.

MissionDirector.isIndirect = function(unit)
{
    return unit.getBaseMinRange() > 1;
};

MissionDirector.isCapturer = function(unit)
{
    return unit.canCapture();
};

MissionDirector.isTransport = function(unit)
{
    return unit.getLoadingPlace() > 0;
};

MissionDirector.isDirectCombat = function(unit)
{
    return unit.hasDirectWeapon() && unit.getBaseMaxRange() === 1;
};

MissionDirector.isAir = function(unit)
{
    return unit.getUnitType() === GameEnums.UnitType_Air;
};

MissionDirector.isNaval = function(unit)
{
    return unit.getUnitType() === GameEnums.UnitType_Naval;
};

These traits can overlap. Infantry can capture and fight directly. An aircraft can be a direct attacker or a transport.

Use broad capability checks first. Add a short mission-specific allowlist when the exact unit type matters.

Example: a Medium Tank may be good for a high-damage screen while an Anti-Air is not, even though both count as direct combat units.

For anti-air jobs, compare candidates against the air units currently on the map with getBaseDamage(enemyAirUnit). Before any air threat exists, use known anti-air unit IDs as a preference.

Scan all deployed units in a stable order

Scan from top to bottom and left to right. Keep only the mission AI's units.

MissionDirector.scanUnits = function(map)
{
    var units = [];
    for (var y = 0; y < map.getMapHeight(); ++y)
    {
        for (var x = 0; x < map.getMapWidth(); ++x)
        {
            var unit = map.getTerrain(x, y).getUnit();
            if (unit !== null &&
                unit.getOwner().getPlayerID() === MissionDirector.AI_PLAYER)
            {
                units.push(unit);
            }
        }
    }
    return units;
};

A stable scan keeps equal-score choices predictable. Use unit.getUniqueID() as the final tiebreaker when needed.

Reconcile assignments instead of rebuilding them blindly

At the start of each AI turn:

  1. Scan all living AI units.
  2. Read each unit's current role and objective.
  3. Remove assignments that no longer make sense.
  4. Keep assignments that are still useful.
  5. Count open role slots.
  6. Fill higher-priority roles first.
  7. Leave unneeded units in GameAi_Normal.

Do not reshuffle units every turn just because one unit became slightly closer to an objective. Change assignments when the old role stops being valid or the mission moves into a new higher-priority phase.

When choosing a replacement, consider:

  • full-map route cost to the role's destination
  • whether that movement type can reach the destination at all
  • HP, ammo, and fuel
  • expected damage at possible staging tiles
  • ability to hurt the threats at the objective
  • whether moving the unit would break another formation
  • mission-specific unit preferences

Use full-map Dijkstra costs for ground and naval units. Manhattan distance is fine for aircraft.

Treat an enemy standing on a future route as a temporary expensive blocker, not a permanent wall. The one-turn engine pathfinder still decides what movement is legal right now.

Give each role a narrow tactical handler

The coordinator chooses who gets the job. The role handler chooses what that unit does.

MissionDirector.handleUnit = function(action, unit, enemies, map)
{
    var role = MissionDirector.readInt(
        unit,
        MissionDirector.VAR_ROLE,
        MissionDirector.ROLE_NORMAL);
    var objective = MissionDirector.readInt(
        unit,
        MissionDirector.VAR_OBJECTIVE,
        MissionDirector.OBJECTIVE_NONE);

    if (role === MissionDirector.ROLE_SCREEN)
    {
        return MissionDirector.handleScreen(
            action, unit, objective, enemies, map);
    }
    if (role === MissionDirector.ROLE_INDIRECT_SUPPORT)
    {
        return MissionDirector.handleIndirect(
            action, unit, objective, enemies, map);
    }
    if (role === MissionDirector.ROLE_CAPTURE)
    {
        return MissionDirector.handleCapturer(
            action, unit, objective, enemies, map);
    }
    return false;
};

The map hook stays tiny:

this.GenericPredefinedUnitMapScriptBehaviour = function(
    ai, action, unit, enemies, enemyBuildings, map)
{
    return MissionDirector.handleUnit(action, unit, enemies, map);
};

Do not call helpers through this inside engine-called hooks. The engine does not bind this to your mission object. Call the global coordinator by name.

A role handler should usually follow this order:

  1. Escape a dangerous situation when survival takes priority.
  2. Take a useful legal shot from the current tile.
  3. Capture immediately when standing on the capture goal.
  4. Move to a better mission or formation position.
  5. Wait when no useful legal action exists.

A stale assignment discovered during the AI turn should return false. Normal AI can pick that action. The next turnStart can permanently reassign the unit or switch it back to GameAi_Normal.

Rescan live unit targets instead of storing one target

Store the type of target a role cares about, not one enemy unit's starting position or unique ID.

Every scripted action should scan the current enemy list again. That automatically handles:

  • a destroyed target
  • a newly built replacement
  • several valid targets at once

For an indirect hunter:

  1. Find all live matching targets.
  2. Fire at one already inside the current modified firing range.
  3. Otherwise find tiles whose firing range would cover at least one matching target.
  4. Use terrain-aware route costs to move toward the nearest reachable firing tile.
  5. Wait when no matching target exists. Keep the role so a later target is noticed.

Do not give an indirect hunter a move-and-fire action. Move first. Recheck targets on the next action before firing.

Decide how much danger the role accepts. A disposable hunter may ignore normal safety rules. A reusable support unit should not.

A role that must claim every unit of a certain class should claim matching friendly units during gameStart and again during each AI turnStart. Do this before normal objective-release logic.

Reacting to production is different from controlling production

The coordinator uses whatever units normal AI buys. It does not force the AI to buy a missing unit type.

Start with:

campaignScript.getBasicBuildList()

Watch what the AI actually produces during play tests.

Add mission-specific production control only after testing shows a needed role repeatedly stays empty.

Keep these jobs separate:

  • the build list controls what unit types are allowed
  • production logic chooses what to buy
  • the mission coordinator decides how completed units are used

Do not script every produced unit. Fill the known mission roles and leave extra forces under normal AI control.

Dynamic reinforcement test checklist

Before release, verify all of these:

  • starting units and factory-built units use the same scan
  • new units get assignments at the start of their first usable turn
  • player units never receive AI roles
  • one unit never fills two roles at once
  • useful assignments survive normal movement
  • destroyed units leave slots that replacements can fill
  • finished or failed objectives release their units
  • extra units stay in normal AI mode
  • ground and naval units are rejected when no terrain route exists
  • ties use stable scan order and unique ID
  • role reads use getVariable, never createVariable
  • handler fallback returns false instead of building a broken action
  • saving and reloading keeps integer role and objective assignments

Build movement actions

Create a one-turn pathfinding system for the unit:

var pfs = unit.createUnitPathFindingSystem(unit.getOwner());
var points = pfs.getAllQmlVectorPoints();

points contains the tiles the unit can reach this turn.

Remove tiles where the unit cannot legally stop. Score the remaining tiles. Then ask the engine for the actual path:

var path = pfs.getPath(destinationX, destinationY);
action.setActionID("ACTION_WAIT");
action.setMovepath(path, pfs.getCosts(path));
return true;

Commander Wars stores paths backward: destination first, starting tile last. Use the path from the engine. Do not build or reverse the path yourself.

A unit that should stay in place can use:

action.setActionID("ACTION_WAIT");
action.setMovepath([], 0);
return true;

Destination movement must be terrain aware

getAllQmlVectorPoints() only tells you what the unit can reach this turn.

For a destination several turns away, choosing the reachable tile with the smallest Manhattan distance can get a unit stuck beside a river, mountain wall, bridge, or other obstacle.

Use two layers of pathfinding:

  1. Calculate the full-map route cost with getMovementCosts(...).
  2. Look at the tiles the unit can reach this turn.
  3. Choose the reachable tile with the lowest remaining full-route cost.
  4. Use getPath() for the legal movement this turn.

For full-map routing, charge movement with:

unit.getMovementCosts(nextX, nextY, currentX, currentY, false)

Reject terrain the unit cannot cross.

Do not treat enemy units on a long-term route as permanent walls. Give enemy-occupied tiles a named penalty. This makes open routes more attractive without making the objective look unreachable forever.

Friendly units may also sit on a future route because they can move later.

The engine's one-turn pathfinder still decides whether the unit can legally move through or stop on an occupied tile right now.

A deterministic Dijkstra search works well on small campaign maps. It can account for terrain, weather, movement type, and scripted movement-cost changes.

Use Manhattan distance for simple formation spacing, nearby checks, and tiebreakers. Do not use it as the only long-range route score for ground or naval units.

Full-map movement costs are required for ground and naval units moving toward things like:

  • fixed posts
  • enemy HQs
  • retreat destinations
  • resupply properties
  • escort routes
  • a previous post they need to return to

Aircraft are the exception. Ground terrain cannot trap them in the same way, so Manhattan progress can guide air movement. Threat checks still need separate logic.

An aircraft avoiding Anti-Air coverage needs a pessimistic threat map. Do not use the normal enemy one-turn pathfinder unchanged, because unit occupancy can disappear before the Anti-Air moves.

Build Anti-Air movement costs while ignoring temporary unit occupancy. Still respect terrain, weather, movement type, and movement points. Combine every reachable Anti-Air origin with its weapon range. This gives the aircraft a safer estimate of where the Anti-Air could threaten it.

Plan a fixed-map property race as one optimization

A fixed campaign map gives you information that a general AI does not have. You already know the victory target, property layout, bridges, and likely approach routes.

Keep static map geometry in normal JavaScript data. Re-read changing information every AI turn, including:

  • who owns each property
  • unit positions
  • HP
  • weapon ranges
  • capture progress
  • tile occupancy

Do not assign several capturing units one by one in scan order.

Build a terrain-aware cost table from every capturer to every property not owned by the AI. Then solve the assignments as one minimum-cost matching problem.

This avoids a flexible Infantry taking the only cheap property that a more restricted Infantry can reach.

For a Dijkstra search running backward from a property, keep charging the movement in the unit's real forward direction:

unit.getMovementCosts(current.x, current.y, next.x, next.y, false);

The real move is from next into current. Reversing those arguments charges the wrong terrain and can quietly choose the wrong route.

A valid long-term route does not prove a tile is legal this turn. Only emit ACTION_CAPTURE after confirming that the property appears in getAllQmlVectorPoints().

After building a completed route-cost field, walk downhill through it to get a cheapest corridor.

That corridor can guide the whole formation:

  • the capturer follows it toward the property
  • direct units protect the front part without occupying the capture tile
  • indirect support positions are scored by how much of the corridor they can fire on
  • later corridor tiles can receive more weight because bridge exits and objective approaches matter more than the rear

This is firing-range coverage, not line of sight.

Rebuild corridors from current unit positions each turn.

For larger maps, static terrain and property fields can be cached by movement class. Keep property owners, units, weapon ranges, and reservations live.

Derive unit order from dependencies

A fixed priority by role is not enough for coordinated movement.

Plan the desired actions first. Reserve destinations. Then create ordering rules based on which action needs another action to happen first.

Common examples:

  • a screen moves before the protected unit advances
  • a unit blocking a lane moves before the follower
  • a wallbreaker attacks before another unit uses the opened route
  • support acts before an attack that would otherwise be unsafe

The order can change from one turn to another.

Example: an indirect unit with a stationary shot should often fire before movement. The same indirect may need to move after an Infantry that currently occupies its planned support tile.

Build the action order with a deterministic topological sort.

Two units requesting the same tile should be resolved during reservation. Do not depend on a priority tiebreak after both have already chosen it.

A dependency cycle needs a real solution such as a buffer tile or a different destination.

Repeatedly approach an occupied destination

A target tile may be occupied.

Do not move the blocking unit unless the mission specifically calls for that.

For the unit trying to approach the occupied tile:

  1. Look at reachable, empty tiles.
  2. Score them by distance to the destination.
  3. Move to the closest real improvement.
  4. Try again on a later turn.

After the unit reaches the destination, switch it to a built-in mode:

if (unit.getX() === TARGET_X && unit.getY() === TARGET_Y)
{
    unit.setAiMode(GameEnums.GameAi_Defensive);
    return wait(action);
}

This is how the northern Rocket in It's War deals with the Anti-Air that starts on (15,1) in my mission remaster.

Build firing actions

First check whether the unit can attack the enemy from the chosen position:

var weaponOne = unit.canAttackWithWeapon(
    0, moveX, moveY, enemy.getX(), enemy.getY());
var weaponTwo = unit.canAttackWithWeapon(
    1, moveX, moveY, enemy.getX(), enemy.getY());

Then build the fire action:

action.setActionID("ACTION_FIRE");
action.setMovepath(path, pfs.getCosts(path));
action.writeDataInt32(enemy.getX());
action.writeDataInt32(enemy.getY());
action.setInputStep(action.getInputStep() + 1);
action.isFinalStep();
return true;

isFinalStep() matters. It tells the fire action to finish writing its battle data before the engine checks canBePerformed().

Direct units may move and fire when unit.canMoveAndFire(...) allows it.

Artillery, Rockets, and other indirect units should normally fire only from their current tile. Do not give them a move-and-fire action.

Put a guard between an ally and an enemy

For protected unit P, guard tile G, and enemy E, this Manhattan-distance check tells you whether G lies on a shortest grid route between the enemy and the protected unit:

distance(G, E) + distance(G, P) == distance(P, E)

Good guard positions are:

  1. reachable and empty
  2. one or two tiles from the protected unit
  3. on that blocking line
  4. closer to the enemy than the protected unit
  5. cheaper to reach when other scores tie

The formation becomes:

Enemy -> Medium Tank -> Rocket

Before moving back into formation, check for a useful attack against an enemy near the protected unit. The guard should intercept a threat instead of blindly returning to its preferred tile.

Clear a moving unit's route without relying on action order

Scripted units do not act in a guaranteed formation order.

Do not assume a guard will move before the unit it protects.

Instead, remove the protected unit's forward lane from the guard's legal stopping tiles.

For a unit moving toward a destination, a tile lies on a shortest Manhattan route when:

distance(Unit, Tile) + distance(Tile, Target) == distance(Unit, Target)

This is useful when an escort must stay out of a convoy's path.

Advance toward an HQ without capturing towns

Give the mission's Infantry and Mechs a scripted role while they move across normal properties.

Ordinary attackers should choose a reachable tile with the lowest remaining route cost to the enemy HQ.

A unit designed to avoid resistance can use a more cautious score:

  • keep only tiles that make real progress toward the HQ
  • add a penalty for nearby enemy pressure
  • include remaining HQ distance
  • include movement cost

One simple pressure score gives more weight to enemies inside a chosen radius. This makes the group drift away from the crowded side while still moving forward.

When the selected destination is the enemy HQ, use ACTION_CAPTURE with the same movement path. The Infantry or Mech moves onto the HQ and starts capturing in that same action.

Also use ACTION_CAPTURE with an empty path when the unit already starts its action on the HQ.

Coordinate a mobile indirect hunter group

Track the intended enemy by its current owner and unit ID, not its starting tile.

For a three-unit group hunting a Heavy Tank:

  • The Rocket fires only from its current tile. Without a shot, it uses terrain-aware routing toward a tile inside its firing range. It takes any legal positive-damage shot before moving. Target value only decides between several shots, with a preference bonus for the main target.
  • The friendly Heavy Tank attacks the marked target when possible. Otherwise it screens one or two tiles in front of the Rocket toward the target.
  • The Artillery fires from its current tile when possible. Otherwise it stays one or two tiles behind the friendly Heavy Tank, away from the target.

Commander Wars HP values use a 0 to 10 scale in both getHp() and getHpRounded().

getBaseDamage() uses percentage points, usually 0 to 100.

For overkill limits, convert rounded HP to percentage points:

getHpRounded() * 10

For attacker health scaling, multiply damage by:

getHpRounded() / 10

Mixing these scales can badly distort target value. An 8 HP unit can look almost worthless, while a full-health attacker can look weak.

Kill checks for a coordinated volley must use HP-scaled damage. Raw getBaseDamage() describes the full-strength matchup. A damaged attacker using raw base damage can create a fake kill prediction.

Keep a coordinated strike plan for the actions it coordinates

The predefined AI returns one action at a time. The board changes before the next unit enters the hook.

A plan that coordinates several units should remain in ordinary JavaScript state until those units finish acting. Clear it at turnStart.

Do not rebuild the entire multi-unit assignment every time one unit enters the hook. That can:

  • assign a target to a unit that already moved
  • forget damage that an earlier attack was supposed to set up
  • lose the connection between the units in the strike

Right before each action, recheck:

  • target identity
  • firing tile
  • attack legality

Treat bomber-to-target, unit-to-slot, and similar limited-choice assignments as matching problems.

Greedy choice can fail when one flexible unit takes the only option available to a more limited unit.

A “hold until ready” rule should use the best result the real matching solver can achieve, not a theoretical number such as min(targets, attackers).

Strict staging also needs an escape route. The formation should not wait forever when no safe state-changing move exists.

Cross-role safety can create a deadlock even while movement is still possible.

Example: one role waits for escorts to remove an enemy that covers a route. Compare that enemy's current HP against the damage the remaining unmoved escorts can safely and legally deal this turn.

That check should include:

  • weapon loaded state
  • path legality
  • firing range
  • danger from other threats

When the covering enemy cannot be removed this turn, relax only the role blocked by that enemy. Keep other threat rules and normal trade-value checks.

Before moving an indirect unit toward firing range, check possible counterattacks.

For every enemy whose HP-scaled base damage is above the accepted threshold:

  1. Build that enemy's pathfinding system.
  2. Find the attack origins it can legally reach.
  3. Reject candidate destinations that are attackable from those origins.

This is more accurate than adding movement range and weapon range with Manhattan distance because it respects terrain and current blockers.

Recalculate each turn as units move.

A promise of “safe after moving” should be tested on the board after the move: remove the mover from its old tile and place it on the candidate tile.

Do not let a friendly unit that can move later make a dangerous tile look safe by temporarily blocking an enemy route.

When many candidate tiles check the same enemy, build one complete strike-coverage field for that enemy and reuse lookups. Do not run a new Dijkstra search for every candidate tile.

Do not use canAttackWithWeapon to test attacks against a future empty tile. Some weapon scripts expect an actual unit to be present and will say the empty tile cannot be attacked.

Instead:

  1. find the enemy's legal origins with terrain-aware pathfinding
  2. compare the distance from each origin to the future destination
  3. check that distance against the enemy's minimum and maximum range

A unit meant to behave strictly as an indirect attacker should enforce its real minimum and maximum firing range before checking individual weapons.

Use:

getMinRange(position)
getMaxRange(position)

These include active CO, terrain, or scenario modifiers. A secondary direct-fire weapon should not allow an adjacent attack that breaks the intended indirect role.

For enemy threat projection, use:

canMoveAndFire(enemy.getPosition())

Direct attackers can threaten from legal movement tiles when they are allowed to move and fire.

Indirect attackers only threaten from their current position because moving uses up their attack.

Every member of a coordinated group should recalculate from current positions. Unit processing order is not stable.

Destroying the main target can trigger a new phase. In It's War, the Heavy Tank then screens the surviving HQ attacker closest to the objective. The Rocket and Artillery follow behind it.

Return false only when the current phase has no required target or partner left.

Keep indirect support behind a tank

For the nearest enemy, a support tile sits “behind” the tank when the support unit is farther from the enemy than the tank is:

distance(Support, Enemy) > distance(Tank, Enemy)

Keep candidate support tiles one or two spaces from the tank. Strongly prefer tiles that satisfy that rule.

The formation becomes:

Enemy -> Medium Tank -> Artillery

Validate indirect coverage from each candidate tile

Standing behind a tank does not automatically mean an Artillery or Rocket can cover the area that matters.

For every possible support tile:

  1. Read the unit's real minimum range with getMinRange(candidate).
  2. Read its real maximum range with getMaxRange(candidate).
  3. Check those ranges against every important tile the support unit should cover.

Require the support tile to cover at least one critical objective tile. Then score how many important tiles it covers in total.

This helps the AI choose a position that covers more of a bridge, exit, or approach instead of a tile that only protects the screen unit.

Example: an Artillery at (8,1) is four tiles from bridge tile (5,2), so a maximum range of three cannot reach it. At (7,1), the same Artillery reaches (5,2) at range three and (6,2) at range two.

Rockets need the same check at the opposite end of their range. A target can be too close and fall inside the Rocket's dead zone.

var BRIDGE_WEST_X = 3;
var BRIDGE_CENTER_X = 4;
var BRIDGE_EAST_X = 5;
var BRIDGE_EXIT_X = 6;
var BRIDGE_ROW_Y = 2;

var BRIDGE_SPAN_TILES = [
    Qt.point(BRIDGE_WEST_X, BRIDGE_ROW_Y),
    Qt.point(BRIDGE_CENTER_X, BRIDGE_ROW_Y),
    Qt.point(BRIDGE_EAST_X, BRIDGE_ROW_Y)
];

var BRIDGE_FIRE_TILES = BRIDGE_SPAN_TILES.concat([
    Qt.point(BRIDGE_EXIT_X, BRIDGE_ROW_Y)
]);

function rangeCovers(unit, candidate, target)
{
    var distance = Math.abs(candidate.x - target.x) +
                   Math.abs(candidate.y - target.y);
    return distance >= unit.getMinRange(candidate) &&
           distance <= unit.getMaxRange(candidate);
}

function coverageCount(unit, candidate, targets)
{
    var covered = 0;
    for (var index = 0; index < targets.length; ++index)
    {
        if (rangeCovers(unit, candidate, targets[index]))
        {
            ++covered;
        }
    }
    return covered;
}

function bridgeSupportScore(unit, candidate)
{
    if (coverageCount(unit, candidate, BRIDGE_SPAN_TILES) === 0)
    {
        return null;
    }
    return coverageCount(unit, candidate, BRIDGE_FIRE_TILES);
}

Recalculate this whenever the unit, objective, CO modifiers, weather, or terrain changes.

Do not reuse an Artillery support-position list for a Rocket. Their minimum and maximum ranges are different.

Stage direct defenders until support is live

A defender should not rush forward just because it entered a broad defensive radius.

Use two zones:

  • a staging line where direct units wait or move into safe flank positions
  • an active defense line where they fight once indirect support is ready

Keep the indirect's center route open while direct units stage on the sides.

A support unit counts as ready only when all of these are true right now:

  • it still has the support role
  • it has a usable weapon and ammunition
  • its current position covers a critical choke tile with its modified range
  • it can damage the intended target when its support is being used to approve an attack

Do not save “support ready” as a permanent true/false flag. The support unit can die, run out of ammo, move away, or lose range after a modifier changes.

A direct unit already sitting on the choke may fire from where it stands to defend itself.

An attack that requires the direct unit to move should be rejected unless a live indirect unit covers the enemy's tile.

This keeps a supported defense from turning into several separate units charging forward.

Screeners should not sit forever on their starting tiles. Give each one a flank staging lane that stays out of the indirect unit's center route.

Once support is active, screeners can move into separate choke tiles. Keep using the same support-coverage check before any move-to-attack action.

Create staging points from the unit's reachable movement graph:

  1. Remove the active choke line.
  2. Remove the indirect unit's center route.
  3. Rank the remaining points by terrain-aware route cost to the eventual defense tiles.
  4. Give screeners stable slot numbers so different units choose different positions.

This avoids two scripted units picking the same “best” tile before either one has moved.

An Artillery's decision order should be:

  1. Fire from the current tile when an enemy is already in range.
  2. Otherwise move to a reachable tile one or two spaces behind the tank.
  3. When no formation tile is reachable, move toward the tank and try again next turn.
  4. Wait when no legal improvement exists.

Artillery cannot move and fire in the same action, so checking for a stationary shot first prevents it from walking away from a useful attack.

Campaign rule enforcement

Set campaign-wide rules at the start of every mission before assigning scripted roles:

var rules = map.getGameRules();
rules.setTransporterRefresh(false);
rules.setRankingSystem(false);
rules.setDamageFormula(GameEnums.DamageFormula_AdvanceWars1_3);

This makes the released map control those rules itself. The result no longer depends on a player's global settings or whatever was last shown in the map editor.

Hold until damaged

GameAi_Hold already gives you a unit that stays still and fires when possible.

To release that unit only after it takes damage, save its current rounded HP during gameStart:

var LAST_HP = "MISSION_BRIDGE_TANK_LAST_HP";

unit.setAiMode(GameEnums.GameAi_Hold);
unit.getVariables().createVariable(LAST_HP).writeDataInt32(unit.getHpRounded());

After every finished action, compare the saved HP with the current HP:

function updateHeldTank(unit)
{
    var variables = unit.getVariables();
    var previous = variables.getVariable(LAST_HP);
    var current = unit.getHpRounded();
    if (previous !== null && current < previous.readDataInt32())
    {
        unit.setAiMode(GameEnums.GameAi_Defensive);
    }
    variables.createVariable(LAST_HP).writeDataInt32(current);
}

Call this from the symmetric actionDone hook.

After taking damage, the unit stays in defensive mode and may reposition.

In It's War, this keeps the bridge Light Tank on its blocking tile until the player attacks it.

Any real HP loss counts as damage here. A mission with global-damage CO powers or scripted environmental damage needs a more specific action check or an exclusion for those damage sources.

Guard failure behavior

The protected unit may be destroyed.

When the guard cannot find its protected ally, return false:

if (protectedUnit === null)
{
    return false;
}

Normal AI can then take over.

Never return true without creating a valid action. That can stall the unit or make the engine reject the scripted choice.

Performance

Normal JavaScript math inside QJSEngine is cheap.

Calls on unit, map, terrain, and building objects are much more expensive because each one crosses from JavaScript into C++.

The main performance rules, in order of value:

  1. Cache getMovementCosts by (ownerPlayerID|movementType).

    • The engine call reads owner modifiers, movement type, weather, and terrain.
    • It does not depend on unit occupancy.
    • Same-class units can share these edge costs.
    • NC06A cut getMovementCosts calls by about 5.5x this way.
  2. Cache complete route fields only when they depend only on terrain.

    • NC06M and NC06A have terrain-only fields, so they can be cached for the turn and cleared at turnStart.
    • NC02, NC04A, and NC05M include live occupancy penalties. Those fields depend on the current board and should only be cached for one dispatch.
    • The smaller edge-cost cache can still be shared.
  3. Match each cache lifetime to what can make it stale.

    • Board-dependent data such as occupancy, rosters, and threat results should normally last one hook dispatch.
    • Use a try/finally wrapper so direct test-harness calls do not accidentally keep stale cached state.
    • Terrain-only data can last a whole turn when a reliable turn hook clears it.
    • A roster cache must be cleared by any code that changes roles, such as assignRole.
    • A CO power that changes movement costs during the turn can make a turn-wide movement cache stale. Include weather or other changing modifiers in the cache key when the mission needs that.
  4. Sweep the board once per dispatch.

    • findRoleUnit, scanUnits, occupancy checks, and factory scans often used to walk the entire board separately.
    • One NC03 wallbreaker dispatch performed eight or more full scans.
    • Build one scan result and reuse it.
  5. Convert QmlVectors to JavaScript arrays once per action.

    • Calling .size() in a loop is another engine crossing every iteration.
    • .at().x/.y adds even more crossings.
    • Nested loops can multiply this into tens of thousands of calls.
    • NC04A reduced getMapWidth calls from about 40,000 to 265 by moving repeated engine reads out of loops.
  6. Move work that does not depend on the candidate tile outside candidate loops.

    • Read enemy corridors, threat data, weapon slots, target coordinates, and findUnit results once.
    • Use plain JavaScript lookups inside the per-tile loop.
    • Build one strike-coverage field per enemy instead of one Dijkstra search for every candidate tile.
    • Pass an already-created pathfinding system into helpers that would otherwise build another one. createUnitPathFindingSystem is one of the most expensive calls in the chain.
  7. Use the Manhattan max-range shortcut only when it is truly safe.

    • It can remove stand/target pairs that are clearly beyond maximum range before calling canAttackWithWeapon.
    • Use getMaxRange(position) for each stand. Do not use getBaseMaxRange, because it ignores CO firing-range modifiers.
    • Always keep distance == 1. Weapons with WeaponType_Both and Direct can accept adjacency regardless of the normal range test.
    • Do not prune based on minimum range.
    • This shortcut also assumes no ACTION_FIRE.extendedCanAttackCheck can approve the shot before the normal range test.
    • Search resources and mods before relying on that assumption.
    • NC01 and NC06A chose the safer route: cache weapon-slot checks and skip this shortcut.
  8. Do not remove targets just because getBaseDamage(target) is zero.

    • That optimization changed real decisions in four missions.
    • NC02 can still choose a zero-damage target because of its indirect-priority bonus.
    • NC04M can choose one because of its objective bonus.
    • canAttackWithWeapon can legally accept a zero-damage matchup that NC03 and NC04A then score highly.
    • You may drop a zero-value target only when the mission's scorer can prove it can never win. NC05M could do that because its best score starts at zero and zero-funds-value targets cannot beat it.
  9. Measure binary heaps before keeping them.

    • A heap helped when route fields were built many times per action. NC01's per-stand strike Dijkstra work became roughly 40x faster overall.
    • NC03 tried the same idea and became about 2x slower at its map sizes because allocations and comparator calls cost more than a tight linear scan in QJSEngine.
    • The real NC03 bottleneck was engine crossings, which edge caching had already reduced.
    • A heap that is kept should order nodes by (cost, insertion index) so equal-cost nodes settle in the same order as the old scan and results stay deterministic.

Determinism guidelines

Scripted AI is part of synchronized gameplay. Two clients must make the same decision from the same game state.

Keep the AI predictable:

  • Scan coordinates in a stable order.
  • Use clear scoring rules.
  • Break ties the same way every time.
  • Use synchronized engine randomness only when the mission intentionally needs randomness.
  • Do not depend on UI state.
  • Do not call createVariable while merely reading a role during AI lookahead.
  • Do not use JavaScript object iteration order as a gameplay tiebreaker.
  • Build movement paths with the engine pathfinding system.
  • Set both the action ID and movement path for every scripted action.

Scripted-unit checklist

Use this before play-testing a new scripted mission behavior.

Movement

  • Use the engine pathfinder for legal destinations and the actual one-turn path.
  • Use full-map terrain-aware movement costs for every destination several turns away.
  • Do not rely only on Manhattan distance for ground or naval travel.
  • The tile where the unit ends this action must be empty.
  • A long-term route may still pass through a friendly unit because that unit can move later.
  • Safety checks should model the board after the move: old tile empty, new tile occupied by the mover.
  • Do not treat movable friendly blockers as permanent protection.
  • Recalculate from current positions every action.
  • Do not assume scripted units act in a fixed order.

Attacks

  • Check for a useful shot before moving when firing should take priority.
  • Define emergency survival rules before normal attack rules.
  • In It's War, projected damage above 50% makes the Rocket retreat even when it has a legal shot.
  • Check every legal target, not only the preferred mission target.
  • Scale getBaseDamage() by the attacker's rounded HP before claiming a kill is possible.
  • Use target value to rank legal shots unless the mission intentionally allows the unit to hold fire.
  • A valid ACTION_FIRE needs a legal path, target coordinates, an increased input step, and isFinalStep().
  • Enforce the real minimum and maximum range for indirect units so a secondary direct weapon does not break their scripted role.

Threat prediction

  • Start from enemy origins the enemy can actually reach through terrain.
  • A direct attacker threatens from reachable movement tiles only when canMoveAndFire(...) allows it.
  • An indirect attacker threatens only from its current tile.
  • Use getMinRange(position) and getMaxRange(position) at each possible firing origin so active modifiers are included.
  • Do not use canAttackWithWeapon against a future empty tile.
  • Check range geometry after finding the enemy's legal origins.
  • Scale expected damage with the engine's 0 to 10 HP scale.
  • Emergency retreat should choose a different reachable tile with lower expected damage.
  • During emergency retreat, survival ranks above route progress.

State and actions

  • Give moving mission units persistent role variables during gameStart.
  • For factory missions, assign newly built units during the AI player's turnStart.
  • Map game scripts do not receive buildedUnit directly.
  • Only assign units to real mission role slots.
  • Leave extra production in GameAi_Normal.
  • Read roles inside one-sided AI hooks with getVariable and a null check.
  • Never call createVariable just to read a role during lookahead.
  • Give every missing-target or missing-partner case a valid fallback.
  • Treat every objective as a mission phase with clear success and failure exits.
  • A unit that stays scripted needs a valid action in the next phase.
  • Otherwise, move it back to a suitable built-in AI mode.
  • Releasing a unit means changing its AI mode, clearing its role, and returning false from the current scripted call.
  • GameAi_Normal can still enter the predefined-unit hook, so returning a scripted wait does not release control.
  • For an HQ capture on the same turn, use ACTION_CAPTURE with the movement path ending on the HQ.
  • Keep multi-unit snapshot assignments alive through the actions they coordinate, or replan using only units that have not moved yet.
  • Use global matching when one unit's choice can take away another unit's only legal option.
  • “Hold until ready” rules must be achievable by the real planner.
  • Add a clear escape condition when strict staging cannot improve the position.

API scale reference

Value Meaning
getHp() Internal HP from 0 to 10, including fractions
getHpRounded() Displayed HP from 0 to 10, rounded upward
getBaseDamage(target) Damage in percentage points, usually 0 to 100
getMinRange(position) Real minimum range at that position after modifiers
getMaxRange(position) Real maximum range at that position after modifiers
getMovementCosts(...) Terrain- and modifier-aware movement cost

For overkill checks:

getHpRounded() * 10

For attacker-strength scaling:

getBaseDamage(target) * (getHpRounded() / 10)

Inspect a live save state

A screenshot only shows what is visible. A .sav file can show the mission state behind the scenes.

When a play tester sees strange behavior, save the game on that exact turn. Keep the save together with the matching map and the exact sidecar script version.

A save can tell you:

  • whether an objective-success or objective-failure flag is set
  • which surviving units still have scripted roles
  • which units were released to ROLE_NORMAL
  • each scripted unit's objective, position, HP, fuel, ammo, and AI mode
  • whether a formation member or objective unit died
  • whether a stalled unit is still script-controlled or has already returned to built-in AI

Start by reading map variables such as phase and objective flags.

Then inspect surviving units and read their role and objective variables with getVariable.

Match integer values against the constants from the exact script version used by that save.

Do not use createVariable while inspecting a save. That changes the loaded state.

A saved role tells you which behavior branch owns a unit. It does not fully explain why the unit chose one tile over another.

For a still-active role, compare the saved board with the same rules used by the script:

  • pathfinding
  • weapon dead zones
  • maximum range
  • coverage
  • occupancy
  • expected damage

For ROLE_NORMAL, inspect the built-in AI instead.

Make the fix in the sidecar script. Run the tests again. Re-embed the new script into the release map. Start a fresh play test.

Loading an old save after changing the sidecar can keep old variables or old embedded script state and may not reproduce the fix from a new game.

Play test

Watch the actual unit order during the AI turn.

Commander Wars can process AI units in a different order, so coordinated units must work no matter which member moves first.

Formation logic should always use current positions. Never depend on a fixed unit order.

Recommended workflow for another campaign mission

  1. Check the native map's script path and starting unit coordinates.
  2. Find and copy the original packaged mission script.
  3. Create named constants for starting positions and role-variable IDs.
  4. Tag the chosen units during gameStart.
  5. For maps with production, define objective role slots and reconcile live AI units during the AI player's turnStart.
  6. Build small reusable helpers for waiting, moving, attacking, and finding role-bearing allies.
  7. Let the predefined-unit hook handle only roles your script recognizes.
  8. Use actionDone for changes triggered by damage or other completed actions.
  9. Put the map and sidecar script together in the release folder.
  10. Clear old embedded text, set the development script path, and save.
  11. Reload the map so Commander Wars reads the latest sidecar, then save again without changing the script field. This embeds the release copy.
  12. Test a disposable copy with a deliberately missing external script path.
  13. Play-test the mission and tune distances, threat radii, and target scores.

Final rule

Coordinates are useful for identifying a unit once. Persistent variables identify it after it moves.

Every scripted unit must receive a valid action.

Use terrain-aware route costs for long-distance movement.

Check legal shots before moving when firing has priority.

Keep indirect fire stationary.

Predict counterattacks from places the enemy can legally reach.

Return units to built-in AI when their scripted job is finished.