Creating simple AI scripts for your custom campaigns

After writing a bunch of AI scripts for my Douglas Gyasi: Golden Chancellor custom campaign, I thought I could share some tips on how you too can create simple campaign AI scripts. You definitely won’t be using these for random maps, but, with decent scenario design, they should be able to put up a good fight.

I’m writing this guide assuming you’re vaguely familiar with AI scripting. If not, check out Leif Ericson’s The World of AI Scripting article. It’s a really good introduction to how AI scripts work.

Here are some other useful links:

And finally, if you still have any scripting questions, you can ask them on the AI Scripting Discord.

Part 1: Immobile AI
One of the most common questions beginners have when using the scenario editor is how to keep units standing still. It seems like an oversight on Microsoft’s part to not have an immobile AI be part of the base game. Fortunately, it’s pretty easy to create an immobile AI yourself. Just paste the following block of code into your .per file:

(defrule	;this rule has FACTS and ACTIONS to keep VILLAGERS still
	(true)
=>
	(set-strategic-number sn-maximum-food-drop-distance 0)
	(set-strategic-number sn-maximum-wood-drop-distance 0)
	(set-strategic-number sn-maximum-gold-drop-distance 0)
	(set-strategic-number sn-maximum-hunt-drop-distance 0)
	(set-strategic-number sn-maximum-stone-drop-distance 0)
	(set-strategic-number sn-food-gatherer-percentage 0)
	(set-strategic-number sn-wood-gatherer-percentage 0)
	(set-strategic-number sn-gold-gatherer-percentage 0)
	(set-strategic-number sn-stone-gatherer-percentage 0)
	(set-strategic-number sn-cap-civilian-explorers 0)
	(set-strategic-number sn-percent-civilian-explorers 0) 
	(disable-self)
)
(defrule	;this rule has FACTS and ACTIONS to keep TROOPS still
	(true)
=>
	(set-strategic-number sn-percent-enemy-sighted-response 100)
	(set-strategic-number sn-hits-before-alliance-change 25)
	(set-strategic-number sn-number-explore-groups 0)
	(set-strategic-number sn-percent-attack-soldiers 0)
	(set-strategic-number sn-task-ungrouped-soldiers 0)
	(set-strategic-number sn-number-attack-groups 0)
	(set-strategic-number sn-enemy-sighted-response-distance 10)
	(set-strategic-number sn-total-number-explorers 0)
	(set-strategic-number sn-relic-return-distance 0)
	(disable-self)
)

This block of code in particular was taken from Immobile Units AI GOLD. As implied by the name, this code keeps all units standing still… except for trade units. I think as long as there is an allied Market/Dock, trade units are hardcoded to trade with those buildings. Thus, if you don’t want moving trade units, then don’t place allied Markets/Docks in your scenario.

Part 2: Gathering Resources
Suppose you want an AI to simply just gather resources. No attacking, no training units. Just have Villagers farm or gather Gold. To do that, you will need to edit strategic numbers, specifically sn-food-gatherer-percentage, sn-wood-gatherer-percentage, sn-gold-gatherer-percentage and sn-stone-gatherer-percentage. As their name implies, these strategic numbers refers to the percentage of villagers assigned to these resources.

The following block of code below sets 40% of Villagers on Wood, 40% on Food, 15% on Gold and 5% on Stone, while keeping military units still. It also builds farms when there is fewer than 4 idle farms.

(defrule	;this rule has FACTS and ACTIONS to keep VILLAGERS still
	(true)
=>
	(set-strategic-number sn-maximum-food-drop-distance 6)
	(set-strategic-number sn-maximum-wood-drop-distance 6)
	(set-strategic-number sn-maximum-gold-drop-distance 6)
	(set-strategic-number sn-maximum-hunt-drop-distance 0)
	(set-strategic-number sn-maximum-stone-drop-distance 6)
	(set-strategic-number sn-food-gatherer-percentage 40)
	(set-strategic-number sn-wood-gatherer-percentage 40)
	(set-strategic-number sn-gold-gatherer-percentage 15)
	(set-strategic-number sn-stone-gatherer-percentage 5)
	(set-strategic-number sn-percent-civilian-gatherers 100)
	(set-strategic-number sn-percent-civilian-builders 0)	
	(set-strategic-number sn-cap-civilian-explorers 0)
	(set-strategic-number sn-percent-civilian-explorers 0) 
	(disable-self)
)

