ColorShapeLinks AI
An AI competition for the IEEE Conference on Games 2021
|
A guide on how to implement an AI thinker
This guide describes how to implement an AI thinker for the ColorShapeLinks competition. This is a simple process, requiring at the bare minimum the creation of a class which extends another class and overrides one of its methods. The Quick start guide shows how to get started quickly by simply dropping the new AI class in the console app or Unity app folders. However, implementing a competitive AI thinker will most likely require to know a bit more about the source code rules and restrictions and about the AbstractThinker base class. Setting up a proper development environment and being able to adequately test the developed code is also essential. This guide closes with a tutorial on how to implement a basic Minimax AI with a very simple heuristic.
The source code of AI thinkers must follow these rules and restrictions:
unsafe
contexts.AbstractThinker
] class or passed to the [Think()
] method, e.g., such as using reflection to probe the capabilities of its opponents.The first step to implement an AI thinker is to extend the AbstractThinker base class. This class has three overridable methods, but it's only mandatory to override one of them, as shown in the following table:
Method | Mandatory override? | Purpose |
---|---|---|
Setup() | No | Setup the AI thinker. |
Think() | Yes | Select the next move to perform. |
ToString() | No | Return the AI thinker's name. |
There's also the non-overridable OnThinkingInfo() method, which can be invoked for producing thinking information, mainly for debugging purposes. In the Unity frontend this information is printed on Unity's console, while in the console frontend the information is forwarded to the registered thinker listeners (which by default print to the console).
Classes extending AbstractThinker also inherit a number of useful read-only properties, namely board/match configuration properties (No. of rows, No. of columns, No. of pieces in sequence to win a game, No. of initial round pieces per player and No. of initial square pieces per player) and the time limit for the AI to play. Concerning the board/match configuration properties, these are also available in the board object given as a parameter to the Think() method. However, the Setup() method can only access them via the inherited properties.
The following subsections address the overriding of each of these three methods.
If an AI thinker needs to be configured before starting to play, the Setup() method is the place to do it. This method receives a single argument, a string
, which can contain thinker-specific parameters, such as maximum search depth, heuristic to use, and so on. It is the thinker's responsibility to parse this string. In the Unity frontend, the string is specified in the "Thinker params" field of the AIPlayer component. When using the console frontend, the string is passed via the --white/red-params
option for simple matches, or after the thinker's fully qualified name in the configuration file of a complete session. Besides the parameters string, the Setup() method also has access to board/match properties inherited from the base class.
The same AI thinker can represent both players in matches, as well as more than one player in sessions/tournaments. Additionally, separate instances of the same AI thinker can be configured with different parameters. In such a case it might be useful to also override the ToString() method for discriminating between the instances configured differently.
Note that concrete AI thinkers require a parameterless constructor in order to be found by the various frontends. Such constructor exists by default in C# classes if no other constructors are defined. However, it is not advisable to use a parameterless constructor to setup an AI thinker, since the various board/match properties will not be initialized at that time. This is yet another good reason to perform all thinker configuration tasks in the Setup() method. In any case, concrete AI thinkers don't need to provide an implementation of this method if they are not parameterizable.
The Think() method is where the AI actually does its job and is the only mandatory override when extending the AbstractThinker class. This method accepts the game board and a cancellation token, returning a FutureMove. In other words, the Think() method accepts the game board, the AI decides the best move to perform, returning it. The selected move will eventually be executed by the match engine.
The Think() method is called in a separate thread. As such, it should only access local instance data. The main thread may ask the AI to stop thinking, for example if the thinking time limit has expired. Thus, while thinking, the AI should frequently test if a cancellation request was made to the cancellation token. If so, it should return immediately with no move performed, as exemplified in the following code:
The game board can be freely modified within the Think() method, since this is a copy and not the original game board being used in the main thread. More specifically, the thinker can try moves with the DoMove() method, and cancel them with the UndoMove() method. The board keeps track of the move history, so the thinker can perform any sequence of moves, and roll them back afterwards.
The CheckWinner() method is useful to determine if there's a winner. If there is one, the solution is placed in the method's optional parameter.
For building heuristics, the public read-only variable winCorridors will probably be useful. This variable is a collection containing all corridors (sequences of positions) where promising or winning piece sequences may exist.
The AI thinker will lose the match in the following situations:
By default, the ToString() method removes the namespace from the thinkers' fully qualified name, as well as the "thinker", "aithinker" or "thinkerai" suffixes. However, this method can be overriden in order to behave differently. One such case is when thinkers are parameterizable, and differentiating between specific parametrizations during matches and sessions becomes important. For example, if an AI thinker is configurable with a maxDepth
parameter, the following would keep the base method behavior, while adding the value of the maximum depth to the thinkers' name:
In any case, concrete AI thinkers are not required to override this method.
The most basic development environment for a ColorShapeLinks AI thinker consists of cloning the repository and just place a new class directly in the console app or Unity app project folders. This is not practical, however, especially when using a version control system. Furthermore, although AI thinkers can be tested in Unity, serious development is much simpler within the context of the console frontend, which also allows thinkers to be tested in isolation. Thus, this section focuses on setting up a development environment within that context. Nonetheless, testing in Unity can still be done by copying the thinker code to the Unity app scripts folder.
The following commands are cross-platform and work in Linux, Windows and macOS, requiring only .NET Core ≥ 3.1 to be installed. On Windows, when not using Git Bash, replace slashes /
with backslashes \
when referencing local paths. The steps for setting up a development environment are as follows:
cd
into it: cd
into it: .gitignore
file and make the first commit: MyAI/
folder, create a new class which extends AbstractThinker and implements the Think() method: MyAISolution.MyAI.MyThinker
class appears in the "Known thinkers:" section (on Windows PowerShell replace $
(pwd)
with $pwd
or with the full path to the current folder): MyThinker
and the random AI provided with the development framework (on Windows PowerShell replace $
(pwd)
with $pwd
or with the full path to the current folder): The console guide describes all the available options for the console app, in particular the ones used in the above steps.
During development, it is crucial to be able to test the AI thinker in isolation, i.e., outside of running matches and sessions. This is easy to accomplish, requiring the creation of a test project which references the AI thinker-specific project (which we called MyAI
in the previous section). Continuing with the setup created in the previous section, and assuming we're in the my-ai-solution
folder, let's create a console project for testing our AI in isolation:
We also need to add a reference to the MyAI
project:
At this stage, it's a good idea to create a .NET solution to include both the MyAI
and TestMyAI
projects (which allows, for example, to have both projects open at the same time in Visual Studio):
The folder structure should now be:
We can now edit TestMyAI/Program.cs
, create an instance of MyThinker
, manipulate it and test it as we see fit, and run our test project with:
However, there is an important caveat to be aware of:
Thus, a ThinkerPrototype should instead be used to create an instance of the concrete AI thinker. This initializes the base class properties, as it also invokes the Setup() method. The ThinkerPrototype constructor requires three parameters:
The last parameter is generally a frontend-dependent type. However, the Common assembly contains the MatchConfig helper class, a simple container of match properties which can be used for this purpose. Thus, instantiating our basic AI thinker in isolation can be done as follows in the TestMyAI/Program.cs
file:
A more complete example is available here.
In this section we discuss the implementation of a basic Minimax AI thinker with a very simple heuristic. A minimax algorithm is a "recursive algorithm for choosing the next move in (...) a two-player game". The most basic version of this algorithm tries out all possible moves, branching out the game tree down to a maximum depth, since the search space would otherwise be too large. Most board states searched by the algorithm, even when it reaches maximum depth, will not be final boards. As such, we'll need an heuristic function to evaluate these non-final boards. An heuristic is "an educated guess, an intuitive judgment" which helps us evaluate the "goodness" of a board state. The better the heuristic, the better the AI will be able to evaluate intermediate boards, and the better it'll play.
Let's start with the template presented in the previous section:
A minimax algorithm works by maximizing the heuristic score of all possible moves when it's the AI's turn to play, and minimizing it when it's the opponent's turn. As such, a Minimax()
function requires:
It will also need the CancellationToken
, so it can check for cancellation requests from the main thread. As such, the code will look something like:
The infrastructure is all set. The following steps have to be implemented in the Minimax()
function:
Minimax()
recursively, selecting the best score and associated move (i.e., maximizing) if it's the AI's turn, or selecting the worst score and associated move (i.e., minimizing) if it's the opponent's turn.Implementing this reasoning in the Minimax()
function can be done as follows:
We're almost there, but there's still a piece missing: the heuristic function. This is a fundamental part of the solution, and as such, only a very basic approach is discussed here. Intuitively, pieces near or at the center of the board potentially contribute to more winning sequences than pieces near corners or edges. This is not a scientific claim, just a (possibly unfounded) guess. As such, let's build an heuristic that values pieces placed closer to the center of the board:
We now have a working AI thinker. We can make our thinker more flexible by allowing the maximum depth to be specified using the Setup()
parameters string:
It would also be useful to differentiate between instances of our AI thinker parameterized with various maximum depths, playing against each other in matches or tournaments. This can be accomplished by overriding the ToString()
method and customizing the AI thinker's name:
Although this implementation will win against a random player (unless the random player is really lucky), and probably some human players as well, it's in reality a very simple solution. Thus, while it's a good way of getting started in board game AI, it won't go very far in a competition. The complete code of this AI thinker is as follows (it's also in the MinimaxAIThinker class, included with the development framework):