AOE2 Random Map Scripting Part 1 – The Basics

Overview

Age of Empires 2 (AOE2) is one of the longest active RTS (Real Time Strategy) games in the genre. Originally released in 1999, it continues to receive regular updates and DLCs, and is in the unusual position – for a 20+ year old game! – of having a growing rather than shrinking player base. Among other things, AOE2 is notable for it’s wide variety of randomly generated maps, which are procedurally generated using a scripting language introduced alongside the Age of Conquerors expansion in 2000. Since getting back into AOE2 following the Definitive Edition’s release a few years ago I’ve seen a number of new maps, both official as well as custom maps used in AOE2’s surprisingly active Tournament scene, and it’s been nagging at the programmer side of my brain. That led me to looking into how AOE2 map scripts work, which in turn has led me here, to Part 1 of what I intend to be a 4-5 part series. Lets dive into the basics.

Note – Many of the below examples are pulled verbatim or with slight modifications from Zetnus’ Definitive Random Map Scripting Guide, which to my knowledge is the single most comprehensive source of AOE2 scripting documentation. See Sources at the end of this article for a link to the aforementioned guide as well as other resources I consulted in the course of writing this.

What is a Random Map Script?

In AOE2 maps are procedurally generated using Random Map Scripts (RMS). These scripts are plain text files saved with the .rms extension, which can easily be opened in any text editor (my preference in this case is Visual Studio Code, but you can easily rename files to .txt and open in notepad). When generating a map using a .rms script, AOE2 first picks a number (the seed) to generate the map with. This seed is used to initialize the random number generator, which ensures AOE2 consistently generates the same map from the script (particularly important in multiplayer, where it enables the host to share the selected seed and let clients generate the map locally instead of having to generate and upload the map to each client).

RMS files are broken into the following sections, which can appear in any order but are processed in the listed order:

  • <PLAYER_SETUP> – This section determines player placement and allows you to modify map attributes
  • <LAND_GENERATION> – Responsible for placing the main terrain features
  • <ELEVATION_GENERATION> – Creates hills or depressions on the map
  • <CLIFF_GENERATION> – Creates impassible cliffs
  • <TERRAIN_GENERATION> – Allows you to replace terrains with other terrains. Typically used to make the map look nicer or fine tune the map.
  • <CONNECTION_GENERATION> – Replaces terrain between the origin (center) of lands generated in the Land Generation step.
  • <OBJECTS_GENERATION> – Places starting units, buildings, resources, etc on the map

Technically only <PLAYER_SETUP> is required, but in practice most maps will use all of the sections (with the exception of <CLIFF_GENERATION>, which is frequently omitted). Each section has its own set of commands focused on the intent of the section as listed above. Of course there are more commands than we have time to dive into here – I highly recommend Zetnus’ Definitive Random Map Scripting Guide (linked in the sources) if you want a full overview of the available commands, their intended uses and syntax, and the caveats you’ll need to keep in mind along the way. Still, with just a few examples I think you’ll get enough of the flavor to be able to follow along. Lets start with the basic command syntax:

command
{
  attribute
  attribute2 argument
  /* Additional attributes and arguments as appropriate */
}

As you can see, the basic structure of a command is relatively straightforward. In practice, a simple command might look like this:

<LAND_GENERATION>
create_player_lands {
  terrain_type DIRT
  land_percent 20
}

This will generate land for each player in the game, using dirt as the terrain type, and will attempt to divide 20% of the map evenly across the generated lands.

Here’s a more complicated example:

<OBJECTS_GENERATION>
create_object FORAGE {
  number_of_objects 7
  set_tight_grouping
  group_placement_radius 1
  set_gaia_object_only
  set_place_for_every_player
  min_distance_to_players 7
  max_distance_to_players 8
}

In this case we’re creating Forage Bushes (specified by create_object FORAGE) with a number of conditions. As you’d expect, number_of_objects specifies how many to place (in this case, 7 individual bushes). The remaining commands are more interesting, as they’re instructing the map generator how and where to place the bushes. The set_tight_grouping command ensures the individual objects are placed on adjacent tiles, while group_placement_radius ensures the placed objects attempt to stay within a specified number of tiles from the central tile (in this case, 1 tile away, ensuring the bushes are placed within a 3×3 square). The set_place_for_every_player command ensures each player’s land(s) (created with create_player_lands, as seen above) have Forage Bushes placed on them, and since Forge Bushes are gaia (neutral) objects the set_gaia_object_only command also needs to be specified. Finally, min_distance_to_players and max_distance_to_players define the valid distance in tiles from the origin tile of the player land between which the Forage Bushes can be placed.

Note that most distance constraints are square distances, with the exception of find_closest, which uses circular distances. This has some interesting implications which we’ll get to in Part 2 of the series.

Injecting Some Randomness

Map scripts in AOE2 have a simple yet highly flexible approach to injecting randomness. Just about anywhere in the script you can specify a random block, which allows you to specify different potential behaviors (defining a constant, executing a command, specifying a particular argument within a command, etc.) and an assigned chance.

