Chess
Recently, I’ve become obsessed with two things: chess and Texas Hold’em.
For now, I’m putting the Texas Hold’em project on hold, because it is significantly more complex. It involves many different scenarios and an enormous set of rules—for example: a flush beats a straight, a straight beats three of a kind, three of a kind beats two pair, and so on. Given this level of complexity, I’m not ready to implement it yet.
So after learning the basic rules of chess, I decided to build a web-based chess application.
Chess Rules
Chess is truly a fascinating game.
The core rules of chess can be divided into three main categories: the board and pieces, movement rules, and win/draw conditions. A brief overview is as follows:
1. Board and Pieces
- Board: An 8×8 board with a total of 64 squares, alternating between dark (black) and light (white) squares. When setting up the board, the square in the bottom-right corner must be white.
- Pieces: Each side has 16 pieces: 1 King, 1 Queen, 2 Rooks, 2 Bishops, 2 Knights, and 8 Pawns. White moves first, then players alternate turns.
2. Movement Rules of Each Piece
| Piece | Movement Rules | Special Notes |
|---|---|---|
| King | Moves one square horizontally, vertically, or diagonally | Cannot move into a square attacked by the opponent; cannot be adjacent to the opposing king |
| Queen | Moves any number of squares horizontally, vertically, or diagonally | The most powerful piece on the board |
| Rook | Moves any number of squares horizontally or vertically | Participates in castling |
| Bishop | Moves any number of squares diagonally | Always stays on the same color square |
| Knight | Moves in an “L” shape (2 squares in one direction, then 1 perpendicular) | Can jump over other pieces |
| Pawn | Movement: 1. On its first move, may move 1 or 2 squares forward 2. Afterwards, only 1 square forward Capture: diagonally forward 1 square | Must promote upon reaching the last rank |
3. Special Rules
- Castling: If neither the king nor the rook has moved, there are no pieces between them, the king is not in check, and the path is not under attack, the king moves two squares toward the rook, and the rook moves to the square next to the king. There are kingside castling and queenside castling.
- En Passant: If a pawn moves two squares forward from its starting position and lands beside an opposing pawn, that pawn may capture it on the very next move as if it had moved only one square. This must be done immediately or the opportunity is lost.
- Pawn Promotion: When a pawn reaches the opponent’s back rank (White to rank 8, Black to rank 1), it must be promoted to a Queen, Rook, Bishop, or Knight (not a King). Promotion to a Queen is usually preferred.
4. Win and Draw Conditions
Win Conditions
- Checkmate: The king is in check and cannot escape by moving, blocking, or capturing the attacking piece.
- Loss on Time: A player runs out of time.
- Resignation: A player voluntarily resigns.
- Illegal Moves: Repeated illegal actions (such as making illegal moves or touching a piece and not moving it) result in a loss.
Draw Conditions
- Stalemate: The player to move is not in check but has no legal moves.
- Threefold Repetition: The same position (including castling and en passant rights) occurs three times.
- Fifty-Move Rule: No pawn moves or captures occur in 50 consecutive moves.
- Mutual Agreement: Both players agree to a draw.
- Insufficient Material: Neither side has sufficient material to checkmate (e.g., King vs King, King + Bishop vs King, King + Knight vs King).
Technology Stack
| Layer | Technology |
|---|---|
| Engine | Stockfish |
| Frontend | React.js |
| Backend | Python Tornado |
- The project structure is very simple. The backend is built using Python Tornado, although it could be implemented in almost any language or framework.
- Personally, I ruled out Spring Boot, .NET, and Go, which are statically typed ecosystems.
- For this project, I needed something that allows fast and convenient implementation of chess rule validation. While Spring Boot and .NET have relevant
.jaror.dlllibraries, I strongly dislike their dependency management and compilation times. Waiting forever after every change kills productivity. - That left Python and Node.js as the main options.
- I chose Python because many like-minded enthusiasts have already packaged a large number of chess-related libraries. Node.js has far fewer options in this area.
- Since the frontend is implemented using an independent framework, using a different language for the backend adds diversity and makes the project more enjoyable.
The image of the tech structure as the following:

