diff --git a/tools/run-mypy b/tools/run-mypy
index 81b781c0..6fbab1da 100755
--- a/tools/run-mypy
+++ b/tools/run-mypy
@@ -58,6 +58,8 @@ force_include = [
     "zulip_bots/zulip_bots/bots/define/test_define.py",
     "zulip_bots/zulip_bots/bots/encrypt/encrypt.py",
     "zulip_bots/zulip_bots/bots/encrypt/test_encrypt.py",
+    "zulip_bots/zulip_bots/bots/chess/chess.py",
+    "zulip_bots/zulip_bots/bots/chess/test_chess.py",
 ]
 
 parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.")
diff --git a/zulip_bots/setup.py b/zulip_bots/setup.py
index 3026fbc6..f372aa80 100755
--- a/zulip_bots/setup.py
+++ b/zulip_bots/setup.py
@@ -52,7 +52,8 @@ setuptools_info = dict(
         'html2text',  # for bots/define
         'BeautifulSoup4',  # for bots/googlesearch
         'lxml',  # for bots/googlesearch
-        'requests'  # for bots/link_shortener
+        'requests',  # for bots/link_shortener
+        'python-chess[engine,gaviota]'  # for bots/chess
     ],
 )
 
diff --git a/zulip_bots/zulip_bots/bots/chess/__init__.py b/zulip_bots/zulip_bots/bots/chess/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/zulip_bots/zulip_bots/bots/chess/chess.conf b/zulip_bots/zulip_bots/bots/chess/chess.conf
new file mode 100644
index 00000000..156bf079
--- /dev/null
+++ b/zulip_bots/zulip_bots/bots/chess/chess.conf
@@ -0,0 +1,2 @@
+[chess]
+stockfish_location = <the location of Stockfish on this machine>
diff --git a/zulip_bots/zulip_bots/bots/chess/chess.py b/zulip_bots/zulip_bots/bots/chess/chess.py
new file mode 100644
index 00000000..2cd4ffa5
--- /dev/null
+++ b/zulip_bots/zulip_bots/bots/chess/chess.py
@@ -0,0 +1,746 @@
+import chess
+import chess.uci
+import re
+import copy
+from typing import Any, Optional
+from zulip_bots.lib import ExternalBotHandler
+
+START_REGEX = re.compile('start with other user$')
+START_COMPUTER_REGEX = re.compile(
+    'start as (?P<user_color>white|black) with computer'
+)
+MOVE_REGEX = re.compile('do (?P<move_san>.+)$')
+RESIGN_REGEX = re.compile('resign$')
+
+class ChessHandler(object):
+    def usage(self) -> str:
+        return (
+            'Chess Bot is a bot that allows you to play chess against either '
+            'another user or the computer. Use `start with other user` or '
+            '`start as <color> with computer` to start a game.\n\n'
+            'In order to play against a computer, `chess.conf` must be set '
+            'with the key `stockfish_location` set to the location of the '
+            'Stockfish program on this computer.'
+        )
+
+    def initialize(self, bot_handler: ExternalBotHandler) -> None:
+        self.config_info = bot_handler.get_config_info('chess')
+
+        try:
+            self.engine = chess.uci.popen_engine(
+                self.config_info['stockfish_location']
+            )
+            self.engine.uci()
+        except FileNotFoundError:
+            # It is helpful to allow for fake Stockfish locations if the bot
+            # runner is testing or knows they won't be using an engine.
+            print('That Stockfish doesn\'t exist. Continuing.')
+
+    def handle_message(
+        self,
+        message: dict,
+        bot_handler: ExternalBotHandler
+    ) -> None:
+        content = message['content']
+
+        if content == '':
+            bot_handler.send_reply(message, self.usage())
+            return
+
+        start_regex_match = START_REGEX.match(content)
+        start_computer_regex_match = START_COMPUTER_REGEX.match(content)
+        move_regex_match = MOVE_REGEX.match(content)
+        resign_regex_match = RESIGN_REGEX.match(content)
+
+        is_with_computer = False
+        last_fen = chess.Board().fen()
+
+        if bot_handler.storage.contains('is_with_computer'):
+            is_with_computer = (
+                # `bot_handler`'s `storage` only accepts `str` values.
+                bot_handler.storage.get('is_with_computer') == str(True)
+            )
+
+        if bot_handler.storage.contains('last_fen'):
+            last_fen = bot_handler.storage.get('last_fen')
+
+        if start_regex_match:
+            self.start(message, bot_handler)
+        elif start_computer_regex_match:
+            self.start_computer(
+                message,
+                bot_handler,
+                start_computer_regex_match.group('user_color') == 'white'
+            )
+        elif move_regex_match:
+            if is_with_computer:
+                self.move_computer(
+                    message,
+                    bot_handler,
+                    last_fen,
+                    move_regex_match.group('move_san')
+                )
+            else:
+                self.move(
+                    message,
+                    bot_handler,
+                    last_fen,
+                    move_regex_match.group('move_san')
+                )
+        elif resign_regex_match:
+            self.resign(
+                message,
+                bot_handler,
+                last_fen
+            )
+
+    def start(self, message: dict, bot_handler: ExternalBotHandler) -> None:
+        """Starts a game with another user, with the current user as white.
+        Replies to the bot handler.
+
+        Parameters:
+             - message: The Zulip Bots message object.
+             - bot_handler: The Zulip Bots bot handler object.
+        """
+        new_board = chess.Board()
+        bot_handler.send_reply(
+            message,
+            make_start_reponse(new_board)
+        )
+
+        # `bot_handler`'s `storage` only accepts `str` values.
+        bot_handler.storage.put('is_with_computer', str(False))
+
+        bot_handler.storage.put('last_fen', new_board.fen())
+
+    def start_computer(
+        self,
+        message: dict,
+        bot_handler: ExternalBotHandler,
+        is_white_user: bool
+    ) -> None:
+        """Starts a game with the computer. Replies to the bot handler.
+
+        Parameters:
+             - message: The Zulip Bots message object.
+             - bot_handler: The Zulip Bots bot handler object.
+             - is_white_user: Whether or not the player wants to be
+                                     white. If false, the user is black. If the
+                                     user is white, they will get to make the
+                                     first move; if they are black the computer
+                                     will make the first move.
+        """
+        new_board = chess.Board()
+
+        if is_white_user:
+            bot_handler.send_reply(
+                message,
+                make_start_computer_reponse(new_board)
+            )
+
+            # `bot_handler`'s `storage` only accepts `str` values.
+            bot_handler.storage.put('is_with_computer', str(True))
+
+            bot_handler.storage.put('last_fen', new_board.fen())
+        else:
+            self.move_computer_first(
+                message,
+                bot_handler,
+                new_board.fen(),
+            )
+
+    def validate_board(
+        self,
+        message: dict,
+        bot_handler: ExternalBotHandler,
+        fen: str
+    ) -> Optional[chess.Board]:
+        """Validates a board based on its FEN string. Replies to the bot
+        handler if there is an error with the board.
+
+        Parameters:
+             - message: The Zulip Bots message object.
+             - bot_handler: The Zulip Bots bot handler object.
+             - fen: The FEN string of the board.
+
+        Returns: `False` if the board didn't pass, or the board object itself
+                 if it did.
+        """
+        try:
+            last_board = chess.Board(fen)
+        except ValueError:
+            bot_handler.send_reply(
+                message,
+                make_copied_wrong_response()
+            )
+            return False
+
+        return last_board
+
+    def validate_move(
+        self,
+        message: dict,
+        bot_handler: ExternalBotHandler,
+        last_board: chess.Board,
+        move_san: str,
+        is_computer: object
+    ) -> Optional[chess.Move]:
+        """Validates a move based on its SAN string and the current board.
+        Replies to the bot handler if there is an error with the move.
+
+        Parameters:
+             - message: The Zulip Bots message object.
+             - bot_handler: The Zulip Bots bot handler object.
+             - last_board: The board object before the move.
+             - move_san: The SAN of the move.
+             - is_computer: Whether or not the user is playing against a
+                            computer (used in the response if the move is not
+                            legal).
+
+        Returns: `False` if the move didn't pass, or the move object itself if
+                 it did.
+        """
+        try:
+            move = last_board.parse_san(move_san)
+        except ValueError:
+            bot_handler.send_reply(
+                message,
+                make_not_legal_response(
+                    last_board,
+                    move_san
+                )
+            )
+            return None
+
+        if move not in last_board.legal_moves:
+            bot_handler.send_reply(
+                message,
+                make_not_legal_response(last_board, move_san)
+            )
+            return None
+
+        return move
+
+    def check_game_over(
+        self,
+        message: dict,
+        bot_handler: ExternalBotHandler,
+        new_board: chess.Board
+    ) -> bool:
+        """Checks if a game is over due to
+         - checkmate,
+         - stalemate,
+         - insufficient material,
+         - 50 moves without a capture or pawn move, or
+         - 3-fold repetition.
+        Replies to the bot handler if it is game over.
+
+        Parameters:
+             - message: The Zulip Bots message object.
+             - bot_handler: The Zulip Bots bot handler object.
+             - new_board: The board object.
+
+        Returns: True if it is game over, false if it's not.
+        """
+        # This assumes that the players will claim a draw after 3-fold
+        # repetition or 50 moves go by without a capture or pawn move.
+        # According to the official rules, the game is only guaranteed to
+        # be over if it's  *5*-fold or *75* moves, but if either player
+        # wants the game to be a draw, after 3 or 75 it a draw. For now,
+        # just assume that the players would want the draw.
+        if new_board.is_game_over(True):
+            game_over_output = ''
+
+            if new_board.is_checkmate():
+                game_over_output = make_loss_response(
+                    new_board,
+                    'was checkmated'
+                )
+            elif new_board.is_stalemate():
+                game_over_output = make_draw_response('stalemate')
+            elif new_board.is_insufficient_material():
+                game_over_output = make_draw_response(
+                    'insufficient material'
+                )
+            elif new_board.can_claim_fifty_moves():
+                game_over_output = make_draw_response(
+                    '50 moves without a capture or pawn move'
+                )
+            elif new_board.can_claim_threefold_repetition():
+                game_over_output = make_draw_response('3-fold repetition')
+
+            bot_handler.send_reply(
+                message,
+                game_over_output
+            )
+
+            return True
+
+        return False
+
+    def move(
+        self,
+        message: dict,
+        bot_handler: ExternalBotHandler,
+        last_fen: str,
+        move_san: str
+    ) -> None:
+        """Makes a move for a user in a game with another user. Replies to
+        the bot handler.
+
+        Parameters:
+             - message: The Zulip Bots message object.
+             - bot_handler: The Zulip Bots bot handler object.
+             - last_fen: The FEN string of the board before the move.
+             - move_san: The SAN of the move to make.
+        """
+        last_board = self.validate_board(message, bot_handler, last_fen)
+
+        if not last_board:
+            return
+
+        move = self.validate_move(
+            message,
+            bot_handler,
+            last_board,
+            move_san,
+            False
+        )
+
+        if not move:
+            return
+
+        new_board = copy.copy(last_board)
+        new_board.push(move)
+
+        if self.check_game_over(message, bot_handler, new_board):
+            return
+
+        bot_handler.send_reply(
+            message,
+            make_move_reponse(last_board, new_board, move)
+        )
+
+        bot_handler.storage.put('last_fen', new_board.fen())
+
+    def move_computer(
+        self,
+        message: dict,
+        bot_handler: ExternalBotHandler,
+        last_fen: str,
+        move_san: str
+    ) -> None:
+        """Preforms a move for a user in a game with the computer and then
+        makes the computer's move. Replies to the bot handler. Unlike `move`,
+        replies only once to the bot handler every two moves (only after the
+        computer moves) instead of after every move. Doesn't require a call in
+        order to make the computer move. To make the computer move without the
+        user going first, use `move_computer_first`.
+
+        Parameters:
+             - message: The Zulip Bots message object.
+             - bot_handler: The Zulip Bots bot handler object.
+             - last_fen: The FEN string of the board before the user's move.
+             - move_san: The SAN of the user's move to make.
+        """
+        last_board = self.validate_board(message, bot_handler, last_fen)
+
+        if not last_board:
+            return
+
+        move = self.validate_move(
+            message,
+            bot_handler,
+            last_board,
+            move_san,
+            True
+        )
+
+        if not move:
+            return
+
+        new_board = copy.copy(last_board)
+        new_board.push(move)
+
+        if self.check_game_over(message, bot_handler, new_board):
+            return
+
+        computer_move = calculate_computer_move(
+            new_board,
+            self.engine
+        )
+
+        new_board_after_computer_move = copy.copy(new_board)
+        new_board_after_computer_move.push(computer_move)
+
+        if self.check_game_over(
+            message,
+            bot_handler,
+            new_board_after_computer_move
+        ):
+            return
+
+        bot_handler.send_reply(
+            message,
+            make_move_reponse(
+                new_board,
+                new_board_after_computer_move,
+                computer_move
+            )
+        )
+
+        bot_handler.storage.put(
+            'last_fen',
+            new_board_after_computer_move.fen()
+        )
+
+    def move_computer_first(
+        self,
+        message: dict,
+        bot_handler: ExternalBotHandler,
+        last_fen: str
+    ) -> None:
+        """Preforms a move for the computer without having the user go first in
+        a game with the computer. Replies to the bot handler. Like
+        `move_computer`, but doesn't have the user move first. This is usually
+        only useful at the beginning of a game.
+
+        Parameters:
+             - message: The Zulip Bots message object.
+             - bot_handler: The Zulip Bots bot handler object.
+             - last_fen: The FEN string of the board before the computer's
+                         move.
+        """
+        last_board = self.validate_board(message, bot_handler, last_fen)
+
+        computer_move = calculate_computer_move(
+            last_board,
+            self.engine
+        )
+
+        new_board_after_computer_move = copy.copy(last_board)
+        new_board_after_computer_move.push(computer_move)
+
+        if self.check_game_over(
+            message,
+            bot_handler,
+            new_board_after_computer_move
+        ):
+            return
+
+        bot_handler.send_reply(
+            message,
+            make_move_reponse(
+                last_board,
+                new_board_after_computer_move,
+                computer_move
+            )
+        )
+
+        bot_handler.storage.put(
+            'last_fen',
+            new_board_after_computer_move.fen()
+        )
+
+        # `bot_handler`'s `storage` only accepts `str` values.
+        bot_handler.storage.put('is_with_computer', str(True))
+
+    def resign(
+        self,
+        message: dict,
+        bot_handler: ExternalBotHandler,
+        last_fen: str
+    ) -> None:
+        """Resigns the game for the current player.
+
+        Parameters:
+             - message: The Zulip Bots message object.
+             - bot_handler: The Zulip Bots bot handler object.
+             - last_fen: The FEN string of the board.
+        """
+        last_board = self.validate_board(message, bot_handler, last_fen)
+
+        if not last_board:
+            return
+
+        bot_handler.send_reply(
+            message,
+            make_loss_response(last_board, 'resigned')
+        )
+
+handler_class = ChessHandler
+
+def calculate_computer_move(board: chess.Board, engine: Any) -> chess.Move:
+    """Calculates the computer's move.
+
+    Parameters:
+         - board: The board object before the move.
+         - engine: The UCI engine object.
+
+    Returns: The computer's move object.
+    """
+    engine.position(board)
+    best_move_and_ponder_move = engine.go(movetime=(3000))
+    return best_move_and_ponder_move[0]
+
+def make_draw_response(reason: str) -> str:
+    """Makes a response string for a draw.
+
+    Parameters:
+         - reason: The reason for the draw, in the form of a noun, e.g.,
+                   'stalemate' or 'insufficient material'.
+
+    Returns: The draw response string.
+    """
+    return 'It\'s a draw because of {}!'.format(reason)
+
+def make_loss_response(board: chess.Board, reason: str) -> str:
+    """Makes a response string for a loss (or win).
+
+    Parameters:
+         - board: The board object at the end of the game.
+         - reason: The reason for the loss, in the form of a predicate, e.g.,
+                   'was checkmated'.
+
+    Returns: The loss response string.
+    """
+    return (
+        '*{}* {}. **{}** wins!\n\n'
+        '{}'
+    ).format(
+        'White' if board.turn else 'Black',
+        reason,
+        'Black' if board.turn else 'White',
+        make_str(board, board.turn)
+    )
+
+def make_not_legal_response(board: chess.Board, move_san: str) -> str:
+    """Makes a response string for a not-legal move.
+
+    Parameters:
+         - board: The board object before the move.
+         - move_san: The SAN of the not-legal move.
+
+    Returns: The not-legal-move response string.
+    """
+    return (
+        'Sorry, the move *{}* isn\'t legal.\n\n'
+        '{}'
+        '\n\n\n'
+        '{}'
+    ).format(
+        move_san,
+        make_str(board, board.turn),
+        make_footer()
+    )
+
+def make_copied_wrong_response() -> str:
+    """Makes a response string for a FEN string that was copied wrong.
+
+    Returns: The copied-wrong response string.
+    """
+    return (
+        'Sorry, it seems like you copied down the response wrong.\n\n'
+        'Please try to copy the response again from the last message!'
+    )
+
+def make_start_reponse(board: chess.Board) -> str:
+    """Makes a response string for the first response of a game with another
+    user.
+
+    Parameters:
+         - board: The board object to start the game with (which most-likely
+                  should be general opening chess position).
+
+    Returns: The starting response string.
+    """
+    return (
+        'New game! The board looks like this:\n\n'
+        '{}'
+        '\n\n\n'
+        'Now it\'s **{}**\'s turn.'
+        '\n\n\n'
+        '{}'
+    ).format(
+        make_str(board, True),
+        'white' if board.turn else 'black',
+        make_footer()
+    )
+
+def make_start_computer_reponse(board: chess.Board) -> str:
+    """Makes a response string for the first response of a game with a
+    computer, when the user is playing as white. If the user is playing as
+    black, use `ChessHandler.move_computer_first`.
+
+    Parameters:
+         - board: The board object to start the game with (which most-likely
+                  should be general opening chess position).
+
+    Returns: The starting response string.
+    """
+    return (
+        'New game with computer! The board looks like this:\n\n'
+        '{}'
+        '\n\n\n'
+        'Now it\'s **{}**\'s turn.'
+        '\n\n\n'
+        '{}'
+    ).format(
+        make_str(board, True),
+        'white' if board.turn else 'black',
+        make_footer()
+    )
+
+def make_move_reponse(
+    last_board: chess.Board,
+    new_board: chess.Board,
+    move: chess.Move
+) -> str:
+    """Makes a response string for after a move is made.
+
+    Parameters:
+         - last_board: The board object before the move.
+         - new_board: The board object after the move.
+         - move: The move object.
+
+    Returns: The move response string.
+    """
+    return (
+        'The board was like this:\n\n'
+        '{}'
+        '\n\n\n'
+        'Then *{}* moved *{}*:\n\n'
+        '{}'
+        '\n\n\n'
+        'Now it\'s **{}**\'s turn.'
+        '\n\n\n'
+        '{}'
+    ).format(
+        make_str(last_board, new_board.turn),
+        'white' if last_board.turn else 'black',
+        last_board.san(move),
+        make_str(new_board, new_board.turn),
+        'white' if new_board.turn else 'black',
+        make_footer()
+    )
+
+def make_footer() -> str:
+    """Makes a footer to be appended to the bottom of other, actionable
+    responses.
+    """
+    return (
+        'To make your next move, respond to Chess Bot with\n\n'
+        '```do <your move>```\n\n'
+        '*Remember to @-mention Chess Bot at the beginning of your '
+        'response.*'
+    )
+
+def make_str(board: chess.Board, is_white_on_bottom: bool) -> str:
+    """Converts a board object into a string to be used in Markdown. Backticks
+    are added around the string to preserve formatting.
+
+    Parameters:
+         - board: The board object.
+         - is_white_on_bottom: Whether or not white should be on the bottom
+                               side in the string. If false, black will be on
+                               the bottom.
+
+    Returns: The string made from the board.
+    """
+    default_str = board.__str__()
+
+    replaced_str = replace_with_unicode(default_str)
+    replaced_and_guided_str = guide_with_numbers(replaced_str)
+    properly_flipped_str = (
+        replaced_and_guided_str if is_white_on_bottom
+        else replaced_and_guided_str[::-1]
+    )
+    trimmed_str = trim_whitespace_before_newline(properly_flipped_str)
+    monospaced_str = '```\n{}\n```'.format(trimmed_str)
+
+    return monospaced_str
+
+def guide_with_numbers(board_str: str) -> str:
+    """Adds numbers and letters on the side of a string without them made out
+    of a board.
+
+    Parameters:
+         - board_str: The string from the board object.
+
+    Returns: The string with the numbers and letters.
+    """
+    # Spaces and newlines would mess up the loop because they add extra indexes
+    # between pieces. Newlines are added later by the loop and spaces are added
+    # back in at the end.
+    board_without_whitespace_str = board_str.replace(' ', '').replace('\n', '')
+
+    # The first number, 8, needs to be added first because it comes before a
+    # newline. From then on, numbers are inserted at newlines.
+    row_list = list('8' + board_without_whitespace_str)
+
+    for i, char in enumerate(row_list):
+        # `(i + 1) % 10 == 0` if it is the end of a row, i.e., the 10th column
+        # since lists are 0-indexed.
+        if (i + 1) % 10 == 0:
+            # Since `i + 1` is always a multiple of 10 (because index 0, 10,
+            # 20, etc. is the other row letter and 1-8, 11-18, 21-28, etc. are
+            # the squares), `(i + 1) // 10` is the inverted row number (1 when
+            # it should be 8, 2 when it should be 7, etc.), so therefore
+            # `9 - (i + 1) // 10` is the actual row number.
+            row_num = 9 - (i + 1) // 10
+
+            # The 3 separate components are split into only 2 elements so that
+            # the newline isn't counted by the loop. If they were split into 3,
+            # or combined into just 1 string, the counter would become off
+            # because it would be counting what is really 2 rows as 3 or 1.
+            row_list[i:i] = [str(row_num) + '\n', str(row_num - 1)]
+
+    # 1 is appended to the end because it isn't created in the loop, and lines
+    # that begin with spaces have their spaces removed for aesthetics.
+    row_str = (' '.join(row_list) + ' 1').replace('\n ', '\n')
+
+    # a, b, c, d, e, f, g, and h are easy to add in.
+    row_and_col_str = (
+        '  a b c d e f g h  \n' + row_str + '\n  a b c d e f g h  '
+    )
+
+    return row_and_col_str
+
+def replace_with_unicode(board_str: str) -> str:
+    """Replaces the default characters in a board object's string output with
+    Unicode chess characters, e.g., '♖' instead of 'R.'
+
+    Parameters:
+         - board_str: The string from the board object.
+
+    Returns: The string with the replaced characters.
+    """
+    replaced_str = board_str
+
+    replaced_str = replaced_str.replace('P', '♙')
+    replaced_str = replaced_str.replace('N', '♘')
+    replaced_str = replaced_str.replace('B', '♗')
+    replaced_str = replaced_str.replace('R', '♖')
+    replaced_str = replaced_str.replace('Q', '♕')
+    replaced_str = replaced_str.replace('K', '♔')
+
+    replaced_str = replaced_str.replace('p', '♟')
+    replaced_str = replaced_str.replace('n', '♞')
+    replaced_str = replaced_str.replace('b', '♝')
+    replaced_str = replaced_str.replace('r', '♜')
+    replaced_str = replaced_str.replace('q', '♛')
+    replaced_str = replaced_str.replace('k', '♚')
+
+    replaced_str = replaced_str.replace('.', '·')
+
+    return replaced_str
+
+def trim_whitespace_before_newline(str_to_trim: str) -> str:
+    """Removes any spaces before a newline in a string.
+
+    Parameters:
+         - str_to_trim: The string to trim.
+
+    Returns: The trimmed string.
+    """
+    return re.sub('\s+$', '', str_to_trim, flags=re.M)
diff --git a/zulip_bots/zulip_bots/bots/chess/doc.md b/zulip_bots/zulip_bots/bots/chess/doc.md
new file mode 100644
index 00000000..6d73cb2e
--- /dev/null
+++ b/zulip_bots/zulip_bots/bots/chess/doc.md
@@ -0,0 +1,39 @@
+## Starting a Game
+
+You can start a game with another user by typing
+
+```
+start with other user
+```
+
+or you can start a game with a computer with
+
+```
+start as <white or black> with computer
+```
+
+## Playing
+
+After starting the game, you can make your move by typing
+
+```
+do <your move>
+```
+
+using [Standard Algebraic Chess Notation](https://goo.gl/rehi8n). For example,
+`do e4` to move a pawn to *e4* or `do Nf3` to move a night to *f3* or `do O-O`
+to castle.
+
+
+## Ending the game
+
+The bot will detect if a game is over. You can end one early by resigning
+with the
+
+```
+resign
+```
+
+command.
+
+(Or you could just stop responding.)
diff --git a/zulip_bots/zulip_bots/bots/chess/test_chess.py b/zulip_bots/zulip_bots/bots/chess/test_chess.py
new file mode 100644
index 00000000..77d1e4b1
--- /dev/null
+++ b/zulip_bots/zulip_bots/bots/chess/test_chess.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python
+
+from zulip_bots.test_lib import StubBotTestCase
+
+class TestChessBot(StubBotTestCase):
+    bot_name = "chess"
+
+    START_RESPONSE = '''New game! The board looks like this:
+
+```
+  a b c d e f g h
+8 ♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜ 8
+7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7
+6 · · · · · · · · 6
+5 · · · · · · · · 5
+4 · · · · · · · · 4
+3 · · · · · · · · 3
+2 ♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙ 2
+1 ♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖ 1
+  a b c d e f g h
+```
+
+
+Now it's **white**'s turn.
+
+
+To make your next move, respond to Chess Bot with
+
+```do <your move>```
+
+*Remember to @-mention Chess Bot at the beginning of your response.*'''
+
+    DO_E4_RESPONSE = '''The board was like this:
+
+```
+  h g f e d c b a
+1 ♖ ♘ ♗ ♔ ♕ ♗ ♘ ♖ 1
+2 ♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙ 2
+3 · · · · · · · · 3
+4 · · · · · · · · 4
+5 · · · · · · · · 5
+6 · · · · · · · · 6
+7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7
+8 ♜ ♞ ♝ ♚ ♛ ♝ ♞ ♜ 8
+  h g f e d c b a
+```
+
+
+Then *white* moved *e4*:
+
+```
+  h g f e d c b a
+1 ♖ ♘ ♗ ♔ ♕ ♗ ♘ ♖ 1
+2 ♙ ♙ ♙ · ♙ ♙ ♙ ♙ 2
+3 · · · · · · · · 3
+4 · · · ♙ · · · · 4
+5 · · · · · · · · 5
+6 · · · · · · · · 6
+7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7
+8 ♜ ♞ ♝ ♚ ♛ ♝ ♞ ♜ 8
+  h g f e d c b a
+```
+
+
+Now it's **black**'s turn.
+
+
+To make your next move, respond to Chess Bot with
+
+```do <your move>```
+
+*Remember to @-mention Chess Bot at the beginning of your response.*'''
+
+    DO_KE4_RESPONSE = '''Sorry, the move *Ke4* isn't legal.
+
+```
+  h g f e d c b a
+1 ♖ ♘ ♗ ♔ ♕ ♗ ♘ ♖ 1
+2 ♙ ♙ ♙ · ♙ ♙ ♙ ♙ 2
+3 · · · · · · · · 3
+4 · · · ♙ · · · · 4
+5 · · · · · · · · 5
+6 · · · · · · · · 6
+7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7
+8 ♜ ♞ ♝ ♚ ♛ ♝ ♞ ♜ 8
+  h g f e d c b a
+```
+
+
+To make your next move, respond to Chess Bot with
+
+```do <your move>```
+
+*Remember to @-mention Chess Bot at the beginning of your response.*'''
+
+    RESIGN_RESPONSE = '''*Black* resigned. **White** wins!
+
+```
+  h g f e d c b a
+1 ♖ ♘ ♗ ♔ ♕ ♗ ♘ ♖ 1
+2 ♙ ♙ ♙ · ♙ ♙ ♙ ♙ 2
+3 · · · · · · · · 3
+4 · · · ♙ · · · · 4
+5 · · · · · · · · 5
+6 · · · · · · · · 6
+7 ♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟ 7
+8 ♜ ♞ ♝ ♚ ♛ ♝ ♞ ♜ 8
+  h g f e d c b a
+```'''
+
+    def test_bot_responds_to_empty_message(self) -> None:
+        with self.mock_config_info({'stockfish_location': '/foo/bar'}):
+            response = self.get_response(dict(content=''))
+            self.assertIn('play chess', response['content'])
+
+    def test_main(self) -> None:
+        with self.mock_config_info({'stockfish_location': '/foo/bar'}):
+            self.verify_dialog([
+                ('start with other user', self.START_RESPONSE),
+                ('do e4', self.DO_E4_RESPONSE),
+                ('do Ke4', self.DO_KE4_RESPONSE),
+                ('resign', self.RESIGN_RESPONSE),
+            ])