Implementing games rules in stechec2¶
Introduction¶
So you want to organize a big contest between IAs and you don’t know where to start? This tutorial is for you! We’re going to implement a basic Connect Four game in this tutorial, step by step, to understand the mechanics of stechec2’s rules writing.
If you’re stuck in some part, you can help yourself with the tictactoe game
that has already been implemented in games/tictactoe
.
Think about The Game¶
We must define first exactly the rules of our game: how it works, who plays first, how many players a play can handle, who wins… The rules of the Connect Four are very simple: two players, a turn by turn game, only one action (drop a disk somewhere).
We can begin to describe the rules in the .yml:
$ mkdir connect4
$ cd connect4
$ vim connect4.yml
Write the YAML¶
First, we must write some config boilerplate at the top of the file:
name: connect4 # The name of the game
rules_type: turnbased # The type of rules to follow
constant:
# Place your constants here
enum:
# Place your enums here
struct:
# Place your structs here
function:
# Place the functions of the API here
user_function:
# Place the functions that the player sould implement here
The type system¶
Stechec2 uses its generators to implements some basic types in every supported
languages. When a field requires a type in the yaml, you can use the built-in
types (int
, bool
and string
), the structs you defined, the enums
you defined and _ array
(where _ is a type itself). You can even use arrays
of arrays (int array array array
, for instance, will create a 3D matrix of
ints).
The user functions¶
Depending of how the game works, the player should implement some functions in which he calls the actions of the API. Usually, this is done by:
- A function
init_game
, called at the start of the game, in which the player can create and initialize his objects - A function
play_turn
, called at each turn, in which he can play (call the API functions). - A function
end_game
, called at the end of the game, in which he can delete and free his objects.
We’re going to use these functions for our connect4, which does not require
more, but the player can have more functions to implement in a phase based
game: play_move_phase
and play_attack_phase
, for instance.
In the yaml, a function is described like this:
fct_name
: the name of the function.fct_summary
: a short documentation of the function.fct_ret_type
: the return type of the function (bool, void, int array…).fct_action
(yes/no): if the function is a game action (default value is no).fct_arg
: the list of arguments that the function takes. Each item is a list containing:- The name of the argument.
- The type of the argument.
- A short description of its use.
So, let’s write our user functions:
user_function:
-
fct_name: init_game
fct_summary: Function called at the start of the game
fct_ret_type: void
fct_arg: []
-
fct_name: play_turn
fct_summary: Function called at each turn
fct_ret_type: void
fct_arg: []
-
fct_name: end_game
fct_summary: Function called at the end of the game
fct_ret_type: void
fct_arg: []
The constants¶
A constant is described by three fields:
cst_name
: the name of the constant.cst_val
: the value of the constant.cst_comment
: a short description of the constant.
The only constants we’ll use in our game are the constants describing the size of the board, and the limit of players:
constant:
-
cst_name: NB_COLS
cst_val: 8
cst_comment: number of columns in the board
-
cst_name: NB_ROWS
cst_val: 6
cst_comment: number of rows in the board
-
cst_name: NB_PLAYERS
cst_val: 2
cst_comment: number of players during the game
The enumerations¶
An enumeration is described by three fields:
enum_name
: the name of the enum.enum_summary
: a short documentation of the enum.enum_field
: a list of the different fields of the enum. Each field is a list containing:- The name of the field.
- A short description of the field.
Our game will use only one enum, error
, the return value of action
functions.
We can write it quickly:
enum:
-
enum_name: error
enum_summary:
"Enumeration containing all possible error types that can be returned
by action functions"
enum_field:
- [ok, "no error occurred"]
- [out_of_bounds, "provided position is out of bounds"]
- [full, "the selected column is full"]
- [already_played, "you already played this turn, you cheater!"]
The structures¶
A structure is described by four fields:
str_name
: the name of the structure.str_summary
: a short documentation of the structurestr_tuple
(yes/no): if set to “yes”, in the languages that support it (Python and OCaml for instance), the structure will be represented as a tuple instead.str_field
: a list of the different fields of the struct. Each field is a list containing:- The name of the field.
- The type of the field.
- A short description of the field.
The only struct we’ll need is a position { int x; int y; }
, to describe a
position in the board:
struct:
-
str_name: position
str_summary: Represents a position in the board
str_tuple: yes
str_field:
- [x, int, "X coordinate (number of the column)"]
- [y, int, "Y coordinate (number of the row)"]
The functions¶
The next part is to write the API that the player will use to play. The functions are usually separated in three kinds:
- The observers: functions that the player can call to see the state of a game. They can take some parameters to describe the information that the player wants, and they return the desired information.
- The actions: functions that the player can call to perform some action. They
usually take some parameters to describe how the action should be executed
and return an error. Errors are generally represented by an enum you have to
implement. Note that you need to add a
fct_action: yes
field to the function. - The state modifiers: functions that can cancel some actions or modify the state of the game.
So, here are the observers we’ll implement:
my_player
: returns the ID of the current playerget_column
: returns the column (a int array corresponding to the disks of a column and their owners (-1 for “free”, the id of the player else). The indice0
of a column will represent its bottom.cell
: returns the owner of the specified cell (-1 for “free”).
The actions:
drop
: drop a disk at the specified column.
The modifiers:
cancel
: cancel the last action.
Add this at the end:
function:
-
fct_name: drop
fct_summary: Drop a disk at the given position
fct_ret_type: error
fct_action: yes
fct_arg:
- [column, int, column where to drop a disk]
-
fct_name: my_player
fct_summary: Return your player number
fct_ret_type: int
fct_arg: []
-
fct_name: get_column
fct_summary: Return the column; indice 0 represents the bottom
fct_ret_type: int array
fct_arg:
- [number, int, number of the column]
-
fct_name: cell
fct_summary: Return the player of a cell (-1 for "free")
fct_ret_type: int
fct_arg:
- [pos, position, position of the cell]
-
fct_name: cancel
fct_summary: Cancel the last played action
fct_ret_type: bool
fct_arg: []
And we’re done!
Generate the skeleton¶
Stechec2 provides a script to generate a skeleton of the rules. It really saves a lot of time, so don’t skip this part!
If you have properly installed stechec2, you should have the generator in your PATH:
$ stechec2-generator -h # Display a lot of useful help
$ stechec2-generator rules ./connect4.yml gen
$ mv gen/connect4/rules src
$ rm -rf gen
$ ls src
action_drop.cc actions.hh api.hh entry.cc game_state.hh rules.cc
action_drop.hh api.cc constant.hh game_state.cc interface.cc rules.hh
You don’t have to modify constant.hh
, entry.hh
and interface.hh
.
They are generated files that shouldn’t be manually edited.
The wscript¶
Stechec2 uses the waf.py Makefile-like to build the games. We need to create a
wscript
file in the root folder of our game, containing this:
#! /usr/bin/env python
def options(opt):
pass
def configure(cfg):
pass
def build(bld):
bld.shlib(
source = '''
src/action_drop.cc
src/api.cc
src/entry.cc
src/game_state.cc
src/interface.cc
src/rules.cc
''',
defines = ['MODULE_COLOR=ANSI_COL_BROWN', 'MODULE_NAME="rules"'],
target = 'connect4',
use = ['stechec2'],
)
bld.install_files('${PREFIX}/share/stechec2/connect4', [
'connect4.yml',
])
You can add source files to the source
string. You don’t need to change the
rest for now.
The rules¶
The loops¶
The first thing is to take a look at rules.cc
and rules.hh
. There are
the three functions every rules should implement: client_loop
,
spectator_loop
and server_loop
. Writing these loops are painful: you
have to handle the turns, the phases, the order of each players… luckily
stechec2 provides some generic loops for some kind of games: TurnBasedRules
and SynchronousRules
. By adding the rules_type
attribute in your
configuration file, we don’t need to worry about those functions.
If you’re interested in how the generic loops work behind the scene, you can
take a look at stechec2/src/lib/rules/rules.cc
.
The game-state¶
We need to have a gamestate class which will contain the state of the game, and which we can interact with (the methods of this class will change the state of the game.) The majority of this part will be left as an exercise for the reader.
The basics of the GameState class are generated in the files game_state.cc
and game_state.hh
. Besides the already presents method, you’ll also need
for this game to define the following: get_current_turn
and
increment_turn
which will do the needful with an internal counter, a
get_board
method which will return the 2D board, a drop
to drop a disk
somewhere, a is_full
to check if one can play in a specific column, and
finally, a winner
method which will return the winner if there’s one, -1
else.
Here’s a template of the additional functions you’ll need to implement:
void increment_turn();
int get_current_turn() const;
bool is_full(int column) const;
std::array<std::array<int, NB_COLS>, NB_ROWS> get_board() const;
int winner() const;
void drop(int column, int player);
You will need to include "constant.hh"
to make use of the constants.
Testing¶
Making unit test bit by bit as your rules are becoming more and more complex is really important: you don’t want to test all the possible cases with custom champions.
Let’s create a src/tests
folder, where we’ll put all our test files. The
tests use googletest, you can find a reference documentation.
Here, we’re going to create a test-gamestate.cc
to test that the functions
we just created are working well.
Here’s a template for test-gamestate.cc
:
#include <gtest/gtest.h>
#include "../game_state.hh"
class GameStateTest : public ::testing::Test
{
protected:
virtual void SetUp()
{
// Some code that will be executed before each test
// Create an array of two players
rules::Players_sptr players(new rules::Players {
std::vector<rules::Player_sptr> {
rules::Player_sptr(new rules::Player(0, 0)),
rules::Player_sptr(new rules::Player(1, 0)),
}
}
);
gamestate_ = new GameState(players);
}
GameState* gamestate_;
};
TEST_F(GameStateTest, TestName)
{
// Test content
}
You can then create as many tests as you want, for instance:
TEST_F(GameStateTest, CheckDropOverflow)
{
for (int i = 0; i < NB_ROWS; i++)
{
ASSERT_EQ(gamestate_->is_full(0), false);
gamestate_->drop(0, 0);
}
ASSERT_EQ(gamestate_->is_full(0), true);
}
Create the following tests:
- CheckFull: checks that
is_full
returnstrue
when the column is full - CheckDrop: checks that the board obtained by dropping disks is valid
- CheckWinner: checks that you winner() function works correctly
To take tests into account, you first need to update your wscript
#! /usr/bin/env python
import glob
import os.path
def options(opt):
pass
def configure(cfg):
pass
def build(bld):
bld.shlib(
source = '''
src/action_drop.cc
src/api.cc
src/entry.cc
src/game_state.cc
src/interface.cc
src/rules.cc
''',
defines = ['MODULE_COLOR=ANSI_COL_BROWN', 'MODULE_NAME="rules"'],
target = 'connect4',
use = ['stechec2'],
)
abs_pattern = os.path.join(bld.path.abspath(), 'src/tests/test-*.cc')
for test_src in glob.glob(abs_pattern):
test_name = os.path.split(test_src)[-1]
test_name = test_name[5:-3]
# Waf requires a relative path for the source
src_relpath = os.path.relpath(test_src, bld.path.abspath())
bld.program(
features = 'gtest',
source = src_relpath,
target = 'connect4-test-{}'.format(test_name),
use = ['connect4', 'stechec2-utils'],
includes = ['.'],
defines = ['MODULE_COLOR=ANSI_COL_PURPLE',
'MODULE_NAME="connect4"'],
)
bld.install_files('${PREFIX}/share/stechec2/connect4', [
'connect4.yml',
])
To run the tests, you just have to build using the --check
option:
./waf.py build --check
Running the testsuite is particularly useful when used along with coverage reports (see the Development section).
The actions¶
The actions are the only objects sent on the network. Let me expand on that part a bit. When you run a stechec2 match, you have a server and two clients. They load the same shared library that defines the rules of the game, and they create a local GameState (actually a linked list of gamestates, to allow a cancel() action that undoes actions). When a player wants to perform an action, the rules first check if the action can be made considering the current state of the game. If everything is okay, the stechec2 client “apply” the action to the gamestate and send the action over the network. The server then receives the action, and checks if it can be made too. If not, there’s a big synchronisation problem (or possibly an attack), so the server disconnects the client. Else, the server applies the action locally to his gamestate and broadcast the action to the other players (so that they can do the same with their gamestates).
An action must define five functions that will be used by the rules:
- check(gamestate): checks that the action can be applied on the gamestate ;
- apply_on(gamestate): applies the action to the given gamestate ;
- handle_buffer(buffer): used to serialize the action object to a buffer ;
- id(): returns the ID of the action (usually an element of an enum) ;
- player_id(): returns the ID of the player that sent the action ;
Most of these functions are already implemented automatically in actions.hh
,
but we still need to code the check
and apply_on
functions. Note that
check
should return an element of the error enumeration we’ve defined in the
rules (see constant.hh
): { OK, OUT_OF_BOUNDS, FULL, ALREADY_PLAYED }.
The API¶
In the bunch of files you’ve previously generated, there is a file called
api.cc
that will describe what happens when the player calls a function
during the game. These functions are directly “translated” in the language from
which they are calling them, so you just have to implement the behaviour as if
everyone played in C++.
The observers are a realy easy part, you just have to return some values from the GameState and the rules::Player objects. For instance with my_player:
int Api::my_player()
{
return player_->id;
}
Implement all the other observers: get_column
and get_cell
. In order to
call our gamestate-specific functions, you need to use the game_state_
member.
The cancel
function is already implemented in stechec2. To call it you just
have to do this:
bool Api::cancel()
{
if (!game_state_->can_cancel())
return false;
actions_.cancel();
game_state_ = rules::cancel(game_state_);
return true;
}
Internally, there’s a linked list of gamestates. The rules::cancel
function
simply removes the current gamestate and returns the last.
The actions in the API are already implemented. Each action, first calls the
appropriate check
function, and if this returns OK, calls apply_on
to
update our gamestate, and add the action to the actions list.
The rules object¶
Let’s typedef the function that will be called by the player as void*()’s in our rules.hh:
typedef void (*f_champion_init_game)();
typedef void (*f_champion_play_turn)();
typedef void (*f_champion_end_game)();
Then add these attributes to the Rules class:
protected:
f_champion_init_game champion_init_game;
f_champion_play_turn champion_play_turn;
f_champion_end_game champion_end_game;
In the Rules
constructor, we have to retrieve the champion library
Rules::Rules(const rules::Options opt)
: TurnBasedRules(opt), sandbox_(opt.time)
{
if (!opt.champion_lib.empty())
{
champion_dll_ = std::make_unique<utils::DLL>(opt.champion_lib);
champion_init_game_ =
champion_dll_->get<f_champion_init_game>("init_game");
champion_play_turn_ =
champion_dll_->get<f_champion_play_turn>("play_turn");
champion_end_game_ =
champion_dll_->get<f_champion_end_game>("end_game");
}
GameState* game_state = new GameState(opt.players);
api_ = std::make_unique<Api>(game_state, opt.player);
register_actions();
}
Then we can overload the functions defined in <rules/rules.hh>
to satisfy
our needs. For instance, we want to overload at_player_start
,
player_turn
and at_player_end
to execute the init_game
,
play_turn
and end_game
client functions. To do so, we’ll use the
sandbox object:
void Rules::at_player_start(rules::ClientMessenger_sptr)
{
try
{
sandbox_.execute(champion_init_game_);
}
catch (utils::SandboxTimeout)
{
FATAL("player_start: timeout");
}
}
void Rules::player_turn()
{
try
{
sandbox_.execute(champion_play_turn_);
}
catch (utils::SandboxTimeout)
{
FATAL("player_turn: timeout");
}
}
void Rules::at_player_end(rules::ClientMessenger_sptr)
{
try
{
sandbox_.execute(champion_end_game_);
}
catch (utils::SandboxTimeout)
{
FATAL("player_end: timeout");
}
}
We also need to implement functions such as start_of_player_turn
,
end_of_player_turn
, and is_finished
that will call our gamestates
functions:
void Rules::start_of_player_turn(unsigned int /* player_id */)
{
api_->game_state()->increment_turn();
}
void Rules::end_of_player_turn(unsigned int /* player_id */)
{
// Clear the list of game states at the end of each turn (half-round)
// We need the linked list of game states only for undo and history,
// therefore old states are not needed anymore after the turn ends.
api_->game_state()->clear_old_version();
}
bool Rules::is_finished()
{
const GameState* st = api_->game_state();
return st->winner() != -1;
}
In stechec2, there is a difference between turns and round. A round is made
up of 2 turns, one for each player. You can therefore overload the same
functions but for round specific needs, such as start_of_round
,
end_of_round
, etc.
And that’s it!