Backend
The backend logic is implemented using RESTful APIs. The available endpoints are shown below:
# Application entry point
import tornado.web
import tornado.ioloop
from config import SERVER_HOST, SERVER_PORT
# Import all handlers
from handlers.board_handlers import BoardStateHandler, GameStatusAggregateHandler
from handlers.game_handlers import (
MakeMoveHandler, AIHandler, ResetHandler,
SetGameModeHandler, GetAllHistoryHandler, UndoMoveHandler
)
from handlers.analysis_handlers import GameAnalysisHandler
from handlers.game_analysis_pgn_handler import GameAnalysisPGNHandler
from handlers.websocket_handlers import GameWebSocketHandler
def make_app():
"""Create Tornado application"""
return tornado.web.Application([
(r"/api/board", BoardStateHandler),
(r"/api/game_status", GameStatusAggregateHandler),
(r"/api/move", MakeMoveHandler),
(r"/api/ai", AIHandler),
(r"/api/reset", ResetHandler),
(r"/api/set_mode", SetGameModeHandler),
(r"/api/get_all_history", GetAllHistoryHandler),
(r"/api/undo", UndoMoveHandler),
(r"/api/game_analysis", GameAnalysisHandler),
(r"/ws/game", GameWebSocketHandler),
(r"/api/analyse_pgn", GameAnalysisPGNHandler),
])
if __name__ == "__main__":
app = make_app()
app.listen(SERVER_PORT, address=SERVER_HOST)
print(f"Tornado server running on http://{SERVER_HOST}:{SERVER_PORT}")
print(f"WebSocket server running on ws://{SERVER_HOST}:{SERVER_PORT}/ws/game")
tornado.ioloop.IOLoop.current().start()In this setup, Python Tornado uses the chess Python library to communicate with Stockfish. Stockfish returns evaluation and move predictions, which Tornado then exposes to the frontend via RESTful APIs.
This forms the complete AI integration pipeline.
class AIHandler(BaseHandler):
def post(self):
try:
data = json.loads(self.request.body)
ai_level = data.get("ai_level", "normal")
board = global_state["board"]
current_game = global_state["current_game"]
current_game["ai_level"] = ai_level
ai_config = AI_CONFIG.get(ai_level, AI_CONFIG["normal"])
with chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH) as engine:
result = engine.play(
board,
chess.engine.Limit(
time=ai_config["time_limit"],
depth=ai_config["depth_limit"]
)
)
ai_move = str(result.move)
board.push(result.move)
move_number = len(current_game["moves"]) + 1
color = "white" if not board.turn else "black"
move_info = {
"move_number": move_number,
"color": color,
"move": ai_move,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"type": "ai"
}
current_game["moves"].append(move_info)
current_game["result"] = get_game_result(board)
save_game_data(current_game)
broadcast_game_update()
response = {
"success": True,
"ai_move": ai_move,
"fen": board.fen(),
"ai_level": ai_level,
"move_info": move_info
}
self.write(json.dumps(response))
except Exception as e:
self.write(json.dumps({"success": False, "error": str(e)}))Additionally, other useful features can be added—for example, saving the game record at the end of a match for later analysis or replay:
def generate_pgn_from_game_data(game_data):
try:
moves = game_data.get("moves", [])
if not moves:
print(f"警告:游戏{game_data['game_id']}无走法记录,跳过PGN生成")
return
move_types = [m.get("type") for m in moves]
is_human_vs_human = all(t == "human" for t in move_types)
is_human_vs_ai = "human" in move_types and "ai" in move_types
is_ai_vs_ai = all(t == "ai" for t in move_types)
white_player = "Human"
black_player = "Human"
if is_human_vs_ai:
white_type = next(m.get("type") for m in moves if m.get("color") == "white")
black_type = next(m.get("type") for m in moves if m.get("color") == "black")
white_player = "Human" if white_type == "human" else f"AI ({game_data['ai_level']})"
black_player = "Human" if black_type == "human" else f"AI ({game_data['ai_level']})"
elif is_ai_vs_ai:
white_player = f"AI ({game_data['ai_level']})"
black_player = f"AI ({game_data['ai_level']})"
game_mode = game_data.get("mode", "").strip()
if not game_mode:
if is_human_vs_human:
game_mode = "human_vs_human"
elif is_human_vs_ai:
game_mode = "human_vs_ai"
else:
game_mode = "ai_vs_ai"
end_time = game_data.get("end_time", "").strip()
if not end_time:
end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
game_data["end_time"] = end_time
# 同步保存到JSON文件
with open(get_game_file_path(game_data["game_id"]), 'w', encoding='utf-8') as f:
json.dump(game_data, f, ensure_ascii=False, indent=2)
game = chess.pgn.Game()
game.headers.update({
"Event": f"Chess Game ({game_mode} mode)",
"Site": "Local Game",
"Date": game_data["start_time"].split()[0].replace("-", "."),
"Round": "1",
"White": white_player,
"Black": black_player,
"Result": convert_result_to_pgn_format(game_data["result"]),
"StartTime": game_data["start_time"],
"EndTime": end_time,
"AILevel": game_data.get("ai_level", "normal"),
"GameID": game_data["game_id"]
})
board = chess.Board()
node = game
for move_info in moves:
uci_move = move_info.get("move", "").strip()
if not uci_move:
continue
try:
move = board.parse_uci(uci_move)
if move in board.legal_moves:
node = node.add_variation(move)
board.push(move)
except ValueError as e:
print(f"跳过无效走法 {uci_move}:{e}")
continue
pgn_path = os.path.join(PGN_SAVE_DIR, f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.pgn")
with open(pgn_path, 'w', encoding='utf-8') as f:
exporter = chess.pgn.FileExporter(f)
game.accept(exporter)
print(f"PGN文件生成成功:{pgn_path}")
except Exception as e:
print(f"生成PGN失败:{str(e)}")(This function generates a PGN file from the recorded game data.)
Frontend
The frontend is built using React, created as a standard React project without tools like Next.js. It integrates React Router for navigation:
import { Routes, Route } from 'react-router-dom';
import Game from './game/Game.js';
import './App.css'
import GameContainer from './game_container/GameContainer.jsx';
import PGNReplay from './pgn_replay/PGNReplay.jsx';
function App() {
return (
<div className="App">
<Routes>
<Route path="/" element={<Game />} />
<Route path="/flight-charts" element={<GameContainer />} />
<Route path="/pgn-replay" element={<PGNReplay />} />
</Routes>
</div>
);
}
export default App;To keep this article from becoming too long, I won’t go into the implementation details of Game, GameContainer, or PGNReplay.
In essence, the work involves:
- Writing JSX and CSS
- Defining what should be displayed
- Using Axios to communicate with the Python Tornado backend
Following React’s design philosophy, I tried to build the UI using modular widgets/components, instead of putting everything into a single monolithic component.
This approach is somewhat similar to how Android development evolved from Activity-heavy designs to Fragment-based modularization.
Logic Layer
- To ensure fast responsiveness, the web client performs local move legality validation before sending any request to the backend.
- For this reason, the React project includes a
moveUtils.jsfile to handle such logic.
// utils/moveUtils.js
import { PROMOTION_NAMES, PIECE_RULES } from '../constants/chess_config.js';
import { toChessNotation } from './boardUtils.js';
// 分析走法无效原因
export const getInvalidMoveReason = (board, fromRow, fromCol, toRow, toCol, currentTurn) => {
const piece = board[fromRow][fromCol];
if (!piece) return '该位置无棋子,请选择有棋子的格子';
const isWhitePiece = piece === piece.toUpperCase();
const isCurrentTurnPiece = (currentTurn === 'white' && isWhitePiece) || (currentTurn === 'black' && !isWhitePiece);
if (!isCurrentTurnPiece) {
return `当前是${currentTurn === 'white' ? '白' : '黑'}棋回合,该棋子是${isWhitePiece ? '白' : '黑'}棋,无法移动`;
}
const targetPiece = board[toRow][toCol];
if (targetPiece) {
const targetIsWhite = targetPiece === targetPiece.toUpperCase();
if ((isWhitePiece && targetIsWhite) || (!isWhitePiece && !targetIsWhite)) {
return `目标位置有己方${targetIsWhite ? '白' : '黑'}棋(${targetPiece.toLowerCase() === 'p' ? '兵' : PROMOTION_NAMES[targetPiece] || '王'}),无法移动`;
}
}
return `该走法违反${isWhitePiece ? '白' : '黑'}${piece.toLowerCase() === 'p' ? '兵' : PROMOTION_NAMES[piece] || '王'}的走法规则:${PIECE_RULES[piece]}`;
};
// 判断是否是升变走法(位置+棋子类型判断)
export const isPromotion = (fromRow, fromCol, toRow, toCol, board) => {
const piece = board[fromRow][fromCol];
if (!piece || piece.toLowerCase() !== 'p') return false; // 不是兵则不升变
const isWhitePawn = piece === 'P';
// 白兵走到第0行(8线)、黑兵走到第7行(1线)
return (isWhitePawn && toRow === 0) || (!isWhitePawn && toRow === 7);
};
// 校验升变走法是否合法(兼容升变后缀)
export const isValidPromotionMove = (fromRow, fromCol, toRow, toCol, board, legalMoves) => {
// 1. 先判断是否是升变位置
if (!isPromotion(fromRow, fromCol, toRow, toCol, board)) return false;
// 2. 校验走法是否合法(兼容升变后缀:如 e7e8q 以 e7e8 开头)
const fromNotation = toChessNotation(fromRow, fromCol);
const toNotation = toChessNotation(toRow, toCol);
const baseMove = fromNotation + toNotation;
// 检查 legalMoves 中是否有以 baseMove 开头的走法
return legalMoves.some(move => move.startsWith(baseMove));
};
// 解析合法走棋位置(用于高亮可走位置)
export const parseLegalMovePositions = (selected, legalMoves, fromChessNotation) => {
if (!selected) return [];
const selectedNotation = toChessNotation(selected.row, selected.col);
const filteredMoves = legalMoves.filter(move => move.startsWith(selectedNotation));
return filteredMoves.map(move => {
const toNotation = move.substring(2, 4);
return fromChessNotation(toNotation);
});
};
// 格式化走棋记录文本
export const formatMoveText = (moveInfo) => {
const colorText = moveInfo.color === 'white' ? '白棋' : '黑棋';
const typeText = moveInfo.type === 'ai' ? '(AI)' : '(玩家)';
return `${moveInfo.move_number}. ${colorText} ${typeText}: ${moveInfo.move}`;
};From the backend’s perspective, the entire game is managed via a single global_state object, which tracks the current board, game metadata, and move history:
global_state = {
"board": chess.Board(),
"current_game": {
"game_id": str(uuid.uuid4()),
"start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"mode": "",
"player_color": "",
"ai_level": "normal",
"moves": [],
"result": "ongoing",
"end_time": ""
}
}- WebSocket support is also included to improve real-time responsiveness.
- This project is designed purely for personal, offline play, and can be deployed via Docker.
- It is not a centralized online multiplayer application.
I have never been interested in centralized competitive platforms. What I enjoy instead are offline, single-player experiences.
It’s like playing on a Nintendo Switch—Breath of the Wild or Tears of the Kingdom. You can play quietly at home or on a train, alone, without unpredictable teammates influencing your emotions. Every success and failure depends solely on your own actions.
That, to me, is the best kind of game.
I fully understand that using a global variable to manage game state is not scalable and cannot support multiple concurrent games—but I simply don’t care about that requirement.
Screenshots
Main Game Interface

Heatmap Analysis

Replay Import a PGN file to replay the game

Commercial Value
The commercial value of this project is almost zero.
Even the most polished chess apps—such as those operated at a national federation level—mostly rely on selling physical merchandise, like boards and pieces, rather than software monetization.
In-app purchases are extremely difficult to make profitable, especially for someone who prefers to think about problems from a technical perspective.
Future Money Sink
I spent over 80 RMB on a 39cm × 39cm chess set. The board is quite large.
I didn’t splurge on the resin material version, though—it’s clearly superior and looks amazing to handle.
There are also Pokémon-themed collectible chess sets priced at around 2,600 RMB, which feels like yet another dangerous money pit.