(defrule	;this rule has FACTS and ACTIONS to keep TROOPS still
	(true)
=>
	(set-strategic-number sn-percent-enemy-sighted-response 100)
	(set-strategic-number sn-hits-before-alliance-change 25)
	(set-strategic-number sn-number-explore-groups 0)
	(set-strategic-number sn-percent-attack-soldiers 0)
	(set-strategic-number sn-task-ungrouped-soldiers 0)
	(set-strategic-number sn-number-attack-groups 0)
	(set-strategic-number sn-enemy-sighted-response-distance 10)
	(set-strategic-number sn-total-number-explorers 0)
	(set-strategic-number sn-relic-return-distance 0)
	(disable-self)
)

(defrule
	(idle-farm-count < 4)
	(can-build farm)
=>
	(build farm)
)

To explain the idle-farm-count command in more detailed, the AI will keep building farms until the percentage of villagers that are farmers reaches the sn-food-gatherer-percentage value. For example, if you have 100 villagers and sn-food-gatherer-percentage is 40, the AI should eventually build 40 farms.

As long as there are some nearby Forage Bushes/herdables, AI Villagers will automatically gather from those units before they start farming. Also, the AI tends to build Farms around the oldest existing Town Center that you placed in the scenario editor. Therefore, make sure the first Town Center you place has adequate spacing.

As we know, resources and farms eventually expire. If something such as a Stone Mine expire, AI Miners will simply start gathering Stone that are within sn-maximum-stone-drop-distance of a Mining Camp, or move onto another resource if the existing Stone Mines are outside that distance. If a Farm expires, then AI Villagers will just build new Farms around the original TC.

But what if you want AI Villagers to gather resources in a specific area indefinitely? Having Villagers migrate across a map to gather resources is inefficient and potentially dangerous on the AI’s part. Or what if in the editor you placed Farms far away from the primary Town Center and you want the AI Farmers to continue farming those non-TC Farms?

For resources such as Forage Bushes, Mines and Trees, you can set a Create Object effect on a loop to constantly create said resource. The resource will appear as soon as the Set Location tile is unoccupied, whether through resource expiration or other units leaving the tile. This method essentially creates an infinite resource and encourages AI gatherers to stay in the area. If you don’t mind all Bushes/Mines/Trees having infinite resources, you can use the Dead Unit ID method outlined in this post instead.

When it comes to infinite resource Farms, you will need to use the Replace Object effect instead of the Create Object effect (h/t Alkhalim). The trigger you will need to create is:

  • Trigger Looping: Yes
  • Condition: Timer = 10 seconds
  • Effect : Replace Object. Set Object List (left) to Farm, Source Player to the AI Player, Target Player to Gaia and Object List (right) to Farm
  • Effect: Replace Object. Set Object List (left) to Farm, Source Player to Gaia, Target Player to the AI Player and Object List (right) to Farm

What the Replace Object effect does is reset the AI’s Farm Food count every 10 seconds (it takes more than 10 seconds of farming for Farms to expire). As this only resets the Food count of existing Farms instead of creating new Farms, Farms can still be destroyed.

If your AI has no Villagers/trade units, but you still want them to accumulate resources, you can simply use cheats, specifically the cc-add-resource cheat. The following block of code, used by Senegal in the second Douglas Gyasi scenario, gives Senegal 100 Gold every 60 seconds as long as Senegal’s Gold is below 500:

(defrule
	(true)
=>
	(enable-timer 2 60)
	(disable-self)
)

(defrule
	(cheats-enabled)
	(gold-amount < 500)
	(timer-triggered 2)
=>
	(cc-add-resource gold 100)
	(chat-local-to-self "gold")
	(disable-timer 2)
	(enable-timer 2 60)
)

Finally, if you want the AI to use the Market, there’s already an existing developer-created script (scenario market.per2) just for that. To get your own AI to use that script, just add (load "scenario market").

Part 3: Difficulty, Training Units and Building Buildings
The AI can be commanded to take certain actions at certain difficulties. The game treats difficulty as a value, with Easiest having the highest value and Extreme having the lowest value; the harder the difficulty, the lower the value.

With this in mind, here’s a block of code that trains 10 knights, 10 villagers and 2 rams on Standard; 20 knights, 20 vils and 4 rams in Moderate; and 30 knights, 30 villagers and 6 rams on Hard:

