BaBiTS - Battling Binaries Tournament System

Do You remember the lightcycle race from the 1982 movie Tron? It is somewhat similar to the infamous Snake game (but definitely much more exciting). BaBiTS is hosting these type of games for racer algorithms (called Players).

 

Try the experimental frontend HERE

 

 

 Table of Contents

 Overview

BaBiTS is designed to be very flexible and modular. Because of this reason the core is very lightweight, and pretty much everything is implemented via containers and plugins.

System design diagram

For details, see API

Technology

 RingMaster

The Ringmaster is the very core of BaBiTS. Its only purpose is to manage an actual battle. For convenience a command line interface is available. To use it simply run the ringmaster binary with all the plugins you want to be loaded as command line arguments. You can also set certain parameters via command line.

$ ./ringmaster --help
Known command line arguments: 
--help|-h              Show this help
--verbose|-v           Enable verbose output
--shuffle|-s           Use randomized player starting positions
--width|-x   <num>     Set arena width
--height|-y  <num>     Set arena height
--timeout|-t <num>     Set player response timeout

Example:

./ringmaster  --width  40  --height  30  -v  spiral.so  spiral.so  spiral.so  jsonlogger.so  sdlclient.so

This command will start the RingMaster in a 40x30 arena with verbose logging, and

The battle has multiple rounds, and when no more than one player is alive, the battle is over.

 Plugins

Currently two types of plugins are supported: Players and Watchers.

 Configuring plugins

Currently the only option to communicate with the plugin from outside of the RingMaster are environment variables. The supportedEnvVars() function returns a list of the environment variable names the plugin uses. Other configuration methods (like having sections in an .ini file, etc) may be implemented in the future.

 Player plugin

Each racer is implemented as a Player plugin. After initialization, where the Player is informed about the sizes of the arena and its own position in it, the battle begins. In each round the Player is notified about the position of all other living players, and has to decide which direction to go (up/down/left/right).

The Player's next() function is executed on a separate thread every time, and all Player is running in parallel. The function must return until the configured timeout (100 msec by default), otherwise it is considered to continue int the same direction it was going last round. If the Player timeouts, it is killed.

See the Spiral or the Linear players as example.

 Watcher plugin

The Watcher is a plugin which does not participate in the battle, but gets all informations about it. This can be used for several purposes, like logging, displaying a UI, analyzing player behaviour, etc.

See the JSONLogger or the SDLClient examples.

 Containerization

With Docker files added, You can either build BaBiTS locally or pull it from the docker registry. See compose.yml for details. Basically to just run the ringmaster, download the image and run:

docker pull glezmen.hu:5000/babits-ringmaster
docker run --rm glezmen.hu:5000/babits-ringmaster

This should print the help and all usable plugins found.

To see it work, try:

docker run --rm  glezmen.hu:5000/ringmaster spiral.so linear.so rtlogger.so -x 10 -y 8

 Docker compose infrastructure

The concept is to have multiple containers with their own responsibility. It is already partly implemented. If You want to try it locally, download compose.yml and execute this command in the directory where compose.yml is located:

MONGO_USER="my_mongo_user" MONGO_PASSWORD="my_secret_pass"  docker compose up -d

After all the containers are up and running, You can talk to the REST server. For full documentation on REST API, see REST API doc. Simply replace glezmen.hu with localhost in all the examples to use your local BaBiTS infrastructure.

 Examples

Get list of available plugins:

curl localhost:34317/plugins

This will give You a list of the available plugins.

To run an actual battle via REST, send a POST request to /battles:

curl -X POST localhost:34317/battles  -d '{"plugins":["./spiral.so", "linear.so", "linear.so"], "width":20, "height":10}'

The response will contain the battle ID what You can use to query the battle result:

curl localhost:34317/battles/664df400f8e4a27589e59d38

Write your own plugin

It is very easy to write your own plugin. There is a pluginbuilder image to help you with the build process.

Here is a step by step guide on how to build a binary from your plugin code:

 1. Write the plugin code in C++

Note: it is very easy to create a wrapper using the C++ interface to be able to use code written in other languages, the repository has a Lua player as example.

