Unicode chess over Gemini
While exploring Geminispace, I came across the Spool-Five Gemini Capsule (gemini://spool-five.com) which included a game page. To my surprise, I saw it included a two-player chess server all served over Gemini. (I later found that the creator made a post about this to his blog which is worth reading: Gemini Chess).
Spool-Five's chess board uses exclusively ASCII characters which is very portable, but somewhat hard to follow, as the author notes:
Games are displayed using a very minimal interface. White pieces are lower-case and black pieces are upper-case. a b c d e f g h --------------------- 8 |R N B Q K B N R| 8 7 |P P P P P P P P| 7 6 |· · · · · · · ·| 6 5 |· · · · · · · ·| 5 4 |· · · · · · · ·| 4 3 |· · · · · · · ·| 3 2 |p p p p p p p p| 2 1 |r n b q k b n r| 1 --------------------- a b c d e f g h I'm open to suggestions with regard to improving this visually. At the moment it isn't ideal, but perhaps if you have a chess board at home you can set one up to help with thinking through moves.
In practice, Gemini servers/clients expect UTF-8 encoded text which has support for Unicode characters. In turn, Unicode includes symbols for black and white variants of all chess pieces. As a result, we can reasonably expect a Gemini browser to render a much more "visual" implementation of a chess board using Unicode. For example:
╔═══════════════╗ ║♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜║ ║♟ ♟ ♟ ♟ ♟ ♟ ♟ ♟║ ║. . . . . . . .║ ║. . . . . . . .║ ║. . . . . . . .║ ║. . . . . . . .║ ║♙ ♙ ♙ ♙ ♙ ♙ ♙ ♙║ ║♖ ♘ ♗ ♕ ♔ ♗ ♘ ♖║ ╚═══════════════╝
Unicode chess
I scavenged the core of my Canaveral server (Launch from Canaveral) and made a prototype chess server with this UI, making heavy use of python-chess and Stockfish. The python-chess package handles the actual game logic and exposes a Universal Chess Interface (UCI) for a chess engine such as Stockfish.
In order to keep the server stateless, I made the URL encode the following:
fen(chess board state in Forsyth-Edwards Notation)fancy(bool to toggle Unicode versus alphabetical symbols for pieces)elo(Elo target for Stockfish)
Then, the server can validate a user input (status 10), attempt to make the user's move on the python-chess board, generate a bot response using Stockfish, then return the output as a new page to the user. Unfortunately, the minimum Elo that the stockfish binary allows is 1320, meaning it will almost always beat me. The result is a working single-player version of chess over the Gemini protocol:
Since the entire server is stateless, the user can "save" their game by copying the URL and returning later. Equivalently, an arbitrary game state can be entered by manually creating a URL with the desired parameter values. Also, the bot's Elo and the board rendering style can be changed during the course of a game.
At first, I though that the single-player restriction would hinder my enjoyment of the server, but I found some unexpected benefits as I was implementing it. Since the bot can respond immediately, the user never needs to refresh or return to the game later. Additionally, no database is needed since the URL encodes the entire state and we do not need to sync between users. Overall, I think that a single-player approach simply works better with the protocol.
Limitations of Gemini multiplayer
The biggest limitation to the usability of two-player chess over Gemini is the lack of server-led polling; if the other player makes a move in a two-player game, then I would need to manually refresh to see any changes or even know that something happened. During a blitz, bullet, or even rapid game, seconds of latency can make a difference; and that's not even mentioning that requiring both players to repeatedly refresh their client waiting for a change is both an undesirably UX and would result in orders of magnitude more requests for the server to handle.
In the almost 2 years since Spool-Five released its chess server, which to my knowledge is the only two-player chess server currently running on Gemini, there has only been 3 "completed" games:
| id | start | end | move count |
|---|---|---|---|
bd110d7c |
2024-06-07 | 2025-08-13 | 22 |
f24fbb29 |
2025-01-14 | 2025-08-14 | 0 |
9be53505 |
2026-02-26 | 2026-03-03 | 26 |
The fastest game (9be53505) lasted for 5 days with an average of 4.6 hours per move. The other completed game that moves (bd110d7c) lasted for 432 days with an average of 19.6 days per move.
TLS identity
That being said, this server is a great demonstrate of pushing the boundaries of the Gemini protocol; with it, two people who only have access to the Gemini network can, in fact, play chess without the WWW. In addition, it's a great example of the use of TLS certificates as an identity.
Users on Geminispace can provide a self-signed certificate which can be used as a form of authentication. As a result, Spool-Five can keep track of users between sessions, which prevents impersonators from thwarting your chess matches and also allows for cross-session identity matching. Spool-Five takes this opportunity to track statistics for their games:
This concept is underexplored in my experience but it brings significant advantages from a user flexibility and privacy perspective. The user holds the key, can create arbitrarily many identities at whim (often with a simple browser UI/UX), and servers can use them as identity challenges. As a result, essentially any combination of identity + text input is possible: game servers, chat rooms, authorized note sharing, etc. At the same time, the minimalism of Gemini prevents big tech-style user tracking and leaves certificate management squarely in the user's hands.
Source code
Below is the source code for my single-player Unicode chess prototype:
Unicode chess (collapsable details)
import socket
import ssl
import base64
import json
from urllib.parse import urlparse, parse_qs, urlencode, unquote
import chess
import chess.engine
HOST = "0.0.0.0"
PORT = 1965
# Requires stockfish download from https://stockfishchess.org/download/
ENGINE_PATH = "./stockfish"
VALID_ELOS = [1320, 1500, 1800, 2000]
HELP_BODY = b"""# Chess over Gemini (help page)
=> gemini:/// Back
## How does this work?
This Gemini capsule lets you play chess against Stockfish.
The current game state is stored in the URL using FEN notation. For move input, the state is encoded into the /move/ URL path so Gemini input does not erase it. As a result, the server is stateless.
## Quirks
1. Moves must be in SAN notation.
2. Minimum Elo is 1320 due to a Stockfish requirement.
3. Your client needs to support Gemini's 10 input, monospace font, and prevent caching.
"""
def fancy_row(r):
translator = {"K": "♔", "Q": "♕", "R": "♖", "B": "♗", "N": "♘", "P": "♙", "k": "♚", "q": "♛", "r": "♜", "b": "♝", "n": "♞", "p": "♟", ".": ".",}
return [translator[i] for i in r]
def generate_board_ascii(board, fancy):
ascii_board = []
ascii_board.append(f" {' '.join([chr(i) for i in range(97, 105)])}")
ascii_board.append(f" ╔{'═' * 15}╗")
for i, r in enumerate(board):
row = " ".join(fancy_row(r) if fancy else r)
ascii_board.append(f"{8 - i} ║{row}║")
ascii_board.append(f" ╚{'═' * 15}╝")
return ascii_board
def plot(frame, x, y, content):
updated_frame = frame
for curr_y in range(y, y + len(content)):
if curr_y < 0 or curr_y >= len(frame):
continue
left_part = frame[curr_y][:x]
insert_part = content[curr_y - y]
right_part = (
frame[curr_y][x + len(insert_part):]
if len(frame[curr_y]) > x + len(insert_part)
else ""
)
updated_frame[curr_y] = left_part + insert_part + right_part
return updated_frame
def game_body(board, engine_elo, fancy=False, w=60, h=20, message=None):
frame = [" " * w for _ in range(h)]
frame[0] = f"┌{'─' * (w - 2)}┐"
frame[h - 1] = f"└{'─' * (w - 2)}┘"
for y in range(1, h - 1):
frame[y] = "│" + frame[y][1:-1] + "│"
board_arr = board_to_arr(board)
ascii_board = generate_board_ascii(board_arr, fancy)
frame = plot(frame, 3, 2, ascii_board)
frame = plot(frame, 6, 14, ["Options", "─"*7, "Fancy mode: " + ("YES" if fancy else "NO"), f"Engine Elo: {engine_elo}"])
frame = plot(frame, 25, 4, ["Welcome to chess over Gemini"])
frame = plot(frame, 25, 7, ["Latest moves", "─"*12])
moves = get_san_moves(board)
move_text = ", ".join(moves)
if len(move_text) > 30:
move_text = move_text[-30:]
frame = plot(frame, 25, 9, [move_text])
frame = plot(frame, 25, 16, ["Controls and information", "below this rectangle"])
body = "\n".join(frame) + "\n"
if message:
body += f"\n{message}\n"
if board.is_game_over():
body += f"\nGame over: {board.result()}\n"
body += f"\n=> {move_prompt_url(board, fancy, engine_elo)} Enter a move (SAN)"
body += f"\n=> {game_url(board, not fancy, engine_elo)} {'Disable' if fancy else 'Enable'} fancy mode"
body += "".join([f"\n=> {game_url(board, fancy, elo)} Set engine Elo to {elo}" for elo in VALID_ELOS])
body += "\n=> gemini://localhost/ Reset game"
#body += "\n=> gemini://localhost/help Help"
body += "\n\nThis project was heavily inspired by existing chess over Gemini projects:\n\n=> gemini://jsreed5.org/log/2024/202406/20240604-announcement-upcoming-closure-of-chess-over-gemini.gmi jsreed5's (former) Chess over Gemini\n=> gemini://dev.spool-five.com/src/app/chess spool-five's Chess"
return body
def parse_bool(value, default=False):
if value is None:
return default
return value.lower() in ("1", "true", "yes", "on")
def encode_state(board, fancy, engine_elo):
state = { "fen": board.fen(), "fancy": fancy, "elo": engine_elo }
raw = json.dumps(state).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def decode_state(token):
try:
padding = "=" * (-len(token) % 4)
raw = base64.urlsafe_b64decode(token + padding)
state = json.loads(raw.decode("utf-8"))
board = chess.Board(state.get("fen"))
fancy = bool(state.get("fancy", True))
engine_elo = int(state.get("elo", 1320))
if engine_elo not in VALID_ELOS:
engine_elo = 1320
return board, fancy, engine_elo
except Exception:
return chess.Board(), True, 1320
def parse_request(request_bytes):
request_line = request_bytes.decode("utf-8", errors="replace").strip()
parsed = urlparse(request_line)
params = parse_qs(parsed.query)
path = parsed.path or "/"
fen = params.get("fen", [None])[0]
fancy = parse_bool(params.get("fancy", [None])[0], default=True)
try:
engine_elo = int(params.get("elo", [1320])[0])
except ValueError:
engine_elo = 1320
if engine_elo not in VALID_ELOS:
engine_elo = 1320
try:
board = chess.Board(fen) if fen else chess.Board()
except ValueError:
board = chess.Board()
move = params.get("move", [None])[0]
return path, board, fancy, engine_elo, move, parsed
def game_url(board, fancy, engine_elo):
params = { "fen": board.fen(), "fancy": "1" if fancy else "0", "elo": str(engine_elo) }
return "gemini://localhost/?" + urlencode(params)
def move_prompt_url(board, fancy, engine_elo):
token = encode_state(board, fancy, engine_elo)
return f"gemini://localhost/move/{token}"
def board_to_arr(board):
arr = []
for rank in range(7, -1, -1):
row = []
for file in range(8):
square = chess.square(file, rank)
piece = board.piece_at(square)
row.append(piece.symbol() if piece else ".")
arr.append(row)
return arr
def get_san_moves(board):
temp = chess.Board()
san_moves = []
for move in board.move_stack:
san_moves.append(temp.san(move))
temp.push(move)
return san_moves
def play_san(board, move_text):
try:
move = board.parse_san(move_text)
board.push(move)
return True
except ValueError:
return False
def bot_move(board, engine_elo):
with chess.engine.SimpleEngine.popen_uci(ENGINE_PATH) as engine:
engine.configure({ "UCI_LimitStrength": True, "UCI_Elo": engine_elo })
result = engine.play( board, chess.engine.Limit(time=0.2))
return result.move
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("cert.pem", "key.pem")
with socket.socket() as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((HOST, PORT))
sock.listen()
print(f"Listening on {PORT}...")
while True:
conn, addr = sock.accept()
with context.wrap_socket(conn, server_side=True) as tls:
request = tls.recv(1024)
path, board, fancy, engine_elo, move, parsed = parse_request(request)
if path == "/help":
response = b"20 text/gemini\r\n" + HELP_BODY + b"\r\n"
tls.sendall(response)
continue
message = None
if path.startswith("/move/"):
token = path.removeprefix("/move/")
board, fancy, engine_elo = decode_state(token)
if not parsed.query:
response = "10 Enter your move in SAN notation. Example: e4\r\n"
tls.sendall(response.encode("utf-8"))
continue
move = unquote(parsed.query)
if move:
if board.is_game_over():
message = "The game is already over. Reset to start a new game."
elif not play_san(board, move):
message = f"Invalid move: {move}"
else:
if not board.is_game_over():
engine_move = bot_move(board, engine_elo)
board.push(engine_move)
body = game_body(board, engine_elo, fancy=fancy, message=message)
response = f"20 text/gemini\r\n{body}\r\n"
tls.sendall(response.encode("utf-8"))
Last updated April 26, 2026