;standard training
(defrule
	(difficulty > moderate)
	(unit-type-count-total knight-line < 10)
	(can-train knight-line)
=>
	(train knight-line)
)
(defrule
	(difficulty > moderate)
	(unit-type-count-total villager < 10)
	(can-train villager)
=>
	(train villager)
)
(defrule
 	(difficulty > moderate)
	(unit-type-count-total battering-ram-line < 2)
	(can-train battering-ram-line)
=>
	(train battering-ram-line)
)

;moderate training
(defrule
	(difficulty == moderate)
	(unit-type-count-total knight-line < 20)
	(can-train knight-line)
=>
	(train knight-line)
)
(defrule
	(difficulty == moderate)
	(unit-type-count-total villager < 20)
	(can-train villager)
=>
	(train villager)
)
(defrule
 	(difficulty == moderate)
	(unit-type-count-total battering-ram-line < 4)
	(can-train battering-ram-line)
=>
	(train battering-ram-line)
)

;hard training
(defrule
	(difficulty < moderate)
	(unit-type-count-total knight-line < 30)
	(can-train knight-line)
=>
	(train knight-line)
)
(defrule
	(difficulty < moderate)
	(unit-type-count-total villager < 30)
	(can-train villager)
=>
	(train villager)
)
(defrule
 	(difficulty < moderate)
	(unit-type-count-total battering-ram-line < 6)
	(can-train battering-ram-line)
=>
	(train battering-ram-line)
)

The above block of code can be simplified using goals:

(defconst gl-knight-count 1)
(defconst gl-villager-count 2)
(defconst gl-ram-count 3)

(defrule
	(difficulty > moderate)
=>
	(set-goal gl-knight-count 10)
	(set-goal gl-villager-count 10)
	(set-goal gl-ram-count 2)
)
(defrule
	(difficulty == moderate)
=>
	(set-goal gl-knight-count 20)
	(set-goal gl-villager-count 20)
	(set-goal gl-ram-count 4)
)
(defrule
	(difficulty < moderate)
=>
	(set-goal gl-knight-count 30)
	(set-goal gl-villager-count 30)
	(set-goal gl-ram-count 6)
)

(defrule
	(unit-type-count-total knight-line g:< gl-knight-count)
	(can-train knight-line)
=>
	(train knight-line)
)
(defrule
	(unit-type-count-total villager g:< gl-villager-count)
	(can-train villager)
=>
	(train villager)
)
(defrule
	(unit-type-count-total battering-ram-line g:< gl-ram-count)
	(can-train battering-ram-line)
=>
	(train battering-ram-line)
)

We can simply use knight-line if we want the AI to train all units on the Knight line (Knights, Cavaliers and Paladins); we don’t need to create 3 separate defrules to train each unit. This is possible thanks to unitlines.json.

Now onto buildings. When it comes to building Town Centers, be sure to use town-center-foundation instead of town-center:

(defrule
	(building-type-count-total town-center less-than 3)
	(can-build town-center-foundation)
=>
	(build town-center-foundation)
)

To prevent your AI from getting housed, use the following block of code:

	(can-build house)
	(housing-headroom < 4) 
	(population-headroom > 3)
=>
	(build house)
)

All building/unit AI names can be found on here.

Part 4: Attacking
Here’s a tutorial I created on how to make the AI attack:

Essentially, there are three ways to get the AI to attack: attack-now, attack-groups and Town Size Attack. For naval attacks, only the attack-now command works. For land attacks, I personally recommend attack-groups over Town Size Attack, due to how attack-groups uses fewer lines. There’s also the Direct Unit Control method, but that method’s a lot more complex compared to the other three methods.

AI will only attack scouted enemies; the Spies technology will not work. An enemy can be scouted either with a scouting unit or with a Map Revealer. Map Revealers are difficult to remove, so I recommending using the Create Object effect to place them instead.

To get the AI to stop attacking, you will need to break the timer loop. Here’s a code block that stops an attack-now attack loop after AI Script Goal 33 has fired:

(defrule
	(true)
=>
	(enable-timer 1 300)
)

(defrule
	(timer-triggered 1)
	(defend-warboat-count >= 7) ;when we have 7 defenders, we will attack! 
=>
	(attack-now)
	(disable-timer 1)
	(enable-timer 1 300)
	(chat-local-to-self "attack-now")	
)