<OBJECTS_GENERATION>
start_random
  percent_chance 10
  create_object GOLD {
    start_random
      percent_chance 30        number_of_objects 5
      percent_chance 50        number_of_objects 6
      percent_chance 20        number_of_objects 7
    end_random
  }
end_random

In this example, we have a 10% chance to create a gold mine. If that gold mine is generated, there’s a 30% chance it will have 5 tiles of gold, a 50% chance it will have 6 tiles, and a 20% chance it will have 7. Note that if you specify branches adding to more than 100%, only options within the first 100% will have a chance to occur.

The other commonly used option for injecting randomness is to specify a random number range to use for an attribute value (typically, a quantity of some sort).

<OBJECTS_GENERATION>
  create_object GOLD {
    number_of_objects rnd(5,7)
    resource_delta rnd(-200,300)
}

In this example we’re generating a gold mine with 5-7 tiles of gold, then specifying that each tile should be set to between -200 and +300 the standard amount (800 gold). Note that resource_delta is the same for each generated tile – ie, the individual tiles in the mine will start with identical quantities of gold, with the starting quantity between 600 and 1100 gold.

Ifs, Conditionals, Constants, Includes, and Comments

Though I suspect this won’t be a major surprise to anyone familiar with programming, map scripts in AOE2 can include If/Else blocks. What may be surprising is how limited they are – the only condition they check is whether a specified label has been defined.

if ConditionalLabel 
  /* CODE, possibly including nested If/Else blocks */
elseif ConditionalLabel2 /* elseif is optional */
  /* CODE, possibly including nested If/Else blocks */
else /* else is also optional */
  /* CODE, possibly including nested If/Else blocks */
endif

Despite this, when combined with random blocks this provides a powerful tool for scripting. It also probably raises a question – where do conditional labels come from? As it turns out, there are two ways these can be defined. First, some conditionals are defined automatically by AOE2. For example, DE_AVAILABLE is set automatically by the AOE2 Definitive Edition, allowing scripts to easily identify when they’re being run by AOE2 DE (vs an older version of the game). AOE2 will also set conditionals based on the game’s lobby settings – for example, the Map Size selected will be used to set a conditional (TINY_MAP for Tiny map size, MEDIUM_MAP for Medium map size, etc). The second option is for the conditional to be declared in the script itself:

start_random
  percent_chance 25    #define WINTER
  percent_chance 25    #define AUTUMN
  percent_chance 25    #define SUMMER
  percent_chance 25    #define SPRING
end_random

Similar to a conditional, scripts also allow you to specify constants. This is extremely useful, as it allows you to set aliases for the numeric IDs used internally in AOE2 to represent objects, terrains, etc. For example, while Forage Bushes have an internal ID of 59, it’s much easier to follow the script if we refer to this ID via an alias (as seen in our earlier example). So before we place our Forage Bushes, we might want to define a useful constant:

#const  FORAGE		59

Of course doing this at the start of every script is rather repetitive, so AOE2 helpfully pre-defines many commonly used IDs in random_map.def, which is then automatically included in all maps. If you find yourself frequently defining the same constants, you can create your own .def files and import them into your script using #include_drs. Similarly, if you find yourself frequently writing the same commands (ex, placing starting player resources) you can create a .inc file and use #include_drs to include them in the section you’re currently working in (by convention it appears .inc files do not define RMS sections, though I’m unclear as to whether that’s a hard restriction). You can even import an entire map script (saved as either .rms or .rms2; the latter appears to mostly be used to identify the random map file as being created post-Age of Forgotten expansion) – this is commonly used for “blind random” map pools, where the intention is for players to have to discover the map they’re playing via exploration.

Finally, AOE2 scripts can include comments. These are useful in explaining what’s going on in various parts of the script, but you need to be careful to properly close your comments – otherwise the parser will interpret the rest of your script as a comment! Comments begin with /* followed by a space, and do not end until the parser finds a space followed by */

/* this is a comment */
/* this is a
multi-line comment
*/
/* /* this is a nested */ comment */

// this is NOT a comment

/*this is NOT a comment*/

/*** this is NOT a comment ***/

/* this comment never ends

/* this comment never ends*/

/* this comment never ends */*

There are some interesting quirks with how the AOE2 parser works, which can result in some… interesting behavior when parsing comments. To briefly summarize, AOE2’s parser assigns every RMS command, attribute, statement, etc. a numeric ID – including /* and */. The parser, while extremely robust (it will happily ignore items it can’t figure out to interpret), is not particularly smart, and when you combine that with the potential for overlap in IDs across contexts it can result in some amusing bugs:

