Skip to content
Snippets Groups Projects
Select Git revision
  • e982821c93cbb25b1e21653276f5e33bdab0a7e2
  • main default protected
2 results

advent-of-coding

Boost Board Game Application (Fall 2022 Version)

A progressive web app (PWA) for learning and playing the board game Boost.

Boost is a turn-based abstract strategy board game like checkers, chess, Xiangqi, or Shōgi. It was designed to be new and interesting for humans to play while still admitting a simple AI and supporting various homework assignments on algorithms and data structures in the SOFT 260 course at UNL.

Quick Start

Recursively clone this repository and cd into the root folder:

$ git clone --recursive git@git.unl.edu:soft-core/soft-260/boost-board-game.git
$ cd boost-board-game

(If you forget the --recursive flag when cloning, you can cd into your clone and run git submodule update --init --recursive instead.)

Install dependencies:

$ npm install

Optionally, run the test suites to make sure everything so far is okay:

$ npm run test

Copy the file ./boost-server/.env.example to ./boost-server/.env and open it an editor. Edit it as described the instructions at the top and also the comments above each variable—you should at a minimum set VAPID_EMAIL_ADDRESS to your email address, VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY to a freshly generated key pair, NOREPLY_ADDRESS and NOREPLY_PASSWORD to the address and password for a test email account, and PEPPER to a long, hard-to-guess string. Save your changes.

Once the multiplayer server has been configured, start it on your local machine:

$ npm run multiplayer-server

Then, open another terminal, cd again into the boost-board-game directory, and serve the application locally:

$ npm start

Once the app is running in your browser, click on the "Rules" button to read the rules of the game. You might also try playing a local game against the AI to make sure it is working (if not, you may have broken symlinks in …/boost-app/public/) and try registering a network account to make sure that the app can communicate with your multiplayer server.

When you are done, press control-c in each terminal to stop the multiplayer server and the app server.

Development Workflow

The project includes a VSCode workspace named boost-board-game.code-workspace. Most development tasks can be completed from within VSCode, though command-line development is also possible. The subsections below describe how to perform common development tasks.

Code Generation

As described later in the architecture overview, some projects depend on code generated by other projects. Normally this code generation happens automatically when you lint, test, build, or run the app, but if you need to force an update for a particular project (for example, if you change the game or engine code while the app is still running), you can use that project's generate script. In VSCode, the generate scripts can be run from the "NPM SCRIPTS" tray, and the generate script in the outermost package.json also generates code for all projects. Or, on the command line:

$ (cd; npm run generate)

for a specific project or

$ npm run generate

for all projects.

Linting

VSCode should automatically detect each project's stylelint and ESLint configurations and dependencies and run those tools on the fly as you edit the code, though you may have to tell VSCode to trust the workspace the first time you open it.

Alternatively, you can also manually lint. In VSCode, the lint scripts can be run from the "NPM SCRIPTS" tray, where the lint script in the outermost package.json generates code for all projects. Or, on the command line:

$ (cd; npm run lint)

for a specific project or

$ npm run lint

for all projects.

Because of a Git precommit hook, linting happens automatically when you commit, and commits are blocked if there are any linting errors.

Unit Testing

Individual projects' unit test suites are set up to run in "watch" mode by default, which means that you can start the test script once, and it will rerun the test suite every time you save a code change. In VSCode, the test scripts can be run from the "NPM SCRIPTS" tray. Or, on the command line:

$ (cd; npm run test)

If you want to run a project's tests only once and not watch for code changes, you can use the test-once scripts. On the command line:

$ (cd; npm run test-once)

Alternatively, to run the unit tests once for all projects, run either the test or the test-once script from the outermost package.json. From the command line:

$ npm run test

On the command line only, if you want to only run tests whose names match some text, you can pass the standard -t option to a project-specific test or test-once script:

$ (cd; npm run test -- -t '[some test-name text]')

Other Jest options, like -u, can also be passed in a similar manner:

$ (cd; npm run test -- -u)

or combined:

$ (cd; npm run test -- -t '[some test-name text]' -u)

System Testing

Both the multiplayer server and the main app are set up to run in "watch" mode, which means that you can start them once, and every time you save a relevant code change, the multiplayer server will restart and/or the main app will refresh in your browser.

From VSCode, start the multiplayer server by running the outermost package.json's multiplayer-server script from the "NPM SCRIPTS" tray. Or, on the command line:

$ npm run multiplayer-server

(You do not need to start the multiplayer server if you will not be using the networked portions of the app or if you have changed the server URL in …/boost-app/.env to point at a multiplayer server running elsewhere.)

Similarly, run the main app from VSCode using the outermost package.json's start script from the "NPM SCRIPTS" tray. Or, on the command line:

$ npm run start

Alternatively, the start script in the boost-app project also starts the main app, but without rebuilding the engine, which means it runs faster. It can be run either from VSCode's "NPM SCRIPTS" tray or the command line:

$ (cd boost-app; npm run start)

Automatic refreshes due to code changes will not clear any persisted Redux state. Because the app uses the local storage engine from redux-persist for persistence, you can clear your data during development by running localStorage.clear() in the developer console and then manually refreshing the page.

Certain app features, like the ability to continue running offline or the ability to receive push notifications, depend on the app's service worker, which is normally only available in a production build. To run a production build of the main app locally, you can either use the outermost package.json's production script from the "NPM SCRIPTS" tray in VSCode or, on the command line:

