OneZenPanda BOT: Inner Peace, Three Checkmates
In OneZenPanda’s first game, the queens came off early and the evening began with a draw. A calm introduction. Very on brand.
Then the panda won three games in a row.
All three ended in checkmate.
Apparently zen describes its emotional state, not its intentions toward the other king.
Three games after the handshake
These were the next three completed games after the debut against Leol-bot, all rated 3+2 blitz on September 25, 2026, in my Pacific time zone. Lichess’s PGN records date them September 26 because it was already the next day in UTC.
| Opponent | Opponent’s rating at the start | OneZenPanda’s color | Opening | Finish |
|---|---|---|---|---|
| aposeij | 2271 | Black | Ruy Lopez, Berlin Defense | 53…Rg1# |
| Hyperopic | 2041 | White | King’s Indian Defense, Semi-Averbakh System | 45.Bg7# |
| Voigtsbach | 2022 | Black | Queen’s Pawn Game, London System | 48…Qg2# |
That made the opening record three wins and a draw. I am enjoying this considerably more than my contribution to the move selection deserves.
Other people built the opponents
The part I like most is discovering what is sitting across the board.
aposeij’s profile describes a custom engine written in Zig, running on one thread with 512 MB of hash memory. Its source is on GitHub.
Hyperopic is written in Rust and runs on AWS Lambda. Its profile describes a single-threaded engine with no pondering and no state carried between moves, designed to support many concurrent games. That is a different engineering ambition from pointing my Mac at one board. Its source is available too.
Voigtsbach’s profile calls its engine Funken and describes an experiment in having Spark 1.3 build an original engine without copying or cloning existing work. It links to the engine repository.
People are building chess players, putting them online, and letting the board test their ideas. That is wonderful.
I installed Stockfish and gave it a panda name.
There is room for both levels of craftsmanship in this story, although only one of us should be taking credit for the search algorithm.
aposeij: the Berlin gets less peaceful
Another Berlin Defense, another queen exchange. This time the queens left after 15…Qxh5 16.gxh5, and the game continued for quite a while without becoming a handshake.
Black pushed 27…g4, met 28.hxg4 with 28…f5, and brought a rook into the attack with 29…Rxg4. Later that rook traveled through f4 and h4 to h1. The absence of queens did not prevent Black’s rooks and bishops from making themselves comfortable around White’s king.
The finish was 51…Bd4 52.e5 Rf2+ 53.Ke1 Rg1#.
OneZenPanda’s clock had dropped to seven seconds after 44…Kd7. It finished with eighteen. Two seconds of increment is a small amount of time until it is the reason there is still a game to finish.
Hyperopic: the new queen has a very short appointment
With White against Hyperopic, OneZenPanda played a King’s Indian structure and exchanged queens on move fifteen. The moment I want to remember came much later.
After 31.Nf6 Bxf6 32.gxf6, White had a pawn on f6. It advanced to f7 on move forty-one, then captured the bishop on e8 and promoted:
44.fxe8=Q+ Rxe8 45.Bg7#.
The new queen lasted one reply. The bishop delivered mate.
That is a useful little correction to how I tend to watch a promotion. I see a new queen and assume she is the point. Here, the recorded finish ends with the queen gone and the bishop doing the final job.
A very brief management appointment.
Voigtsbach: replacement queen, same shift
Against Voigtsbach’s London System, the promotion happened on the other side of the board.
Black’s b-pawn reached b3 on move thirty-six and b2 on move thirty-seven. Then came the sequence that made this replay worth stopping:
38.Bc2 Qxc2 39.Nxc2 b1=Q+.
Black gave up its queen for a bishop, White’s knight took the queen, and the pawn immediately supplied a replacement with check. Counting only the captured queen would miss the next move entirely.
The replacement stayed on the board. After 42.Qd3 Nd2+ 43.Qxd2 Qxd2, White’s queen was gone too. Black finished with 47…Qf3+ 48.Kg1 Qg2#.
Two promotions across two games. One queen appeared just long enough to be captured. The other stayed to deliver mate. Both replays reward watching what happens after the promotion square lights up.
What I am actually running
The bot’s local startup logs identify the engine used in these games as Stockfish 19. The current setup uses the lichess-bot bridge, with Stockfish installed through Homebrew on my Mac.
The main settings are:
- Four threads and 512 MB hash, with Skill Level 20 and strength limiting disabled.
- Pondering enabled, so the engine can think during the opponent’s turn.
- One game at a time, with automatic outgoing challenges for rated standard 3+2 games.
- A rating-difference limit of 1000 for automatic matchmaking, with no preference for higher- or lower-rated opponents.
- No enabled Polyglot book, online move providers, or bridge-managed tablebases. The engine has a configured
SyzygyPath; that setting alone does not establish that tablebase files are installed. - No fixed depth, node count, or move time override in the active UCI settings. These were clock-managed blitz games.
Incoming challenges have their own settings: rated standard bullet or blitz, subject to the configured base-time and increment limits. The automatic invitations are specifically 3+2.
This is a record of three games, not a controlled engine comparison. The opponents were rated substantially below OneZenPanda’s starting ratings in these games, and at least two describe single-threaded setups. My bot has four threads and pondering. Three wins are fun; they do not isolate engine quality from hardware, settings, or time management.
I also did not write the engine. The Stockfish developers supplied the chess, the lichess-bot contributors supplied the bridge, and the opposing developers supplied three interesting programs willing to play it.
My contribution was getting the pieces connected and watching with entirely unearned confidence.
Current config.yml
For documentation, this is my complete configuration snapshot from September 25, 2026, including its comments and inactive examples. The API token on the first line has been replaced with REDACTED. This records the current file; it is not a claim that every setting remained unchanged throughout the evening.
token: "REDACTED"
url: "https://lichess.org/" # Lichess base URL.
engine: # Engine settings.
dir: "/opt/homebrew/bin/" # Directory containing the engine. This can be an absolute path or one relative to lichess-bot/.
name: "stockfish" # Binary name of the engine to use.
debug: False # When enabled, log all startup communication with the engine.
# interpreter: "java"
# interpreter_options:
# - "-jar"
working_dir: "" # Directory where the chess engine will read and write files. If blank or missing, the current directory is used.
# NOTE: If working_dir is set, the engine will look for files and directories relative to this directory, not where lichess-bot was launched. Absolute paths are unaffected.
protocol: "uci" # "uci", "xboard" or "homemade"
ponder: true # Think on opponent's time.
polyglot:
enabled: false # Activate polyglot book.
book:
standard: # List of book file paths for variant standard.
- engines/book1.bin
- engines/book2.bin
# atomic: # List of book file paths for variant atomic.
# - engines/atomicbook1.bin
# - engines/atomicbook2.bin
# etc.
# Use the same pattern for 'chess960', 'giveaway' (antichess), 'crazyhouse', 'horde', 'kingofthehill', 'racingkings' and '3check' as well.
min_weight: 1 # Does not select moves with weight below min_weight (min 0, max: 100 if normalization isn't "none" else 65535).
selection: "weighted_random" # Move selection is one of "weighted_random", "uniform_random" or "best_move" (but not below the min_weight in the 2nd and 3rd case).
max_depth: 20 # How many moves from the start to take from the book.
normalization: "none" # Normalization method for the book weights. One of "none", "sum", or "max".
draw_or_resign:
resign_enabled: false # Whether or not the bot should resign.
resign_score: -1000 # If the score is less than or equal to this value, the bot resigns (in cp).
resign_for_egtb_minus_two: true # If true the bot will resign in positions where the online_egtb returns a wdl of -2.
resign_moves: 3 # How many moves in a row the score has to be below the resign value.
offer_draw_enabled: true # Whether or not the bot should offer/accept draw.
offer_draw_score: 0 # If the absolute value of the score is less than or equal to this value, the bot offers/accepts draw (in cp).
offer_draw_for_egtb_zero: true # If true the bot will offer/accept draw in positions where the online_egtb returns a wdl of 0.
offer_draw_moves: 10 # How many moves in a row the absolute value of the score has to be below the draw value.
offer_draw_pieces: 10 # Only if the pieces on board are less than or equal to this value, the bot offers/accepts draw.
online_moves:
max_out_of_book_moves: 10 # Stop using online opening books after they don't have a move for 'max_out_of_book_moves' positions. Doesn't apply to the online endgame tablebases.
max_retries: 2 # The maximum amount of retries when getting an online move.
# max_depth: 10 # How many moves from the start to take from online books. Default is no limit.
chessdb_book:
enabled: false # Whether or not to use chessdb book.
min_time: 20 # Minimum time (in seconds) to use chessdb book.
max_time: 10800 # Maximum starting game time (in seconds) to use chessdb book.
move_quality: "good" # One of "all", "good", "best".
min_depth: 20 # Only for move_quality: "best".
lichess_cloud_analysis:
enabled: false # Whether or not to use lichess cloud analysis.
min_time: 20 # Minimum time (in seconds) the bot must have to use cloud analysis.
max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use cloud analysis.
move_quality: "best" # One of "good", "best".
max_score_difference: 50 # Only for move_quality: "good". The maximum score difference (in cp) between the best move and the other moves.
min_depth: 20
min_knodes: 0
lichess_opening_explorer:
enabled: false
min_time: 20
max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use the lichess opening explorer.
source: "masters" # One of "lichess", "masters", "player"
player_name: "" # The lichess username. Leave empty for the bot's username to be used. Used only when source is "player".
sort: "winrate" # One of "winrate", "games_played"
min_games: 10 # Minimum number of times a move must have been played to be chosen.
online_egtb:
enabled: false # Whether or not to enable online endgame tablebases.
min_time: 20 # Minimum time (in seconds) the bot must have to use online EGTBs.
max_time: 10800 # Maximum starting game time (in seconds) the bot must have to use online EGTBs.
max_pieces: 8 # Maximum number of pieces on the board to use endgame tablebases.
source: "lichess" # One of "lichess", "chessdb".
move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move).
lichess_bot_tbs: # The tablebases list here will be read by lichess-bot, not the engine.
syzygy:
enabled: false # Whether or not to use local syzygy endgame tablebases.
paths: # Paths to Syzygy endgame tablebases.
- "engines/syzygy"
max_pieces: 7 # Maximum number of pieces in the endgame tablebase.
move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move).
gaviota:
enabled: false # Whether or not to use local gaviota endgame tablebases.
paths:
- "engines/gaviota"
max_pieces: 5
min_dtm_to_consider_as_wdl_1: 120 # The minimum DTM to consider as syzygy WDL=1/-1. Set to 100 to disable.
move_quality: "best" # One of "best" or "suggest" (it takes all the moves with the same WDL and tells the engine to only consider these; will move instantly if there is only 1 "good" move).
# engine_options: # Any custom command line params to pass to the engine.
# cpuct: 3.1
homemade_options:
# Hash: 256
uci_options: # Arbitrary UCI options passed to the engine.
Move Overhead: 100 # Increase if your bot flags games too often.
Threads: 4 # Max CPU threads the engine can use.
Hash: 512 # Max memory (in megabytes) the engine can allocate.
Skill Level: 20 # Maximum Stockfish skill.
UCI_LimitStrength: false # Play at full strength.
SyzygyPath: "./syzygy/" # Paths to Syzygy endgame tablebases that the engine reads.
UCI_ShowWDL: true # Show the chance of the engine winning.
# go_commands: # Additional options to pass to the UCI go command.
# nodes: 1 # Search so many nodes only.
# depth: 5 # Search depth ply only.
# movetime: 1000 # Integer. Search exactly movetime milliseconds.
# xboard_options: # Arbitrary XBoard options passed to the engine.
# cores: "4"
# memory: "4096"
# egtpath: # Directory containing egtb (endgame tablabases), relative to this project. For 'xboard' engines.
# gaviota: "Gaviota path"
# nalimov: "Nalimov Path"
# scorpio: "Scorpio Path"
# syzygy: "Syzygy Path"
# go_commands: # Additional options to pass to the XBoard go command.
# depth: 5 # Search depth ply only.
# Do note that the go commands 'movetime' and 'nodes' are invalid and may cause bad time management for XBoard engines.
silence_stderr: false # Some engines (yes you, Leela) are very noisy.
abort_time: 30 # Time to abort a game in seconds when there is no activity.
fake_think_time: false # Artificially slow down the bot to pretend like it's thinking.
rate_limiting_delay: 0 # Time (in ms) to delay after sending a move to prevent "Too Many Requests" errors.
move_overhead: 2000 # Increase if your bot flags games too often.
max_takebacks_accepted: 0 # The number of times to allow an opponent to take back a move in a game.
quit_after_all_games_finish: false # If set to true, then pressing Ctrl-C to quit will only stop lichess-bot after all current games have finished.
correspondence:
move_time: 60 # Time in seconds to search in correspondence games.
checkin_period: 300 # How often to check for opponent moves in correspondence games after disconnecting.
disconnect_time: 150 # Time before disconnecting from a correspondence game.
ponder: false # Ponder in correspondence games the bot is connected to.
challenge: # Incoming challenges.
concurrency: 1 # Number of games to play simultaneously.
games_reserved_for_humans: 0 # Number of game slots (out of concurrency) kept free for human challengers. Bot games are limited to (concurrency - this) so humans can always challenge.
sort_by: "best" # Possible values: "best" and "first".
preference: "none" # Possible values: "none", "human", "bot".
accept_bot: true # Accepts challenges coming from other bots.
only_bot: false # Accept challenges by bots only.
max_increment: 20 # Maximum amount of increment to accept a challenge in seconds. The max is 180. Set to 0 for no increment.
min_increment: 1 # Minimum amount of increment to accept a challenge in seconds.
max_base: 1800 # Maximum amount of base time to accept a challenge in seconds. The max is 10800 (3 hours).
min_base: 60 # Minimum amount of base time to accept a challenge in seconds.
max_days: 14 # Maximum number of days per move to accept a challenge for a correspondence game.
# Unlimited games can be accepted by removing this field or specifying .inf
min_days: 1 # Minimum number of days per move to accept a challenge for a correspondence game.
variants: # Chess variants to accept (https://lichess.org/variant).
- standard
# - fromPosition
# - antichess
# - atomic
# - chess960
# - crazyhouse
# - horde
# - kingOfTheHill
# - racingKings
# - threeCheck
time_controls: # Time controls to accept (bots are not allowed to play ultraBullet).
- bullet
- blitz
# - correspondence
modes: # Game modes to accept.
- rated # Rated games.
# block_list: # List of users from which the challenges are always declined.
# - user1
# - user2
# online_block_list: # The urls from which to retrieve a list of bot names that will not be challenged. The list should be a text file where each line contains the name of a blocked bot
# - example.com/blocklist
# allow_list: # List of users from which challenges are exclusively accepted, all others being declined. If empty, challenges from all users may be accepted.
# - user3
# - user4
# recent_bot_challenge_age: 60 # Maximum age of a bot challenge to be considered recent in seconds
# max_recent_bot_challenges: 2 # Maximum number of recent challenges that can be accepted from the same bot
# min_rating: 0 # Minimum rating of the challenger to accept a challenge.
# max_rating: 4000 # Maximum rating of the challenger to accept a challenge. The maximum rating in lichess is 4000.
# rating_difference: 300 # Maximum rating difference between the bot and the challenger. Overrides min_rating and max_rating if set.
bullet_requires_increment: false # Require that bullet game challenges from bots have a non-zero increment
max_simultaneous_games_per_user: 5 # Maximum number of simultaneous games with the same user
greeting:
# Optional substitution keywords (include curly braces):
# {opponent} to insert opponent's name
# {me} to insert bot's name
# Any other words in curly braces will be removed.
hello: "Hi! I'm {me}. Good luck! Type !help for a list of commands I can respond to." # Message to send to opponent chat at the start of a game
goodbye: "Good game!" # Message to send to opponent chat at the end of a game
hello_spectators: "Hi! I'm {me}. Type !help for a list of commands I can respond to." # Message to send to spectator chat at the start of a game
goodbye_spectators: "Thanks for watching!" # Message to send to spectator chat at the end of a game
# pgn_directory: "game_records" # A directory where PGN-format records of the bot's games are kept
# pgn_file_grouping: "game" # How to group games into files. Options are "game", "opponent", and "all"
# "game" (default) - every game is written to a different file named "{White name} vs. {Black name} - {lichess game ID}.pgn"
# "opponent" - every game with a given opponent is written to a file named "{Bot name} games vs. {Opponent name}.pgn"
# "all" - every game is written to a single file named "{Bot name} games.pgn"
matchmaking:
allow_matchmaking: true # Set it to 'true' to challenge other bots.
allow_during_games: false # Set it to 'true' to create challenges during long games.
challenge_variant: "standard" # Challenge other bots to standard chess.
challenge_timeout: 1 # Create a challenge after being idle for 'challenge_timeout' minutes. The minimum is 1 minute.
challenge_initial_time: # Initial time in seconds of the challenge (to be chosen at random).
- 180
challenge_increment: # Increment in seconds of the challenge (to be chosen at random).
- 2
# challenge_days: # Days for correspondence challenge (to be chosen at random).
# - 1
# - 2
# opponent_min_rating: 600 # Opponents rating should be above this value (600 is the minimum rating in lichess).
# opponent_max_rating: 4000 # Opponents rating should be below this value (4000 is the maximum rating in lichess).
opponent_rating_difference: 1000 # The maximum difference in rating between the bot's rating and opponent's rating.
rating_preference: "none" # One of "none", "high", "low".
challenge_mode: "rated" # Set it to the mode in which challenges are sent. Possible options are 'casual', 'rated' and 'random'.
challenge_filter: "coarse" # If a bot declines a challenge, do not issue a similar challenge to that bot. Possible options are 'none', 'coarse', and 'fine'.
# block_list: # The list of bots that will not be challenged
# - user1
# - user2
# online_block_list: # The urls from which to retrieve a list of bot names that will not be challenged. The list should be a text file where each line contains the name of a blocked bot
# - example.com/blocklist
include_challenge_block_list: false # Do not challenge bots in the challenge: block_list/online_block_list in addition to the matchmaking block list.
# challenge_decliner_file_name: # If the user wants to permanently stop challenging bots that decline challenges, specify the name of the file to store the bot names. If the path is not absolute, it will be relative to the current directory.
# overrides: # List of overrides for the matchmaking specifications above. When a challenge is created, either the default specification above or one of the overrides will be randomly chosen.
# bullet_only_horde: # Name of the override. Can be anything as long as each override has a unique name ("bullet_only_horde" and "easy_chess960" in these examples).
# challenge_variant: "horde" # List of options to override. Only the options mentioned will change when making the challenge. The rest will follow the default matchmaking options above.
# challenge_initial_time:
# - 1
# - 2
# challenge_increment:
# - 0
# - 1
#
# easy_chess960:
# challenge_variant: "chess960"
# opponent_min_rating: 400
# opponent_max_rating: 1200
# opponent_rating_difference:
# challenge_mode: casual
#
# no_pressure_correspondence:
# challenge_initial_time:
# challenge_increment:
# challenge_days:
# - 2
# - 3
# challenge_mode: casual
#
# The following configurations cannot be overridden: allow_matchmaking, challenge_timeout, challenge_filter and block_list.
The first game proved the bot could finish a game. The next three gave me a reason to keep watching.
OneZenPanda is still calm. I am becoming a less neutral spectator.