This is an ongoing project featuring:
- BoardLang, a library-level DSL for easily creating combinatorial and turn-based board games in Scala.
- A collection of simple games implemented using BoardLang. (WIP)
- Minimax and RL based solvers to play (2), implemented in a game-independent manner. (COMING SOON)
- A web interface for playing (2) against (3) or other human players. (WIP)
Featured games (this list will continually expand):
This is a continuation from a long lineage of similar projects (1.0, 2.0, 3.0).
Boards is composed of 6 subprojects, which you will find in the top-level directories of the same names. More information for each subproject can be found in the respective README.
- dsl contains the implementation of the BoardLang DSL.
- games contains the rule specifications for the games themselves, written using
BoardLang. - bots contains general and game-specific strategies for playing these games.
- server code which is compiled to JVM bytecode for use on a web server. Handles user requests, authentication and persistence.
- client code which is transpiled to JS and served to the user's web browser. Handles rendering and user input.
- common code which is shared by both the server and client, and compiled into both projects. Primarily contains data formats for communication therebetween.
Boards is written entirely in Scala, and in order to work on this project you will need the following:
- An up-to-date JDK for development in a JVM-based language.
- Scala 3.5.2 itself.
- sbt, the pre-eminent build tool for Scala projects.
- Git for version control.
To download the project into a subdirectory named Boards, run:
git clone https://github.com/SgtSwagrid/Boards.git
To run a local development server, navigate to the Boards directory and run:
sbt "project server" "run"
You should then be able to access the website at localhost:9000 in your browser.
For development purposes, it is recommended that you use IntelliJ IDEA with the Scala plugin. IntelliJ configuration files are deliberately included in the project to offer a uniform developer experience with consistent formatting rules, code highlighting and build configurations. If you are using IntelliJ, the Boards Development Server run option is equivalent to the command shown above.
In any case, the project is configured to automatically detect code changes while the server is running, so that changes are reflected immediately. Note however that this unfortunately isn't foolproof and if something isn't working, a full server restart is the safest option.
A key commponent of Boards is BoardLang, an embedded domain-specific language (eDSL) for creating turn-based board games in Scala.
- You will find the implementation of BoardLang in
dsl/src/main/scala/boards. - You will find examples of games created using BoardLang in
games/src/main/scala/boards.
BoardLang uses a functional style and all objects are immutable. Fundamentally, a game is built by defining some number of PieceTypess and composing Rules to precisely specify what the player can and can't do with these pieces. Each Action the player takes causes a transition to a new GameState in accordance with the current Rule.
Game: A precise specification of the rules for a game (e.g. Chess, Connect Four, etc).
For the state of the game:
InstantaneousState: The current "physical" state of the game. Contains theBoardand currentPieceSet, and tracks theactivePlayerId.GameState: The total state of the game. Contains the entire game history, including all pastInstantaneousStates and userActions.Rule: A mechanism for, given some currentGameState, enumerating possible playerActions and corresponding successorGameStates, as well as performingActionlegality testing.
For the game board and pieces:
Board: The topology of the game, describing which positions are in bounds and the connectivity therebetween.Piece: A specific piece that is on the board at a specific time in a specific place.PieceType: A property held by aPieceused to distinguish different kinds of pieces (e.g. rook, knight, etc).PieceSet: A set ofPieces, used in particular by anInstantaneousState, with specialised functionality for filtering,Actionquerying and modification.
Mathematical types:
VecI: An integer vector representing a position on theBoard.RegionI: A set of vectors describing a region in space.Ray: A specific kind ofRegionformed by making up to some number of steps in some direction(s).
There are two important things to note about Rules in BoardLang:
-
A
Ruleis not Markovian in the currentInstantaneousState, which is to say that the state transitions can depend arbitarily on the full state history inGameState. To see why, consider chess: en passant is legal only on the turn directly following the initial double pawn move. The account for this, theRulemust be able to see when and how the pawn being captured arrived in its current position. -
The
Ruleitself is a property of theGameState, not of the entireGame. In particular, this means that theRuleis dynamic rather than static, meaning it can (and typically does) change over time. TheGamespecifies the initialRule, and thereafter each successorGameStateis infused with a newRuleupon creation. When aRulegenerates successorGameStates, it is also responsible for determining whichRuleshould apply thereafter from that state. The reason is that this makes it much easier to reason about sequences ofActions, and implicitly provides support for two kinds of situation which arise very frequently: turn phases and game phases.
In many games, the turn is divided up into multiple phases. For instance, maybe you first have to roll, then trade, and finally build. To implement this behaviour, one can simply create a separate Rule for each phase, and sequence them together, whereby each phase knows that when it is done, it should replace the Rule with the one corresponding to the next phase.
For an example, consider chess again: after a pawn moves to the final rank, as a separate action it must then promote. With a static Rule, the state would need some kind of a global flag indicating the need for promotion, to override the default behaviour on the next action. This moves the promotion logic outside of the pawn PieceType where it belongs. Instead, with a dynamic Rule, the pawn can simply infuse the successor GameState with a special, one-time promotion rule.
In some games, there are even multiple global game phases. For instance, it is common to have a separate setup phase, which still requires input from the players, but with completely different rules than the main phase (example: Catan). Again, with a dynamic Rule, this is easy to achieve without any global flags by creating a separate Rule for each phase and sequencing them in the same way as before.
Any Rule is actually a tree of Rules, of the following basic kinds:
Cause: The leaves of theRuletree. AGeneratorsimply enumerates legalActions. For example, a king in chess might provide aGeneratorwhich produces oneDraginput for each octagonal direction.Effect: A passive effect which is only indirectly caused by theActionof thePlayer. For instance, when we castle in chess, aGeneratorallows the king to move, but then a susequentEffectensures that the rook moves too as a result.Combinator: A composition of multiple simplerRules, for reasoning aboutActionsequencing. For example, in chess, we need to take the union ofRules from individualPieces to indicate that thePlayercan choose whichPieceto move, then sequence this withEffect.endTurn, then repeat indefinitely.Combinators deliberately hide the dynamic nature of the currentRule, and automatically decide whichRuleshould apply next after eachAction.
To use the BoardLang DSL, the following import is always required:
import boards.imports.games.{*, given}Furthermore, any game must inherit from the base class Game:
class Chess extends GameWe can't have chess without a chessboard!
// A chessboard is an 8x8 grid.
override val Board = Kernel.box(8, 8)
// The following is only required for aesthetic reasons:
.withLabels(Pattern.Checkered(Colour.Chess.Dark, Colour.Chess.Light))We may want to define some types of pieces, otherwise there won't be much to do in our game:
object Pawn extends TexturedPiece(Texture.WhitePawn, Texture.BlackPawn)
object King extends TexturedPiece(Texture.WhiteKing, Texture.BlackKing)
...Often, the first thing that should happen is some setup. For example, if we're making chess, we might want to start with this Rule to insert a row of white pawns in the second rank from the bottom:
val setup = Effect.insert(/* The owner of the pieces. */ PlayerId(0))(Pawn -> Board.row(1))After setup, we probably want some kind of main game loop:
val loop = Rule.alternatingTurns:
???This particular Rule will repeat the body indefinitely, ending the turn after each iteration. For most turn-based games, this is how it works; the Players always play in the same clockwise or counterclockwise order.
Inside the main loop, we allow the Player to move one of their pieces:
pieces.ofActivePlayer.actions
pieces.ofActivePlayer is a PieceSet containing only those pieces belonging to the player whose turn it currently is. .actions produces a Rule which allows the Player to move one of the pieces in this PieceSet.
This won't do anything yet, because none of the pieces know which kinds of movement they can do. This can be fixed by having each moveable PieceType implement the actions method:
def actions(pawn: Pawn): Rule =
pawn.move(if pawn.owner == 0 then Dir.Up else Dir.Down)The abstract class Game only requires us to implement a single member, rule. This defines the initial Rule for our Game. In other words, from the start of the game, what happens and what are the players allowed to do?
def rule: Rule = setup |> loopIn the above, we start by setting up the game board, then we enter the main game loop.
Now the Players can move pieces around. But the Game will never end as Rule.alternatingTurns goes forever and we have no termination condition.
In chess, the game ends when the current Player has no legal moves. For this, we can use the ?: operator (read: orElse) which specifies some alternative behaviour to use precisely when there are no legal Actions in the main path:
pieces.ofActivePlayer.actions ?: Effect.endGame(Draw)Of course, chess shouldn't always end in a draw. To determine a winner, we also need check detection:
def inCheck(using GameState) = pieces.ofInactivePlayers.canCaptureType(King)From here, it is also very easy to forbid the Player from taking any action that would result in them being in check. Instead of:
pieces.ofActivePlayer.actionswe use this:
pieces.ofActivePlayer.actions.require(!inCheck)Now we can replace the termination Effect with:
Effect.endGame(if inCheck then Winner(state.nextPlayer) else Draw)In other words, if there are no possible Actions, either we are in checkmate (lose) or stalemate (draw).
BoardLang provides a number of important operators for combining and modifying Rules. The most important ones are:
|: An infix union operator for specifying that thePlayermay choose which of twoRules to follow. Also available in function notation asRule.unionfor use with any number of alternativeRules.|>: An infix sequence operator for specifying that thePlayermust execute bothRules in the given order. Also available in function notation asRule.sequencefor user with any number of chainedRules._.optional: For specifying that thePlayercan decide whether or not to execute thisRule._.repeatXXX: There are various methods of this kind for performing aRulemultiple times. SeeRule.scalafor all variants.