$ npm run production

Alternatively, the production script in the boost-app project also starts a production build of the main app, but without rebuilding the engine, which means it runs faster. It can be run either from VSCode's "NPM SCRIPTS" tray or the command line:

$ (cd boost-app; npm run production)

There are some caveats to system testing with a production build.

First, the build will use settings from …/boost-app/.env.production, not …/boost-app/.env, so make sure that that file is configured for your environment.

Second, a production build does not run in watch mode, so it will not automatically restart when you make code changes. You must manually stop it by clicking the "trashcan" icon on its terminal in VSCode or pressing control-c if you used an ordinary terminal and then rerun the production script.

Third, even if you restart the production server, the service worker may still be caching old resources in your browser. To invalidate that cache, open your browser's developer tools while viewing the main app and, under the "Application" pane, unregister the service worker. Once it is unregistered, make sure that you close all tabs where the main app is running, and finally open the app again. You can then check that you have an updated service worker by looking at its timestamp on the "Application" pane.

Be especially careful to always unregister the service worker before going back to running a normal development build—if you are running both kinds of builds on the same port, the production build's service worker can mask changes from the development build.

Fourth, if your browser is running in HTTPS-only mode, you will need to add an exception for the URL you are using (probably localhost:3000) to be able to connect to the multiplayer server. (It is technically possible to support the wss protocol on websocket connections to localhost by using a self-signed certificate, but adding a exception in the browser is much easier.)

Deployment

The code for the main app runs entirely client-side, so it can be deployed as a build folder to be placed on any hosting platform. First, make sure that the URL in …/boost-app/.env.production is pointing at a non-localhost multiplayer server. Then from VSCode, run the outermost package.json's build script from the "NPM SCRIPTS" tray. Or, on the command line:

$ npm run build

Alternatively, the build script in the boost-app project also builds the app, but without rebuilding the engine. It can be run either from the "NPM SCRIPTS" tray or the command line:

$ (cd boost-app; npm run build)

At the end of either command's output you should see a link to further deployment instructions.

Architecture Overview

The code for the Boost board game application is organized as follows:

  • The project @unlsoft/stylelint-config contains the stylelint configuration for the CSS coding style used across the other projects.

  • The project @unlsoft/eslint-config contains the ESLint configuration for the JavaScript coding style used across the other projects. Per create-react-app convention, in a development build of the main app, a separate, weaker coding style also warns at runtime about likely bugs.

  • The project @unlsoft/boost-game is responsible for representing positions as bit boards and for features that rely on bit-board operations to run acceptably fast, primarily move generation and static evaluation. Because so much of the bit-board logic can be precomputed and unrolled, @unlsoft/boost-game does not implement game logic itself, but is actually a parameterized code generator that writes game logic into separate JavaScript files.

  • The project @unlsoft/boost-engine contains the game-playing engine, the application's AI. Like other abstract strategy board game engines, this engine is designed to run in a separate thread or process from any UI, and it communicates with a controller using a protocol called BEI (see the protocol documentation further below). The engine has a library of games that it knows how to play, and its build system automatically generates the source code for each of these games using @unlsoft/boost-game.

  • The project @unlsoft/boost-app is a create-react-app PWA that provides the application's game controller and UI. Like @unlsoft/boost-engine it relies on a game library generated by @unlsoft/boost-game, and it also includes @unlsoft/boost-engine as a suite of web workers via symlink.

  • The project @unlsoft/boost-server is a websocket server that coordinates communication between clients for networked multiplayer games. Like other projects, it relies on a game library generated by @unlsoft/boost-game to generate starting positions and check move legality.

Generating Game Objects with boost-game

When @unlsoft/boost-game is installed as a development dependency, it provides a command generate-boost-game that writes a JavaScript implementation of a Boost game to standard out. The exact code produced depends on a number of command-line options, described below. You can also run generate-boost-game --help to see a list of all of the above options, their short descriptions, and their default values.

Command-Line Options Affecting Game Rules