See API for detailed description of the Plugin API.

See this example code with a very simple, "headless chicken" style player:

/*
 * Example Player plugin
 * This is an extremely simplified version, just to give you a skeleton.
 * 
 * A few notes:
 *
 * - If using the pluginbuilder container, the util.h will be in /opt/util,
 *   we are setting the include path in CMakeLists.txt.
 *
 * - The player just tries to go to a random direction every round where
 *   it wouldn't crash.
 *
 * - If it reaches a deadend and there is no escape, it stays in an infinite
 *   loop. The Ringmaster will drop the player after the timeout - and we
 *   crash anyway.
 */

#include <util.h>

/*
* These are needed only for the random engine
*/
#include <ctime>
#include <cstdlib>

static f_queryCallback queryPosition{nullptr};

extern "C" {

/*
* For upload, only PLAYER plugins are supported
*/
PluginType type()
{
    return PluginType::PLAYER;
}

/*
* We could do any necessary initialization here,
* for us it is only the random number generator
* initialization here
*/
int init(Coords size, Coords startPos, int timeoutMsec)
{
    std::srand(std::time(nullptr));

    return 0;
}

/*
* This is how the player will be called in the logs
*/
const char* name()
{
    return "Dummy";
}

/*
* The version is not actually used for anything else than
* including it in the player description
*/
const char* version()
{
    return "0.1";
}

/*
* Each plugin is supposed to have a unique signature which
* will be the same even in newer versions. The server will use
* this signature to decide if overwriting an already existing
* plugin with the same name is allowed or not. An already
* existing plugin file is overwritten only if their signature
* are the same.
* This can be pretty much anything unique, we advice to use
* an UUID which can be easily generated when starting to
* develop a new plugin.
*/
const char* signature()
{
    return "0bf36603-a150-499d-91f2-264510ecc5eb";
}

/*
* This is the most important function of the Player. It will be
* called every round and we need to respond with a direction to go.
*/
Direction next(Coords playerpos, const std::vector<Coords> opponents)
{
    Direction dir;
    do {
              /* select a totally random direction
               * note: when there are no empty cells around the player,
               * this will be an endless loop.
               * Normally you should NOT do this, but this way
               * we can demonstrate that the Ringmaster will see this
               * player is not responding, and will kill it without mercy.
               */
        dir = (Direction)(std::rand() % (int)Direction::DIRECTION_COUNT);
    } while (queryPosition(playerpos + dir) > 0); // see if we would crash into a wall (returned >0)

    return dir;
}

/*
* We could use our own map to track what is happening in the arena,
* but it is much easier to use the queryPosition callback. With this we
* can simply call queryPosition(Coords) to see what we have at a certain
* position.
*/
void setQueryCallback(int (*f_query)(const Coords &))
{
    queryPosition = f_query;
}

} // extern "C"

 2. Build the plugin using pluginbuilder container

Get the docker registry certificate and add it to your certs storage (these example commands are for Ubuntu, on other distributions you may need to use different commands):

curl -O https://glezmen.hu/glezmen.crt
sudo cp glezmen.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
sudo service docker restart

Pull the pluginbuilder image:

docker pull glezmen.hu:5000/babits-pluginbuilder

Create a directory containing ONLY your plugin source file. Change into it, and run the plugin builder with mounting this directory to /opt/src:

docker run --rm -v .:/opt/src glezmen.hu:5000/babits-pluginbuilder

The pluginbuilder container will start, search for any usable source files and create myplugin.so in your source directory.

*Note: instead of using plain .cpp files, you should create either a CMakeLists.txt file or a Makefile in the source directory. The pluginbuilder will find and use these files (in that order). *

Example CMakeLists.txt:

# Setting the minimum CMake version can be omitted but
# CMake will show a warning if it is missing
cmake_minimum_required(VERSION 3.15)

# Like above, if project() is missing we will get a
# warning
project(Dummy)

# If we are building in pluginbuilder container, util.h and
# util.cpp will be in /opt/util, this source should be mounted to
# /opt/src, so ${CMAKE_SOURCE_DIR}/../util will point to the directory
# with those files
include_directories(${CMAKE_SOURCE_DIR}/../util)