;Stop attacking/exploring after AI Script Goal 33 has fired
(defrule
	(event-detected trigger 33)
=>
	(set-strategic-number sn-blot-exploration-map 0)	
	(set-strategic-number sn-number-explore-groups 0)
	(set-strategic-number sn-number-boat-explore-groups 0)
	(chat-local-to-self "trigger 33")
	(disable-timer 1)
	(disable-self)	
)

And here’s another code block that stops a attack-group loop with AI Script Goal 12:

(defrule	
	(true)
=>
	(set-strategic-number sn-number-attack-groups 0)
	(enable-timer 1 0)
	(disable-self)
)
(defrule
	(timer-triggered 1)
=>
	(disable-timer 1)
	(set-strategic-number sn-number-attack-groups 0)
	(chat-to-all "sn-number-attack-groups 0")
	(enable-timer 2 10)
)

(defrule
	(timer-triggered 2)
=>
	(set-strategic-number sn-number-attack-groups 200)
	(chat-to-all "sn-number-attack-groups 200")
	(disable-timer 2)	
	(enable-timer 1 50) ;attack for 50 seconds
)

(defrule
	(event-detected trigger 12)
=>
	(set-strategic-number sn-number-attack-groups 0)
	(chat-to-all "trigger 12")
	(disable-timer 2)	
	(disable-timer 1)
	(disable-self)
)

Part 5: Taunts
The taunt-detected command is used to get the AI to respond to taunts. Suppose you want the AI to tribute you Food when you give the “Food, please” taunt. Additionally, you want the AI to only give Food when they have more than 100 Food and when they have a Market. Otherwise, you want the AI to inform you that they do not have 100 Food and/or a Market. The code to make this happen is:

(defrule
   (taunt-detected any-ally 3)
   (food-amount > 100)
   (building-type-count-total market > 0)
   =>
   (tribute-to-player any-human-ally food 500)
   (acknowledge-taunt this-any-ally 3)
   (chat-to-player any-human-ally "Here is Food.")
)
(defrule
   (taunt-detected any-ally 3)
   (food-amount < 101)
   (building-type-count-total market > 0)
   =>
   (acknowledge-taunt this-any-ally 3)
   (chat-to-player any-human-ally "We do not have enough Food to tribute at the moment.")
)
(defrule
   (taunt-detected any-ally 3)
   (building-type-count-total market < 1)
   =>
   (acknowledge-taunt this-any-ally 3)
   (chat-to-player any-human-ally "We don't have a Market.")
)

Part 6: Script Goals and Signals
You’ve already seen AI Script Goals and the event-detected command earlier in this post, but I want to go more into detail on how AI scripts and scenario triggers interact.

Scenarios triggers communicate with the AI using the AI Script Goal effect, while the AI communicates with scenario triggers using the set-signal command. For AI Script Goal, the corresponding AI commands are the event-detected fact and the acknowledge-event action. For the set-signal command, the corresponding trigger stuff are the AI Signal condition and the Acknowledge AI Signal effect. Both Acknowledge AI Signal and acknowledge-event resets set-signal and the AI Script Goal respectively, allowing those two to be able to fire again.

Here’s my tutorial on how to use the set-signal command with a tribute-based objective:

In the second scenario of my Douglas Gyasi campaign, I have a trigger that creates transport ships holding 8 military units each. The number of transport ships created depends on the military-population parameter, which in turns depends on difficulty level. Here’s the AI script code I used for this situation:

(defrule
	(event-detected trigger 31)
=>
	(enable-timer 3 300) ;ship created every 5 minutes
	(disable-self)
)
;Player can train up to 44 units that are NOT on transport ships. If non-transport ship pop is below 44, then more than 1/2/3 Transport Ships can be created
(defrule
	(timer-triggered 3)
	(difficulty > moderate)
	(military-population < 52); 1 transport ship at a time with 44 non-transport ship pop
	(food-amount > 420)
	(wood-amount > 225)
	(gold-amount > 730)
=>
	(set-signal 1)
	(disable-timer 3)
	(enable-timer 3 300)
)
(defrule
	(timer-triggered 3)
	(difficulty == moderate)
	(military-population < 60); 2 transport ships at a time with 44 non-transport ship pop
	(food-amount > 420)
	(wood-amount > 225)
	(gold-amount > 730)
=>
	(set-signal 1)
	(disable-timer 3)
	(enable-timer 3 300)
)
(defrule
	(timer-triggered 3)
	(difficulty < moderate)
	(military-population < 68); 3 transport ships at a time with 44 non-transport ship pop
	(food-amount > 420)
	(wood-amount > 225)
	(gold-amount > 730)
=>
	(set-signal 1)
	(disable-timer 3)
	(enable-timer 3 300)
)