/* this is a properly-ending comment HOUSE
SHORE_FISH this is a comment too HOUSE

Prior to AOE2 DE there were a number of additional bugs in the parser, but my understanding is these have largely been fixed… though a few new edge cases were introduced along the way. If you’re curious as to the full details I’d recommend checking out the Parser Pitfalls article linked to in Sources.

A Simple Example

So, lets take what we’ve learned and make a simple map. We’ll start with a very basic setup:

<PLAYER_SETUP>
random_placement

<LAND_GENERATION>
create_player_lands 
{
  terrain_type DIRT
  land_percent 20
}

<OBJECTS_GENERATION>
create_object TOWN_CENTER {
  set_place_for_every_player
  max_distance_to_players 0
}

After saving this as example.rms in \AoE2DE\resources\_common\random-map-scripts, I generated a few test maps to verify it was working, then used CaptureAge to take a screenshot:

Example map. Note how each player is placed in roughly a circle (the default behavior when random_placement is set) and how the player lands (dirt) are clearly distinguishable from the default (grass).

Of course, the map is pretty bare – lets add a few forests. While we’re at it, we’ll also add some starting resources and units to get the players started.

<PLAYER_SETUP>
random_placement

<LAND_GENERATION>
create_player_lands 
{
   terrain_type DIRT
   land_percent 20
}

<OBJECTS_GENERATION>
create_object TOWN_CENTER {
  set_place_for_every_player
  max_distance_to_players 0
}
create_object VILLAGER
{
  set_place_for_every_player
  min_distance_to_players       6
  max_distance_to_players       6
  min_distance_group_placement  2
 }
create_object SCOUT
{
  number_of_objects             1
  set_place_for_every_player
  min_distance_to_players       7
  max_distance_to_players       9
  avoid_forest_zone 1
  min_distance_group_placement  2
 }
create_object FORAGE {
  number_of_objects 7
  set_tight_grouping
  group_placement_radius 1
  set_gaia_object_only
  set_place_for_every_player
  min_distance_to_players 7
  max_distance_to_players 8
}
create_object GOLD_MINE {
  number_of_objects 7
  set_tight_grouping
  group_placement_radius 1
  set_gaia_object_only
  set_place_for_every_player
  min_distance_to_players 5
  max_distance_to_players 9
}
create_object STONE_MINE {
  number_of_objects 7
  set_tight_grouping
  group_placement_radius 1
  set_gaia_object_only
  set_place_for_every_player
  min_distance_to_players 7
  max_distance_to_players 10
}
create_object SHEEP {
  number_of_objects 4
  set_loose_grouping
  set_gaia_object_only
  set_place_for_every_player
  min_distance_to_players 7
  max_distance_to_players 8
}

<TERRAIN_GENERATION>
create_terrain FOREST {
  base_terrain GRASS
  land_percent 40
  number_of_clumps 10
}

And now let’s see what this looks like:

Our revised example map, now with forests and some starting units and resources. Notice how the random clumps of trees we’ve generated has sealed off the poor red player!

From our zoomed out perspective its easy to see how the map generator has clearly added a decent amount of forest to the map. It also highlights why there is a <CONNECTION_GENERATION> section – notice the bottom of the map, where the red player is completely sealed off by forests! Lets zoom in a bit closer:

V2 of our example map, zoomed in on the red player’s base. As expected, we now have some starting villagers, a scout, 4 sheep, and our berries, gold and stone.

The red player continues to showcase some of the limitations and naiveté of the map generator. While all of the expected resources are present, look closely at the stone mine. Remember how we many tiles we specified the Stone Mine should have in our script?

create_object STONE_MINE {
  number_of_objects 7
  set_tight_grouping
  group_placement_radius 1
  ...
}

Even though we specified 7 tiles of stone, only 6 were generated! Unfortunately for the red player, what appears to have happened here is the map generator placed the central stone tile adjacent to the forest. As a result, 3 of the tiles adjacent to the central tile already have trees on them, and since we specified a group_placement_radius of 1 (resulting in a 3×3 grid of available tiles to place stone in) the final stone tile was discarded!

Something else interesting is happening here. Remember how we initialized our villager? I’ll again save you the trouble of scrolling up:

create_object VILLAGER
{
  set_place_for_every_player
  min_distance_to_players       6
  max_distance_to_players       6
  min_distance_group_placement  2
}

Notice how we didn’t specify a quantity, but the red player (and all the players, in fact) started with 3 villagers? Villagers are treated specially by the map generation engine. Unlike other objects, not specifying number_of_objects will result in the game placing the expected starting number of villagers for the player (typically 3, but Mayans get an extra villager, and Chinese get 3 extra villagers) instead of placing a single object. The Scout object also has special handling – Mesoamerican civilizations (Aztecs, Mayans, Incas) automatically start with an Eagle Scout instead of a standard Scout.

Conclusion

As we’ve seen, the AOE2’s map scripting tools are extremely flexible, yet also very easy to get started with. The basic commands are generally easy to follow, and creating a simple map script is straightforward (the example I threw together in about 15 minutes, most of which was spent figuring out how I was going to take the screenshots!). However, much like AOE2’s gameplay, the scripting tools are easy to learn but hard to master. Map designers need to pay careful attention to how, where and when objects and terrain are placed to avoid bugged map generation; creating interesting and balanced maps pose additional challenges.

Stay tuned for next time, where we’ll dive into how one of AOE2’s most iconic maps – Arabia – is generated.

Sources

Add a Comment