# We are creating a shared library. This is pretty much the only
# one line in this file which is REALLY needed, all the others are just
# for convenience and avoid warnings
add_library(dummy SHARED example_plugin.cpp)

# You should always want the compiler to show all its warnings
add_compile_options(-Wall -Wextra -Wpedantic)

# This is just for convenience, remove the "lib" prefix from filename
set_target_properties(dummy PROPERTIES PREFIX "")

# With this the pluginbuilder container will put the final binary
# in the source directory so You can find easily
install(TARGETS dummy DESTINATION .)

Example Makefile:

DIR      :=  $(strip $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))))
SOFILE   :=  dummy.so
DESTDIR  ?=  $(DIR)

all:
    g++ -o $(SOFILE) -shared -I/opt/util -Wall -Wextra -Wpedantic $(DIR)/example_plugin.cpp

install:
    mv $(PWD)/$(SOFILE) $(DESTDIR)/

 3. Test the plugin using ringmaster

Pull the ringmaster image:

docker pull glezmen.hu:5000/babits-ringmaster

When you are in the directory where you built your plugin, start the Ringmaster with the current directory mounted:

docker run --rm -v .:/opt/plugins glezmen.hu:5000/babits-ringmaster linear.so rtlogger.so /opt/plugins/myplugin.so

What are we doing here?

 4. Uploading your plugin

If you have a complete BaBiTS infrastructure running, you can upload your plugin binary and use it for battles. My server, glezmen.hu is hosting such an infrastructure and you can use it, but consider it just a testing environment what can change or go down anytime.

Note: Currently only Player plugins are accepted.

To upload the plugin, send it to the /plugins resource as a file attachment:

 curl -F "data=@myplugin.so"  glezmen.hu:34317/plugins

In the response you will get an ID belonging to that file upload:

{"id":"66504a8a5321a86b7701d874"}

The uploaded file will go through a testing process with multiple steps, like antimalware scanning, plugin interface checks, etc. This will take a while, therefore you are supposed to query this ID until the result is ready:

curl glezmen.hu:34317/plugins/66504a8a5321a86b7701d874

While the testing is in progress, you will get a response similar to this:

{ "_id" : { "$oid" : "66504a8a5321a86b7701d874" }, "filename" : "myplugin.so" }

After testing has finished, the result should look like:

{ "_id" : { "$oid" : "66504a8a5321a86b7701d874" }, "filename" : "myplugin.so", "type" : "player", "state" : "Accepted" }

If something went wrong, you will definitely see it in the response. As long as the state field is Accepted, everything is OK. If all tests were successful, your plugin will be included in the list of the available plugins, query the list with:

curl glezmen.hu:34317/plugins

The result should contain your plugin:

{"plugins":[{"env":[],"name":"Linear","path":"./linear.so","type":"player","version":"0.1"},{"env":[],"name":"spiral","path":"./spiral.so","type":"player","version":"0.1"},{"env":[],"name":"rtlogger","path":"./rtlogger.so","type":"watcher","version":"0.1"},{"env":[],"name":"jsonlogger","path":"./jsonlogger.so","type":"watcher","version":"0.1"},{"env":["BABITS_SDLCLIENT_FPS","BABITS_SDLCLIENT_WIDTH","BABITS_SDLCLIENT_HEIGHT","BABITS_SDLCLIENT_FULLSCREEN"],"name":"SDLclient","path":"./sdlclient.so","type":"watcher","version":"0.1"},{"env":[],"name":"Dummy","path":"./plugins/myplugin.so","type":"player","version":"0.1"}]}

Note the ./plugins/myplugin.so path. All the uploaded plugins will be available inside the plugins directory, and you have to refer to the uploaded plugins using this path.

 5. Start an online battle

To see your plugin racing against other players, start an online battle:

curl -X POST glezmen.hu:34317/battles -d '{"plugins":["spiral.so", "linear.so", "plugins/myplugin.so"], "width":30, "height":20}'