And here’s how I set up the trigger in the scenario:

  • Trigger Looping: Yes
  • Condition: AI Signal. Set AI Signal Value to AI Signal 1
  • Effect: Create Object. Set Object List to Transport Ship
  • Effect: Create Garrison Object. Create 8 of these.
  • Effect: Modify Resource. Create 3 of these to subtract Food, Wood and Gold
  • Effect: Activate Trigger. Activates trigger to unload Transport Ship
  • Effect: Acknowledge AI Signal. Set AI Signal Value to AI Signal 1 so that the AI can fire this signal again.

Part 7: Random Numbers
If you want some randomness in your AI, use the generate-random-number command. As implied by its name, every time generate-random-number is run, a new random-number value is generated.

You can run a random-number comparison by simply writing “random-number” like below:

(defrule 
	(true)
=> 
	(generate-random-number 100)
	(disable-self)
)
(defrule 
	(random-number > 50)
=> 
	(chat-to-all "Random number is greater than 50")
	(disable-self)
)
(defrule 
	(random-number < 51)
=> 
	(chat-to-all "Random number is less than 51")
	(disable-self)
)

Or you can store random-number into a goal using up-get-fact:

(defconst gl-randnum 1)
(defrule 
	(true)
=> 
	(generate-random-number 100)
	(up-get-fact random-number 0 gl-randnum)
	(up-chat-data-to-all "gl-randnum %d" g: gl-randnum)
	(disable-self)
)
(defrule 
	(up-compare-goal gl-randnum > 50)
=> 
	(chat-to-all "Random number is greater than 50")
	(disable-self)
)

(defrule 
	(up-compare-goal gl-randnum < 51)
=> 
	(chat-to-all "Random number is less than 51")
	(disable-self)
)

The generate-random-number command can be run as many times as you want. In the code snippet below, the AI, using the attack-groups method, will wait 20 between 40 seconds, and then attack for 20 seconds:

(defconst gl-attack-time-range 6)
(defconst gl-attack-interval 7)
(defconst gl-game-time 8)

(defrule
	(true)
=>
	(set-strategic-number sn-number-explore-groups 1)
	(set-strategic-number sn-relic-return-distance 0)
	(set-strategic-number sn-wall-targeting-mode 1)
	(set-goal gl-attack-interval 20)
	(disable-self)
)

;attack groups
(defrule	
	(true)
=>
	(set-strategic-number sn-number-attack-groups 0)
	(enable-timer 1 0)
	(disable-self)
)
(defrule
	(timer-triggered 1)
=>
	(disable-timer 1)
	(set-strategic-number sn-number-attack-groups 0)
	(up-get-fact game-time 0 gl-game-time)
	(generate-random-number 20)
	(up-get-fact random-number 0 gl-attack-time-range)
	(up-modify-goal gl-attack-interval g:+ gl-attack-time-range)
	(up-set-timer c: 2 g: gl-attack-interval)
	(set-goal gl-attack-interval 20)
)

(defrule
	(timer-triggered 2)
=>
	(set-strategic-number sn-number-attack-groups 200)
	(up-get-fact game-time 0 gl-game-time)
	(disable-timer 2)	
	(enable-timer 1 20)
)

For a more detailed look at what’s going on above, you can check out the Randomizing Attack Interval section of my attacking AI tutorial.

Finally, if you combine random-number with set-signal, you can create something similar to the chance trigger condition in the scenario editor.

Part 8: Existing Campaign AI Scripts for Reference
Of course, probably the best way to learn how to write AI scripts is to take a look at the scripts from the Microsoft campaigns. One simple script I’ve used as a reference is the Greek player’s script from the Lepanto scenario (Conquerors-scn6 player 3.per2). That script gathers resources, builds camps, trains units, switches stances and uses event-detected/set-signal commands. AI scripts from the Catalaunian Fields scenario are a bit longer, but are still simple enough to understand if you’re creating a deathmatch-like scenarios. I do not recommend using the DLC AIs as references, though, as those AIs are pretty complex, thanks to them having a large number of constants, UserPatch commands and references to other AI scripts.

Watch this video if you don’t know how to view campaign AI. Also, AI scripts are automatically packed into your scenario file when saving with the scenario editor. Therefore, when you upload your campaign, you do not need to upload separate AI files.

2 Likes