There are six main command-line options that can be passed to generate-boost-game to control what kind of game is generated:

  • --board-width [number] sets the width of the game board in points. If omitted, the default width of nine points is used.

  • --board-height [number] sets the height of the game board in points. If omitted, the default height of nine points is used.

  • --player-count [number] sets the number of players (and therefore the number of non-dragon piece colors). If omitted, the default of two players is used.

  • --starting-population-limit [number] sets the maximum number of starting pawns given to each player in a standard board setup. (It is only a maximum because the game code may give players fewer starting pieces when the board's perimeter is too crowded.) If omitted, the default limit of eight pawns is used.

  • --tower-limit [number] sets the maximum number of towers that each player may build. (This limit only affects when construction moves are available to a player; it does not preclude other code from placing extra towers on the board.) If omitted, the default limit of two towers is used.

  • --demo overrides the usual rules to allows players to move their pieces even when they are defeated. This can be useful both in testing and tutorials for keeping the number of pieces on the board down.

Based on these six options, the game will be assigned a unique string of the form boost-[width]-[height]-[players]-[population]-[towers] or of the form boost-[width]-[height]-[players]-[population]-[towers]-demo, which is called its game identifier. For example, the game identifier for a standard Boost game is boost-9-9-2-8-2.

Command-Line Options Affecting the AI

Other command-line options are also available to tweak the weights in the static evaluation function, which is the function used by @unlsoft/boost-engine to estimate how favorable a position is for each player. The defaults values, used when these options are omitted, were chosen by human intuition and are given in decitempi (tenths of an extra turn). However, these defaults have not been empirically validated yet. If they are changed, care must be taken to consider what behavior is incentivized by their relative values. For example, if the value for being in book is too high relative to the value of a tower, the AI might forego its second tower to shuffle its pieces instead of exiting the book line cleanly.

The command-line options for static evaluation weights are as follows:

  • --ai-pawn [number] sets the estimated value of a pawn. The default value is 200 decitempi (20 extra turns).

  • --ai-knight [number] sets the estimated value of a knight when a player has all of their towers. The default value is 220 decitempi (a pawn plus two extra turns).

  • --ai-endgame-knight [number] sets the estimated value of a knight when a player does not have all of their towers. The game also computes an appropriate fixed penalty for a player not having all of their towers so that the AI is not incentivized to sacrifice its towers to make its knights more valuable. The default value is 350 decitempi (a pawn plus 15 extra turns).

  • --ai-knight-activity [number] sets the estimated value of moving a knight one point closer to the closest non-friendly piece (i.e., a dragon or a foe). The default value is 5 decitempi (an extra half turn).

  • --ai-zero-activity-distance [number] sets the distance, in points, that must lie between a knight and the closest non-friendly piece for that knight to have an activity score of zero. As a knight moves closer to the non-friendly piece, its activity score becomes positive (at the rate given by --ai-knight-activity), and as it moves farther away, its activity score turns negative (at the same rate). The main use of this parameter is to control the relative value of pawns and inactive knights; because an inactive knight prevents a player from promoting a better-positioned pawn, the knight can actually be a liability. The default value is four points (so a knight eight points from the nearest non-friendly piece is estimated to be worth the same as a pawn).

  • --ai-edge-knight-penalty [number] sets the estimated decrease in value for having a knight on the edge of the board. The default value is 30 decitempi (a loss of three turns).

  • --ai-in-book [number] sets the estimated value of remaining in book during the opening. (An opening book is a precomputed policy for how to play certain opening positions, and in Boost the opening book usually covers lines up through construction of a second tower. If the AI is "in book", it has a position covered by that policy, though it is still free to improvise variations; if it is "out of book", then it must rely on search alone.) The default value is 30 decitempi (3 turns).

  • --ai-construction-site [number] sets the estimated value of a completed construction site (a circle of four friendly pieces around an empty point). Partial construction sites are awarded partial points: 1/16 value for a construction site with one piece in place, 4/16 value for a construction site with two pieces in place, and 9/16 value for a construction site with three pieces in place. The number of construction sites that the AI can score for each side is limited by the number of additional towers that the player can usefully build. Additionally, construction sites are ineligible for scoring if they are not within four points of four friendly mobile pieces. The default value is 80 decitempi (8 turns).

  • --ai-tower [number] sets the estimated value of a tower when a player has at least four mobile pieces. The default value is 125 decitempi (12.5 extra turns).

  • --ai-endgame-tower [number] sets the estimated value of a tower when a player does not have at least four mobile pieces. The game also computes an appropriate fixed penalty for a player not having four mobile pieces so that the AI is not incentivized to sacrifice its pawns and knights to make its towers more valuable. The default value is 700 decitempi (70 extra turns).

  • --ai-dragon-proximity [number] sets the estimated value of moving a dragon one point closer to the closest friendly tower when there are at least four dragons on the board. The default value is 10 decitempi (an extra turn).

  • --ai-tight-spot-penalty [number] sets the estimated decrease in value for having a dragon in a "tight spot"—diagonally between a tower and an edge of the board. The default value is 30 decitempi (three extra turns).

  • --ai-circle-dragon [number] sets the estimated additional value of moving a dragon next to a friendly tower when there are at least four dragons on the board. The default value is 100 decitempi (ten extra turns).

  • --ai-crowd-member [number] sets the estimated additional value of a piece that has at least three other friendly pieces at most four points away. The default value is 1 decitempo (one tenth of an extra turn).

  • --ai-defeat [number] sets the estimated value of defeating one opponent of many. The default value is 1,000,000 decitempi (100,000 extra turns).

  • --ai-victory [number] sets the estimated value of defeating all opponents. The default value is 10,000,000 decitempi (1,000,000 extra turns).

Typical Usage

By convention, one usually redirects the output of generate-boost-game to JavaScript files in a …/src/games folder and then writes code like

import _GAME from './games/….js';
import _GAME from './games/….js';

const GAME_LIBRARY = new Map([
  _GAME,
  _GAME,
].map((game) => [game.identifier, game]));
export default GAME_LIBRARY;

in …/src/gameLibrary.js so that other code can import the game library and look up games by their identifiers.

Because Node 18 has a bug where redirection does not work from a Node process in a package.json, generate-boost-game also offers a --output-file [path] option which will cause it to save the generated code directly to a given path. The bug appears to be fixed in the Node 19 prerelease, so hopefully that workaround will not be required in the future.

Using Game Objects from @unlsoft/boost-game

Game objects from a game library provide the following fields and methods:

Constants from Game Generation

These constants are set at code generation time, as described earlier:

  • game.identifier is the identifier for the game, as described in the previous section.

  • game.boardWidth is the width, in points, of the board on which the game is played.

  • game.boardHeight is the height, in points, of the board on which the game is played.

  • game.compensationFromPoint is the point, as a file-rank pair, on the board where a dragon compensation move would begin.

  • game.compensationToPoint is the point, as a file-rank pair, on the board where a dragon compensation move would end.

  • game.playerCount is the number of players in the game.

  • game.populationLimit is the maximum number of starting pawns given to each player in a standard board setup.

  • game.towerLimit is the maximum number of towers that each player may build. (This limit only affects when construction moves are available to a player; it does not preclude other code from placing extra towers on the board.)

  • game.victory is the static evaluation returned by getStaticEvaluation (see its description in the section on static evaluation) if the player has won the game.

Piece Types

These constants represent the different piece types, which are returned by position.getColorAndPieceType and passed to position.modified as described later:

  • game.dragon is a constant value used to represent a dragon.

  • game.pawn is a constant value used to represent a pawn.

  • game.knight is a constant value used to represent a knight.

  • game.tower is a constant value used to represent a tower.

Although they must be serializable and are therefore not Symbols, they should still be considered opaque. Do not write code depending on them having particular values.

Algebraic Notation

The following fields and helper methods are useful for encoding and decoding algebraic notation for files, ranks, points, and moves:

  • game.prettifyFile(file) takes a file as a zero-based 𝑥 coordinate and returns the corresponding letter in algebraic notation.

  • game.unprettifyFile(file) takes a file as a letter in algebraic notation and returns the corresponding zero-based 𝑥 coordinate.

  • game.prettifyRank(rank) takes a rank as a zero-based 𝑦 coordinate and returns the corresponding digits in algebraic notation.

  • game.unprettifyRank(rank) takes a rank as a string of digits in algebraic notation and returns the corresponding zero-based 𝑦 coordinate.

  • game.noPoint is the constant string representing no point at all in algebraic notation.

  • game.prettifyPoint(file, rank) takes a point as zero-based 𝑥 and 𝑦 coordinates and returns the corresponding algebraic notation. If either coordinate is undefined, it returns game.noPoint.

  • game.unprettifyPoint(point) takes a point in algebraic notation and returns the corresponding zero-based 𝑥 and 𝑦 coordinates in an Array. If the point is game.noPoint, both coordinates will be undefined.

  • game.pass is the constant string representing a pass in algebraic notation.

  • game.joinPoints(fromPoint, toPoint) takes two points in algebraic notation and returns the algebraic notation for a move from fromPoint to toPoint. For constructions and promotions, one point may be omitted or set to game.noPoint, or the two points may be set equal to each other. (For consistency with game.splitMove and the main app's UI, the preferred practice is to give the same point for both arguments.) For passes, both points should be omitted or set to game.noPoint. (For consistency with game.splitMove, the preferred practice is to pass game.noPoint for both arguments.)

  • game.splitMove(move) takes a move in algebraic notation and returns the algebraic notation for its "from" and "to" points in an Array. For constructions and promotions, the "from" point will be the same as the "to" point. For passes, both points will be game.noPoint.

  • prettifyMove(fromFile, fromRank, toFile, toRank) takes a move as zero-based 𝑥 and 𝑦 coordinates for its "from" and "to" points and returns the algebraic notation for the move. Coordinates may be omitted or undefined for special moves in the same places where game.joinPoints would take game.noPoint.

  • unprettifyMove(move) takes a move in algebraic notation and returns the corresponding zero-based 𝑥 and 𝑦 coordinates for its "from" and "to" points. Coordinates will be repeated for constructions and promotions, and will be undefined for passes.

Positions

The following fields and helper method are useful for obtaining Position objects corresponding to the game:

  • game.blankPosition is a position containing no pieces.

  • game.startingPosition is the game's standard starting position without any dragons.

  • game.buildStartingPositionWithDragons(requestedDragonCount) creates a starting position for the game (no compensation move yet played) with up to the requested number of randomly placed dragons, where the only possible reductions from the requested count would be due to the following restrictions:

    • To maintain symmetry, a game board with even width or height will not be given an odd number of dragons.

    • To maintain playability, no more than 75% of the available points will be filled with dragons. (The points just north and south of the center do not count as available because they must remain open for compensation moves.)

  • game.deserializePosition(serialization) deserializes a position previously encoded as a string with position.serialization, a property described further below.

Using Position Objects from @unlsoft/boost-game

Position objects corresponding to a game provide the following fields and methods:

Encodings

  • position.signature is the position encoded as a single BigInt. This encoding is meant to be used as a reasonably fast hash-table key. (The position itself cannot be a hash-table key because ES6 does not support hash-by-value natively.)

  • position.nextSignature is equivalent to position.nextTurn.signature (see the description of position.nextTurn in the "Moves" subsection further below), but runs faster because it does not actually advance the turn. It is mostly useful for checking candidate moves for repetitions.

  • position.serialization is the position serialized as a string. A position can later be deserialized with game.deserializePosition as described earlier.

Pieces

  • position.getColorAndPieceType(x, y) returns the color and piece type of the piece at the zero-based coordinates x and y as a two-element Array. Colors are represented by the number of turns until the corresponding player will play (e.g., 0 for the player whose turn it is and 1 for the other player in a two-player game), and piece types are represented with the constants game.dragon, game.pawn, game.knight, and game.tower described earlier. Dragon's colors are always undefined. If there is no piece at the given coordinates, both the color and piece type will be undefined.

  • position.modified(x, y, color, pieceType) returns a new position (Position objects are immutable) where the piece at the zero-based coordinates x and y has been replaced with a piece of the given color and piece type. As above, the color should be given as the number of turns until the corresponding player moves next (e.g., 0 for the player whose turn it is and 1 for the other player in a two-player game), and piece types should be given as one of the constants game.dragon, game.pawn, game.knight, or game.tower, which are also described earlier. As special cases, color should be undefined if pieceType is game.dragon, and a point can be cleared by passing undefined for both color and pieceType.

Static Evaluation

  • position.getStaticEvaluation(color) is the static evaluation of the position from the perspective of the player who is to play in color turns (hence, the possible values for color are the same as the values returned by position.getColorAndPieceType or passed to position.modified). Higher static evaluation scores are more favorable for that player. A score equal to game.victory means that the player to move next has already won.

  • position.getCircleEvaluation(color) is the contribution to the static evaluation for color from that player's progress on a dragon circle. It ignores all other factors, even other player's progress on dragon circles. A score equal to game.victory means that the player has won by completing a dragon circle.

  • position.inBookBonus is the adjustment to make to a static evaluation when a player is in book (see --ai-in-book above). This value is provided separately because the opening book, if any, is the engine's responsibility, and the getStaticEvaluation method only gives a bookless evaluation.

  • position.live is true if the game is still ongoing, false if any player has won.

Moves

  • position.children is the list of positions that the current player can move to, not considering the repetition rule. Note that it is the same player's turn in the children positions as in the parent position; a turn is not considered complete until the code uses position.nextTurn.

  • position.getEncodedMoveTo(child) returns an opaque representation of the move from position to child. This method runs faster than getMoveTo, described below, but the move is not human-readable.

  • position.getChildByEncodedMove(encodedMove) returns the child reached by playing the move given by its opaque representation.

  • position.getMoveTo(child) returns the algebraic notation for the move from position to child.

  • position.getChildByMove(move) returns the child reached by playing the move given in algebraic notation.

  • position.nextTurn is the same as position, except that in position.nextTurn it is the next player's turn. So, for example, position.getChildByMove('a1a3').nextTurn would be the position after the current player played the move a1a3.

Side Statistics

The field position.sides is an array of Side objects, one for each player, indexed by the number of turns until that player is to play (hence, the possible values for the array index are the same as the values returned by position.getColorAndPieceType or passed to position.modified). The field position.dragons is a Side object for the neutral dragons. Each Side object has the following fields:

  • side.openingSignature is the player's pieces encoded as a single BigInt, but without promotion information, so it treats pawns and knights identically. This encoding is meant to be used as a reasonably fast hash-table key in the code for an opening book. (The side itself cannot be a hash-table key because ES6 does not support hash-by-value natively.)

  • side.pawnCount is the number of pawns controlled by the player as a BigInt. Dragons count as pawns for the dragon side.

  • side.knightCount is the number of knights controlled by the player as a BigInt. This count is always zero for the dragon side.

  • side.towerCount is the number of towers controlled by the player as a BigInt. This count is always zero for the dragon side.

  • side.population is the total number of mobile pieces (pawns and knights) controlled by the player as a BigInt. Dragons count as pawns for the dragon side.

Using Controller Objects from @unlsoft/boost-engine

When @unlsoft/boost-game is installed as a development dependency, it provides two minified files, …/node_modules/@unlsoft/boost-engine/dist/engine.js and …/node_modules/@unlsoft/boost-engine/dist/engineThread.js that implement the engine's web workers. These files should be included as-is in any app that wants to use the engine; for a create-react-app app, the easiest approach is to symlink them to the public folder.

For talking to these web workers, the default export from @unlsoft/boost-engine is a Controller class, where Controller objects provide the following methods:

  • new Controller(engineURL, engineThreadURL, gameIdentifier, propertyHandler, moveHandler) creates a controller for an engine whose web workers' source code is located at the given URLs and that will play the game identified by gameIdentifier. Two callbacks must be provided:

    • propertyHandler will be called with a property name and a value whenever the engine sends a description of itself (see the documentation for the id BEI message below).

    • moveHandler will be called with four arguments whenever the engine makes a move (see the documentation for the move BEI message below):

      • the engine's score for the position in decitempi,

      • the chosen move in algebraic notation, and

      • a list of the moves the engine considers best, including the chosen move, also in algebraic notation.

  • controller.setStrength(strength) sets the engine's strength as close as possible to strength on a scale where 100 is the strength for giving a new player an instructive but winnable game, 1500 is the strength of the average experienced human player, and 2500 is the strength of a grandmaster. At the moment, for performance reasons, the engine only supports strengths up to 2000.

  • controller.setDepth(depth) is an alternative to controller.setStrength that sets the engine's search depth as close as possible to depth. At the moment, for performance reasons, the engine only supports depths up to 5 ply.

  • controller.setLine(position, taboo) sends a position and an array of preceding positions (which are taboo under the repetition rule) to the engine.

  • go() tells the engine to choose a move to play in the last sent position. The engine's reply will be sent to the moveHandler callback once the engine is done thinking.

  • stop(wantMove) tells the engine stop thinking early. If wantMove is true, the controller will still call moveHandler with the best move the engine found so far; otherwise that move will be discarded.

Under the hood, the Controller constructor creates associated web workers, and its other methods communicate with these web workers using BEI, a protocol described in the next section.

The Boost Engine Interface (BEI) Protocol

BEI is a text-based protocol for communication between a controller (a program that wants to incorporate a Boost-playing AI) and an engine (a Boost-playing AI). It is based on and simplified from the Arimaa Engine Interface (AEI), which in turn is based on the Universal Chess Interface (UCI).

BEI is built on asynchronous bidirectional communication of short strings called messages between the controller and engine. For example, a controller might send messages as lines of text to an engine's standard input and read messages from the engine's standard output, or the controller and engine might exchange BEI messages as string payloads in web worker messages. (@unlsoft/boost-engine uses the latter approach.)

Generally the engine should not process later messages until it has finished processing earlier ones. The two exceptions are pondering (speculatively searching ahead during an opponent's turn) and thinking (deciding what move to make during the engine's own turn), long-running operations that should effectively happen "in the background" and not prevent the engine from responding to other communication.

Controller-to-Engine Messages

The following messages may be sent by a controller to an engine:

  • bei [value] [value] … is sent to initiate communication and optionally to provide engine-specific startup arguments. (It must be possible to configure an engine to take no startup arguments so that the engine can be used with an implementation-agnostic controller, but an engine may still opt to take arguments in other settings.) The engine will reply with a protocol message, possibly some id messages, and then beiok. Apart from isready messages, the controller must not send any other communication until it has confirmed that it is using a compatible protocol version and has received a beiok.

  • isready is sent to ping the engine and ensure that the engine has finished processing any previous messages. The engine will reply with readyok once all previous messages have been handled.

  • newgame [identifier] is sent to start a new game with the given game identifier. The engine will reply with known if it can play that game, unknown if it cannot.

  • setoption [option] [value] is sent to set the named engine option to the given value. Currently there are only two supported option names:

    • strength should be followed by a rating on a scale where 100 is the strength for giving a new player an instructive but winnable game, 1500 is the strength of the average experienced human player, and 2500 is the strength of a grandmaster. If the engine strength is never set, the engine should default to its strongest setting; if an engine cannot play at the requested strength, it should set its strength as close as possible.

    • depth should be followed by a search depth and is an alternative way to configure the engine's strength. Again, if the engine's search depth is never set, the engine should default to its strongest setting; if an engine cannot search to the requested depth, it should set its search depth as close as possible.

  • setline [serialization] [tabooSerialization] [tabooSerialization] … is sent to tell the engine about a new board position, which is given by the first serialization, and all of the previous board positions that affect the repetition rule, which are give by the following serializations. The serialization format is the same format as used by position.serialization from @unlsoft/boost-game.

  • ponder [turns] is sent to tell the engine that it will be playing after the given number of turns and that it may ponder in the background. The newgame, setline, go, and stop messages all stop pondering.

  • go is sent to tell the engine that it should start thinking in the background about what move to play in the current position. When it is done thinking, the engine will respond with move message reporting the best moves that it was able to find (or one move, a pass, if no real moves are possible because the game has already been won or lost). The newgame, setline, ponder, go, and stop messages all stop any previous thinking and trigger a move message, even if the engine was not done considering the position.

  • stop is sent to tell the engine that it should stop any pondering or thinking. If the engine is thinking, this will prompt it to reply with a move message.

  • quit is sent to tell the engine that no further messages will be sent and that it may exit.

Engine-to-Controller Messages

The following messages may be sent by an engine to a controller:

  • protocol [version] is sent as the first message in response to a bei message to tell the controller what version of BEI the engine uses. The version described here is 2.0.0.

  • id [property] [value] is sent in response to a bei message to describe the engine to the controller. Each property should be sent at most once. Three property names are supported:

    • The value for the name property is the name of the engine.

    • The value for the author property is the name of the engine's author (or the names of the engine's authors if there are more than one).

    • The value for the version property is the version number of the engine.

  • beiok is sent to indicate the end of responses to a bei message.

  • readyok is sent to reply to an isready message.

  • known is sent to reply to a newgame message when the engine is able to load the specified game from its game library.

  • unknown is sent to reply to a newgame message when the engine is unable to load the specified game from its game library.

  • move [score] [move] [move] … is sent to report the best score and best moves the engine was able to find whenever the engine stops thinking. The first move is the one chosen by the engine to play. However, the engine should not assume that this move will actually be made; if it is, the engine will receive an appropriate setline message. If no moves are possible because the game has already been won or lost, the engine must send a single placeholder move: a pass.

  • log [text] is sent to report a log message from the engine.

The Networked Multiplayer API

Clients communicate with the multiplayer server over websockets by sending stringified JSON objects where the object's action field determines the type of message and then waiting for a matching response. For example, if a client sends a message of the form

{
  action: 'foo',
  
}

then the server will reply with a matching response of either the form

{
  action: 'foo',
  success: true,
  
}

or the form

{
  action: 'foo',
  success: false,
  
}

depending upon whether or not the requested action was successful.

Therefore, from a client's perspective, using the API is very similar to using a REST webservice where the action field is analogous to the resource part of a REST URL, and the success field is analogous to the HTTP status code. However, there are two important exceptions: (1) the server may also send messages to the client unprompted, and (2) the server may also send the messages as web push notifications if the client is not connected via a websocket.

To reduce friction (though at a slight reduction in security), any messages delivered as web push notifications also include a token field with a notification token that can be used to log in quickly without user interaction (see the description of claimToken messages below).

Conventions for Failure Responses

In most cases, if a request fails, the response will merely be

{
  action: ,
  success: false,
}

with no explanatory details in order to avoid revealing information that could be useful to someone attempting a cyberattack. The one exception is that a message of the form

{
  action: ,
  success: false,
  recourse: 'logIn',
}

is always sent if the client's session is no longer valid and the user must log in again.

Conventions for Success Responses

Most success responses have a format specific to the action the correspond to, but one recurring pattern across these responses is the set of fields used to describe an invite or game. Those fields are as follows:

  • code holds the invite or game code that uniquely identifies the invite or game. This code can be used to refer to the invite or game in subsequent communication, and the server furthermore guarantees that a game created by accepting an invite will be the same as the original invite code, so this code persists across acceptances.

  • timestamp holds the time the invite or game was created as a number of milliseconds since the ECMAScript epoch (January 1, 1970, UTC, the same as the UNIX epoch). A game created by invitation is considered to be created at the time the invitation was accepted, not the time it was extended.

  • gameIdentifier holds the game identifier describing the rules of the game (see the section "Command-Line Options Affecting Game Rules" from earlier in this document).

  • timeControls holds the game's time controls in seconds per side and is always a multiple of 60 so that the time can be accurately displayed as an integer number of minutes.

  • timeUsed holds a two-element list where the first entry is the number of seconds already used by the first player and likewise for the second entry and second player. These numbers may be slightly out of date when they reach the client due to network lag.

  • winnerOnTime holds the index of the player that won because their opponent's time ran out: 0 for the first player, 1 for the second player, or null if neither player has won for that reason. This field is necessary because players can continue playing even after someone's time has expired, and it would not be possible to determine who won if both players thereby both used up all of their time.

  • winnerByResignation holds the index of the player that won because their opponent resigned: 0 for the first player, 1 for the second player, or null if neither player has won for that reason.

  • dragons holds a list of strings, each being the name of a point where a dragon was initially placed in algebraic notation (see the unprettify* methods in the section on algebraic notation). It does not account for the subsequently played compensation move.

  • violet holds the name of the first player or undefined if they have not accepted their invitation yet.

  • cyan holds the name of the second player or undefined if they have not accepted their invitation yet.

  • color is the color that the client is playing in the game: the string 'violet' if they are the first player, the string 'cyan' if they are the second, or undefined if they are an observer.

  • record is a list of all moves played in algebraic notation (see the unprettify* methods in the section on algebraic notation). It does not include the compensation move; client may assume that the compensation move has been played once both the violet and cyan fields have been filled with player names.

Client-to-Server Messages and their Responses

The following messages may be sent by an client to a server:

  • { action: 'logInAsGuest' } is sent to log in as a guest, which will grant the client a newly created guest account and start a one-time session under that account. On success, the response will take the form { action: 'logInAsGuest', success: true, name: …, email: null, session: … } where the name field holds the guest account's username as a string and the session field holds the session key also as a string.

  • { action: 'register', name: …, email: …, password: … } is sent to register a new non-guest account using the name, email address, and password provided as strings and immediately log in and start a session under that account. The name must not contain spaces or at signs (@), and the email address must be valid. On success, the response will take the form { action: 'register', success: true, name: …, email: …, session: …, vapidPublicKey: … } where the name and email fields repeat the name and email address from the request (possibly normalized), the session field holds the session key as a string, and the vapidPublicKey field holds the server's VAPID public key as a string (which the interested client could use to subscribe to push notifications).

  • { action: 'logIn', identity: …, password: … } is sent to log in to an existing account using a name or email address (case insensitive) and a password. On success, the response will take the form { action: 'logIn', success: true, name: …, email: …, session: …, vapidPublicKey: … } where the name and email fields contain the name and email address from the account (possibly normalized), the session field holds the session key as a string, and the vapidPublicKey field holds the server's VAPID public key as a string (which the interested client could use to subscribe to push notifications).

  • { action: 'claimToken', session: …, token: … } is sent to log in without user interaction in response to a user tapping a notification. The session key is optional, but should be provided if the client has one to avoid an unnecessary logout/login cycle. The token must be a notification token provided in a recent web push notification. On success, the response will take the form { action: 'claimToken', success: true, name: …, email: …, session: …, } where the name and email fields contain the name and email address from the account logged into (possibly normalized), and the session field holds the session key as a string.

  • { action: 'logOut', session: … } is sent to log out of an account's active session by providing the session key. The response will always take the form { action: 'logOut', success: true }.

  • { action: 'changeEmail', session: …, email: …, password: … } is sent to change the email address associated with the logged-in account indicated by the given session key. The account's password is also required to confirm the change. On success, the response will take the form { action: 'changeEmail', success: true, name: …, email: null, session: … } where the name field holds the account's username, the email field repeats the new email address (possibly normalized), and the session field repeats the provided session key.

  • { action: 'changePassword', session: …, oldPassword: …, newPassword: … } is sent to change the password for the logged-in account indicated by the given session key. The account's old password is also required to confirm the change. On success, the response will take the form { action: 'changePassword', success: true }.

  • { action: 'requestPasswordResetEmail', identity: …, } is sent to request a password-reset email be sent for an account identified by the provided name or email address (case insensitive). On success, the response will take the form { action: 'requestPasswordResetEmail', success: true }.

  • { action: 'resetPassword', identity: …, passwordResetCode: …, password: … } is sent to reset an account's password using a code from a recent password-reset email. On success, the response will take the form { action: 'resetPassword', success: true }.

  • { action: 'subscribe', session: …, subscription: … } is sent to subscribe to push notifications from the server under the account indicated by the given session key. The subscription should be a valid JSON-encoded PushSubscription, which can be created with registration.pushManager.subscribe(…) or retrieved with await registration.pushManager.getSubscription() and then encoded with subscription.toJSON() (see https://developer.mozilla.org/en-US/docs/Web/API/Push_API for details). On success, the response will take the form { action: 'subscribe', success: true }.

  • { action: 'seek', session: …, fastestTimeControls: …, slowestTimeControls: …, colorSought: … } is sent to seek a new game through the server's autopairing system under the account indicated by the given session key. The fastest and slowest time controls should be given in seconds per side, must be positive, and must be multiples of 60 so that the time can be accurately displayed as an integer number of minutes. The color sought should be undefined if the player is willing to play either color, the string 'violet' if the player wants their opponent to move first, or the string 'cyan' if the player wants their opponent to move second. On success, the seek will be created and the response will take the form { action: 'seek', success: true }. If the seek is eventually matched, the server will at that time also send a seekReady message (see its description in the next section).

  • { action: 'unseek', session: … } is sent to cancel any created but still unresolved seek associated with the provided session. On success, the response will take the form { action: 'unseek', success: true }.

  • { action: 'invite', session: …, dragons: …, timeControls: …, colorSought: … } is sent to create an invite under the account indicated by the given session key. The initial dragon placements (before any compensation move) should be given as a list of strings, each being the name of an otherwise open point on the board in algebraic notation (see the prettify* methods in the section on algebraic notation), and the color sought should either be the string 'violet' if the inviting player wants their opponent to move first or the string 'cyan' if they want their opponent to move second. The time controls should be given in seconds per side and must be multiples of 60 so that the time can be accurately displayed as an integer number of minutes. On success, the response will take the form { action: 'invite', success: true, … } where the ellipses indicates fields describing the created invite (see the previous section).

  • { action: 'uninvite', session: …, code: … } is sent to cancel a still-open invite made by the account identified by the provided session and also having the given invite code. On success, the response will take the form { action: 'uninvite', success: true, code: … } where the code field repeats the given code.

  • { action: 'accept', session: …, code: … } is sent to accept an invite with the provided invite code using the account indicated by the given session key. On success, the response will take the form { action: 'accept', success: true, … } where the ellipses indicates fields describing the created game (see the previous section).

  • { action: 'observeLiveGames', session: …, } is sent to begin observing all games that are live (or were recently live) on the server, the session required only to verify that the request is from a logged-in account. (Note that the server tracks whether a connection is observing all live games separately from the individual games it is observing, so a connection observing all live games will see updates even about live games it has chosen to unobserve individually.) On success, the response will take the form { action: 'observeLiveGames', success: true, games: … } where the games field holds of list of game description object (see the previous section for a complete description of their fields) covering not only these games but also any invites or games belonging to the player. Whenever moves or resignations are made in observed games, the server will at that time also send move and resign messages (see their descriptions in the next section).

  • { action: 'unobserveLiveGames' } is sent to stop observing all live games. (Note that the server tracks whether a connection is observing all live games separately from the individual games it is observing, so a connection unobserving all live games will still see updates about live games it has chosen to observe individually.) On success, the response will take the form { action: 'unobserveLiveGames', success: true }.

  • { action: 'observeGame', session: …, code: … } is sent to observe a specific game identified by its code, the session required only to verify that the request is from a logged-in account. (Note that the server tracks whether a connection is observing all live games separately from the individual games it is observing, so a connection will still see updates a specifically observed live game even if it requests to unobserve all live games.) On success, the response will take the form { action: 'observeGame', success: true, … } where the ellipses indicates fields describing the game (see the previous section).

  • { action: 'unobserveGame', code: …, } is sent to stop observing a specific game. (Note that the server tracks whether a connection is observing all live games separately from the individual games it is observing, so a connection will still see updates about a game it has unobserved if it has also requested to observe all live games.) On success, the response will take the form { action: 'unobserveGame', success: true }.

  • { action: 'makeMove', session: …, code: …, move: … } is sent to make a move given in algebraic notation in the move field in a game identified by the code field as a player whose account is identified by the provided session key. On success, the response will take the form { action: 'makeMove', success: true, … } where the ellipses indicates fields describing the updated game (see the previous section).

  • { action: 'resign', session: …, code: … } is sent to resign a game identified by the code field as a player whose account is identified by the provided session key. On success, the response will take the form { action: 'resign', success: true, … } where the ellipses indicates fields describing the updated game (see the previous section).

Spontaneous Server-to-Client Messages

The following messages may also be sent unprompted by an server to a client:

  • { action: 'seekReady', success: true, … } is sent to indicate that the autopairing has found a match for the client's outstanding seek and has started the new game. The ellipses indicates fields describing the created game (see their explanation two sections prior).

  • { action: 'accept', success: true, … } is sent to indicate that an opponent has accepted one of the client's outstanding invites and has started the new game. The ellipses indicates fields describing the created game (see their explanation two sections prior).

  • { action: 'move, success: true, … } is sent to indicate that another player has made a move in a game the client is playing in or observing. The ellipses indicates fields describing the updated game (see their explanation two sections prior).

  • { action: 'resign', success: true, … } is sent to indicate that another player has resigned in a game the client is playing in or observing. The ellipses indicates fields describing the updated game (see their explanation two sections prior).