micropython-sunfish
February 19, 2025 ยท View on GitHub
MicroPython Sunfish Chess Engine
This is an unofficial MicroPython port of Sunfish Chess Engine. Some parameters (marked with QL) are tuned to make it fit constrained memory. It is tested with MicroPython V1.19.1 on ESP32.
- Ported by: Quan Lin
- License: GNU GPL v3 (the same as Sunfish)
Installation
The file may be installed on target hardware with mpremote:
$ mpremote mip install github:jacklinquan/micropython-sunfish
Gameplay
Running sunfish.py starts a terminal-based textual chess game. The human player is White,
and the machine is Black. White pieces are denoted by upper case letters, and Black by lower. Blank
squares are marked with a .character. The appearance of the board is as follows:
8 r n b q k b n r
7 p p p p . p p p
6 . . . . . . . .
5 . . . . p . . .
4 . . . . P . . .
3 . . . . . . . .
2 P P P P . P P P
1 R N B Q K B N R
a b c d e f g h
Moves are input and displayed in "algebraic" notation, for example b1c3 to move a white Knight.
Castling is done by moving the King, e.g. e1g1 to castle on the King side - the Rook moves
automatically. Pawn promotion - always to Queen - occurs automatically. The machine can do en passant
captures. I haven't had the opportunity to test a human en passant move but I would expect it to be
performed by moving the Pawn to its end position.
If an invalid move is entered, the engine prompts the user to try again.
Limitations
The machine does not tell you when it has put you in check. Further it seems unable to detect checkmate on either side. If you put it in checkmate you need to actually take its King for it to acknowledge the loss. If you are in checkmate you need to make some arbitrary move before it claims the win. If you are in check and make a move which fails to deal the situation, on its move it will declare it has won.
As is often the case for simple engines, its end-game performance is poor. In one game where I was reduced to just my King, it failed to spot an excruciatingly obvious mate-in-one while it set about promoting a Pawn to add a Queen to its two Rooks...
The engine does not allow you to play as Black (although with a suitable graphical front-end this can be remedied).
Using the API
This allows Sunfish to work with a graphical front-end such as miropython-touch. An example graphical game demo is here. It is intended that the API could be adapted to other chess engines to enable engines and front-ends to be ported.
The API comprises two generators, game and get_board and a function make_board. With suitable
front-end code the user can play as Black or White.
The game generator
This runs for the duration of a game. The end of a game is flagged by a StopIteration exception,
with the exception value indicating win, lose or (potentially) draw. (Sunfish cannot detect draws).
The following describes one iteration of the generator.
By default, with no passed argument, the generator starts with a standard chessboard with the human
playing White. Before starting the loop, the GUI calls next() to retrieve a representation of the
board state. The GUI passes this to the get_board generator to populate the visual representation
of the board. The player then enters a move. If an invalid move is entered, it is ignored: the
generator yields None. This prompts the GUI to ignore any bad move. When a valid move is entered,
the generator returns a representation of the new board state. The GUI passes that to the get_board
generator to refresh the board on screen.
After a brief delay for the player to evaluate the board, the GUI issues next() which prompts the
game generator to calculate its move. This next() call returns the new board state and the latest
move (in "a2a4" format). The GUI briefly highlights the squares of the move. The next pass refreshes
the screen as before.
Note that moves passed from the GUI to the generator must be lexically valid (no "a8a9"), but the engine checks for a valid chess move. Special moves such as castling are handled as described above in the text game.
The following is an example of the generator in use
async def play_game(self):
game_over = False
# Set up the board
game = sunfish.game(iblack) if self.invert else sf.game()
board, _ = next(game) # Start generator, get initial position
while not game_over:
try:
self.populate(board) # Fill the board grid with the current position using get_board
await asyncio.sleep(0)
board = None
while board is None: # Acquire valid move
await self.moved.wait() # Wait for player/GUI to update self.move
board = game.send(self.move) # Get position after move
self.populate(board) # Use the Position instance to redraw the board
await asyncio.sleep(1) # Ensure refresh, allow time to view.
board, mvblack = next(game) # Sunfish calculates its move. Yields the new board state and its move
self.flash(*rc(mvblack[:2]), WHITE) # Flash the squares to make the forthcoming move obvious
self.flash(*rc(mvblack[2:]), WHITE)
await asyncio.sleep_ms(700) # Let user see forthcoming move
except StopIteration as e:
game_over = True
print(f"Game over: you {'won' if (e ^ self.invert) else 'lost'}")
Note that generator iterations return a board instance. From the point of view of the GUI code
this is an opaque object whose only use is to be passed to the get_board generator. In general a
board is some object which contains information on the current board state.
Inverted play
It is possible to play as Black. The micropython-sunfish engine doesn't support this, but owing to the inherent symmetry of Chess the issue can be fixed in the GUI by doing the following:
- Treat lowercase pieces as White and uppercase as Black.
- Pass an initial board state to the GUI including White's initial move.
- Reverse the sense of Win and Lose results from
StopIteration.
The first item ensures that the board is rotated such thst the player's Black pieces are at the bottom of the board. The second allows the possibility of starting from arbitrary positions such as problem solving.
The way of passing the initial state is designed to be applicable to any engine. It is a bytes instance
of length 64 representing the board. Bytes are lowercase for pieces at the top of the board and uppercase
for those at the bottom. An example for a board where the machine plays White and has made the first move
follows:
iblack = b'rnbqkbnrpppp ppp p PPPPPPPPRNBQKBNR'
The get_board generator
This is instantiated with a board object, as returned by the game generator, and yields the
alphabetic character associated with each square in turn. Order is left to right, then top to bottom.
In the board illustrated above, initial successive calls would yield "rnbqkbnrpppp.ppp." The following is
a code fragment illustrating its use to populate a grid with the current board state.
# Return state of each square in turn.
def get(board, invert):
dic = {}
gen = sunfish.get_board(board) # Instantiate Sunfish generator
n = 0
while True:
r = next(gen) # Get chesspiece character
dic["text"] = lut[r.upper()] # Convert to chess glyph
dic["fgcolor"] = WHITE if (r.isupper() ^ invert) else RED
dic["bgcolor"] = BLACK if ((n ^ (n >> 3)) & 1) else PALE_GREY
yield dic
n += 1
Implementing the API
This describes the process of adding the API to an existing chess engine, enabling engines to readily be changed in a graphical game. The following must be implemented.
The game generator
The generator function takes a single optional argument, a 64 element bytes object comprising the initial
board state. Reduced to its essentials it has the following structure:
def game(iboard=None):
# If an initial board is passed, convert it to the engine's native format and initialise the engine
# engine.start(make_board(iboard))
engine_move = None # Machine's last move in "a2a4" format
while True:
if engine.player_has_lost():
return False # StopIteration: player lost
elif engine.drawn_game():
return None
# Yield current board state and last black move (if any)
move = yield current_board_state, engine_move # Await a move in "a2a4" format
# The GUI guarantees these are lexically valid. Engine checks for chess validity.
while not legal_move(move):
move = yield # A None reponse prompts user to try again
engine.user_move(move) # Pass vaild "a2a4" user move to engine
if engine.player_has_won():
return True # Player won
# Fire up the engine to look for a move.
move = engine.calculate_move()
if engine.player_has_lost():
return False # Player lost
elif engine.drawn_game():
return None
# The black player moves from a rotated position, so we have to
# 'back rotate' the move before printing it.
engine_move = convert_engine_move_format_to_text() # "a2a4" format
The engine may optionally add a fifth character to moves it returns, with "+" denoting chack and "#" denoting checkmate. Sunfish does not do this.
The get_board generator
This converts the engine's internal representation of a board into alphanumeric format, with pieces at the top of the board being lowercase and those at the bottom being uppercase. Empty squares should yield "". The generator should yield exactly 64 characters in turn, sarting at the top left of the board, progressing left to right then by row downwards.
The make_board function
This takes a 64 element bytes object and returns the engine's internal representation of a board.
The format of the bytes object is described above in "inverted play".