Note that we are using the plugins/myplugin.so path, as described before. The response should contain the ID of the battle. To query the battle result, use this ID:

curl glezmen.hu:34317/battles/66504dbe5321a86b7701d877

And the response will describe arena features, players and the winner. It will have the start and end timestamps and duration (in seconds) too.

{"_id":{"$oid":"66504dbe5321a86b7701d877"},"height":20,"players":[{"id":1,"name":"spiral","version":"0.1"},{"id":2,"name":"Linear","version":"0.1"},{"id":3,"name":"Dummy","version":"0.1"}],"timeout":100,"width":30,"winner":1}

You can even get the detailed representation of the battle including each steps by adding /details. You can use the returned detailed report to replay it using an offline GUI client, analyzing battles, etc.

curl glezmen.hu:34317/battles/66504dbe5321a86b7701d877/details

Please note that while the battle is in progress, you will not get partial results, only the start timestamp and the "in_progress" status:

{"_id":{"$oid":"665236606d1beecf6405d213"},"started":{"$date":1716663904000},"status":"in_progress"}

To avoid using too much resources, battles which take too long or would use too much memory will be cancelled. In that case you will get a result like this:

{"_id":{"$oid":"665236606d1beecf6405d213"},"duration":122,"finished":{"$date":1716664026000},"started":{"$date":1716663904000},"status":"failed"}

 6. Using GUI

Ringmaster image has a basic graphical display via sdlclient.so. To use this from docker, you have to mount /tmp/.X11-unix, pass you DISPLAY variable and may need to add your xauth token and use host networking. Ringmaster image has some support for this, but it may need heavy tinkering. Nevertheless to start with, you should pass DISPLAY, HOSTNAME and XAUTH_TOKEN variables to the container.

If you really want to see the graphical representation, run the ringmaster binary directly with sdlclient.so added to the plugin parameters list. The animated gif at the top of this page shows how SDLClient looks like.

7. Using Web UI

A web frontend is available on port 34318. Here You can start battles and tournaments, and can replay any of the battles with the desired speed.

 Tournaments

To effectively compare different players, You can start a tournament. You need to select the players to compete (for tournaments all of them must be unique, You are not allowed to list the same player more than once!) and the number of rounds. If there is no single winner after playing the selected number of battles, tiebreaker battles are added.

curl -X POST glezmen.hu:34317/tournaments -d '{"plugins":["spiral.so", "linear.so", "plugins/myplugin.so"], "rounds":5}'

Example response while tournament is in progress:

{ "_id" : { "$oid" : "665ad65989ca60546a05bb20" }, "status" : "in_progress", "battles" : [ { "$oid" : "665ad65a89ca60546a05bb21" }, { "$oid" : "665ad65b89ca60546a05bb22" }, { "$oid" : "665ad65c89ca60546a05bb23" }, { "$oid" : "665ad65d89ca60546a05bb24" } ], "rounds" : 5, "players" : [ "./spiral.so", "linear.so" ] }

Example response after tournament has been finished:

{ "_id" : { "$oid" : "665ad65989ca60546a05bb20" }, "status" : "finished", "battles" : [ { "$oid" : "665ad65a89ca60546a05bb21" }, { "$oid" : "665ad65b89ca60546a05bb22" }, { "$oid" : "665ad65c89ca60546a05bb23" }, { "$oid" : "665ad65d89ca60546a05bb24" }, { "$oid" : "665ad65e89ca60546a05bb25" } ], "rounds" : 5, "players" : [ "./spiral.so", "linear.so" ], "scores" : { "1" : 3 }, "winner" : 1 }

scores contains the number of wins for each player who has won at least one battle (key is player ID as listed in any of the battle responses, and they are actually 1-based indices of the players array). winner contains the ID of the player with the most wins.

Tournament battles are executed parallel, the number of battles running the same time depends on the limit set in compose.yml or the environment variable TOURNAMENT_PARALLEL_BATTLES and the number of tournaments already running. This ensures that a tournament is executed and finishes as soon as possible, but when several tournaments are running, the load on the server will not skyrocket (well, it will, but to a certain limit...).