Basically - I want to utilize an engine to see tactics through the whole game, regardless of whether they're a good move or not. Sometimes, Stockfish may say a position is absolutely winning, but the opponent has a fork which loses the game - UNLESS you play an absolutely perfect sequence of 6, hard to find moves. So, it doesn't even tell you that there was a fork - because the engine assumes you play perfectly, so therefore you are absolutely winning. But, to beginners, it would not be so.

Is there some way to do this, using Stockfish or other?

Edit: Basically - it would be really cool to "filter" moves by a theme - pin, fork, discovered attack, etc. I think this is really the essence of my question. And then, theoretically, you could go beyond that make the engine have low depth, more lines, etc. But simply increasing the amount of lines it shows you isn't exactly what I'm looking for. (Sorry for my badly phrased question.)

  • chesstricksapp.wordpress.com This software may have what you are looking for. It is obviously not perfect, but it can find some nasty tricks Mar 15, 2023 at 2:13
  • @chess_lover_6 that's pretty cool, but when comparing it to the Boris Trapsky bot, it doesn't find the moves it makes... I think that's because it's trying to find moves that only have one correct answer, but most moves will have multiple, so it doesn't consider it... but, cool software anyway - it's pretty close Mar 15, 2023 at 2:45
  • Programmatically finding positions that has a tactical move is easier to do than classifying the position into different tactical themes. There are solutions to these issues. First list and understand each tactical themes. You can use python-chess library and engine to detect these themes. The other way is to train a model that can classify the position.
    – ferdy
    Mar 16, 2023 at 4:03

5 Answers 5


Created a script that will detect the following tactical themes.

  • pins
  • forks
  • double check
  • undermine

It will read the games in the pgn file. Visit each position in the game and run stockfish at multipv 10 to find non-losing move that generates the said tactics.

Note the code is not optimized.


"""Find pin, fork, double check and underminig moves.

    pip install chess

import chess
import chess.pgn
import chess.engine

def detect_forks(init_epd, curmove, currank, curscore, board, wp, bp, event, date):
    """Find move the forks 2 or more pieces.

        init_epd: The position in the game.
        curmove: The move from stockfish from multipv analysis.
        currank: The rank of the move from multipv analysis.
        board: The board after executing the curmove.
        wp: White player in the game.
        bp: Black player in the game.
        event: The event name.
        date: The date the event is held.

        A list of positions with fork.
    forks = []

    # The to_square of the move that forks must not be attacked by enemy.
    move_attackers = board.attackers(board.turn, curmove.to_square)
    if len(move_attackers):
        return forks

    prev_board = chess.Board(init_epd)

    # Find the attacked squares from the move.
    attacked_sq = board.attacks(curmove.to_square)
    legal_attacked_sq = []

    # Number of attacked squares must be 2 or more.
    if len(attacked_sq) >= 2:
        # Scan all the squares and pieces on the board.
        for square, piece in board.piece_map().items():
            # If this square is not in the attacked squares, skip.
            if square not in attacked_sq:

            # If the color of this square is not an enemy color, skip.
            if piece.color != board.turn:

            # The attacked square should not be defended.
            defenders = board.attackers(board.turn, square)

            # If there is defender, skip.
            if len(defenders):

            # Store the legal attacked square.

        # Check if all the legal attacked squares are two or more.
        if len(legal_attacked_sq) >= 2:
            forks.append([init_epd, prev_board.san(curmove), currank, curscore, "fork"])
            forked_sq = []
            for s in legal_attacked_sq:
            print(f'initepd: {init_epd}, move: {prev_board.san(curmove)}, rank: {currank}, score: {curscore}, forked_sq: {forked_sq}, white: "{wp}", black: "{bp}", event: "{event}", date: {date}')

    return forks

def double_check(init_epd, curmove, currank, curscore, board, wp, bp, event, date):
    """Find move that attacks the enemy king twice."""
    dchecks = []

    prev_board = chess.Board(init_epd)

    # If the to_square of curmove does not check, skip.
    if board.is_check() and len(board.attackers(not board.turn, board.king(board.turn))) > 1:
        dchecks.append([init_epd, prev_board.san(curmove), currank, curscore, "double check"])
        print(f'initepd: {init_epd}, move: {prev_board.san(curmove)}, rank: {currank}, score: {curscore}, checked_sq: {board.king(board.turn)}, white: "{wp}", black: "{bp}", event: "{event}", date: {date}')

    return dchecks

def undermine(init_epd, curmove, currank, curscore, board, pv, wp, bp, event, date):
    Check if curmove is an undermining move.

    curmove, oppmove, oppmovereply ------ pv sequence
    The curmove captures the enemy piece.
    oppmove captures the piece from curmove
    The piece captured by curmove is a defender_pc.
    oppmovereply captures a piece that is defended by defender_pc.
    undermines = []

    if len(pv) < 3:
        return undermines
    pv = pv[:3]  # starts from init_epd
    prev_board = chess.Board(init_epd)

    is_capture = prev_board.is_capture(curmove)
    if not is_capture:
        return undermines
    # Walk the pv starting from prev_board.
    bt1 = prev_board.copy()
    b1 = bt1.copy()
    m1 = curmove

    b2 = bt1.copy()
    m2 = pv[1]

    b3 = bt1.copy()
    m3 = pv[2]

    # The last move should be a capture.
    if not b3.is_capture(m3):
        return undermines

    if m1.to_square == m2.to_square:
        # The piece captured by m1 is a defender.
        defended_sq = b1.attacks(m1.to_square)
        if len(defended_sq):
            # Loop thru the defended sq, if one of it is captured by m3 then m1 is an undermining move.
            for s in defended_sq:
                if m3.to_square == s and m3.to_square != m1.to_square:
                    pv_san = b1.variation_san(pv)
                    undermines.append([init_epd, prev_board.san(curmove), currank, curscore, "undermine"])
                    print(f'initepd: {init_epd}, move: {prev_board.san(curmove)}, pv: {pv_san}, rank: {currank}, score: {curscore}, undermined_sq: {chess.square_name(m3.to_square)}, white: "{wp}", black: "{bp}", event: "{event}", date: {date}')
                    return undermines
    return undermines

def detect_good_pins(init_epd, curmove, currank, curscore, board, wp, bp, event, date):
    pins = []
    prev_board = chess.Board(init_epd)

    # Loop thru the squares and pieces on the current board.
    for square, piece in board.piece_map().items():

        # Detect a pinned piece of the side to move.
        # The pinned piece is not a king and not a pawn.
        if (piece.color == board.turn and
                piece.piece_type != chess.KING and
                piece.piece_type != chess.PAWN):
            # If this square is pinned by the side not to move.
            if board.is_pinned(board.turn, square):

                # Get the piece type that currently occupies the square, if this piece has no
                # legal move, then it is pinned.
                is_pin = True
                for move in board.legal_moves:
                    if move.from_square == square:
                        is_pin = False

                if is_pin:

                    # Is the last move pins the opponent?
                    # Takeback the move and check if the square is not pinned.
                    # If it is pinned, the move is not the cause of the pin.
                    # If it is not pinned, the move is the cause of the pin.
                    tmp_board = board.copy()

                    if not tmp_board.is_pinned(not tmp_board.turn, square):

                        # Save the info.
                        attackers = board.attackers(not board.turn, square)
                        defenders = board.attackers(board.turn, square)
                        if len(attackers) >= len(defenders):
                            pins.append([init_epd, prev_board.san(curmove), currank, curscore, "pin"])
                            print(f'initepd: {init_epd}, move: {prev_board.san(curmove)}, rank: {currank}, score: {curscore}, pinned_sq: {chess.square_name(square)}, white: "{wp}", black: "{bp}", event: "{event}", date: {date}')

    return pins

if __name__ == '__main__':
    fn = 'f:/project/pgn/tatamast23.pgn'
    fn = 'f:/project/pgn/2023-pro-chess-league-main-event-regular-season.pgn'
    gcnt = 0
    all_pins = []
    all_forks = []
    all_double_check = []
    all_undermines = []
    multipv = 10

    engine = chess.engine.SimpleEngine.popen_uci(
    engine.configure({'Hash': 128})

    with open(fn) as pgn:
        while True:
            game = chess.pgn.read_game(pgn)
            if game is None:

            gcnt += 1
            print(f'game {gcnt}')

            wp = game.headers['White']
            bp = game.headers['Black']
            event = game.headers['Event']
            date = game.headers['Date']

            for node in game.mainline():
                board = node.parent.board()

                if board.ply() < 24:

                # Analyze this board with the engine. Use multipv 10 to
                # increase the chance of detecting a move that generates
                # a dangerous pin.
                info = engine.analyse(board, chess.engine.Limit(time=0.5), multipv=multipv)

                for i in range(min(multipv, board.legal_moves.count())):
                    score = info[i]['score'].relative.score(mate_score=32000)
                    pv = info[i]['pv']
                    move = pv[0]

                    # The move score should not be lossing.
                    if score <= -200:

                    # Make the engine move on the board and check if this move pins an enemy piece.
                    tmp_board = board.copy()

                    pins = detect_good_pins(board.epd(), move, i+1, score, tmp_board, wp, bp, event, date)
                    if len(pins):

                    forks = detect_forks(board.epd(), move, i+1, score, tmp_board, wp, bp, event, date)
                    if len(forks):

                    double_checks = double_check(board.epd(), move, i+1, score, tmp_board, wp, bp, event, date)
                    if len(double_checks):

                    undermines = undermine(board.epd(), move, i+1, score, tmp_board, pv, wp, bp, event, date)
                    if len(undermines):

            if gcnt >= 1000000:


    for pins in all_pins:
        for p in pins:

    for forks in all_forks:
        for f in forks:

    print('double checks:')
    for dc in all_double_check:
        for d in dc:

    for un in all_undermines:
        for u in un:

Sample pins

initepd: 3n2k1/p4r1p/1pR1p1p1/5q2/3P4/4QP2/P3N1P1/6K1 w - -, move: Rc8, rank: 2, score: 3, pinned_sq: d8, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14
initepd: 2Q5/p4nk1/1p4p1/3p2q1/3P3p/5P2/P3N1P1/5K2 w - -, move: Qc7, rank: 1, score: 0, pinned_sq: f7, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14
initepd: 2Q5/p4nk1/1p4p1/3p2q1/3P3p/5P2/P3N1P1/5K2 w - -, move: Qb7, rank: 2, score: -2, pinned_sq: f7, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14
initepd: 2Q5/p4nk1/1p4p1/3p2q1/3P3p/5P2/P3N1P1/5K2 w - -, move: Qd7, rank: 4, score: -39, pinned_sq: f7, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14
initepd: 8/Q4nk1/1p4p1/3p1q2/3P3p/5P2/P3N1P1/5K2 b - -, move: Qd3, rank: 7, score: -26, pinned_sq: e2, white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14

The rank is the ranking of move based from stockfish. Rank 1 is the best move based from a given analysis time of 0.5 sec.

The first output line is this.

enter image description here

The move that pins the enemy is Rc8. This is only ranked at number 2 (I have not recorded the top 1 engine move and the actual game move, but this is possible by revising the script) with a score of 3 cp from the point of view of the side to move.

The pinned square is d8. The game is between white: "Praggnanandhaa, R", black: "Erigaisi, Arjun", event: "85th Tata Steel Masters", date: 2023.01.14.

Another line:

initepd: 6k1/1p3p2/1R3p1p/3pn3/8/2P1P3/r4PPP/1B2K3 b - -, move: Ra1, rank: 3, score: -108, pinned_sq: b1, white: "Abdusattorov, Nodirbek", black: "Caruana, Fabiano", event: "85th Tata Steel Masters", date: 2023.01.15

enter image description here

Sample fork output

initepd: 1k1r3r/ppqb1pQp/2n5/1B6/8/B3PN2/P4PPP/2R3K1 b - -, move: Qa5, rank: 3, score: -28, forked_sq: ['b5', 'a3'], white: "Carlsen, Magnus", black: "Abdusattorov, Nodirbek", event: "85th Tata Steel Masters", date: 2023.01.19

enter image description here

The move Qa5 will forked a3 and b5.

Sample double check

initepd: 5rk1/5pp1/8/7p/1QB2P2/1P2q2P/2P2nP1/4R1K1 b - -, move: Nxh3+, rank: 1, score: 0, checked_sq: 6, white: "Pranesh, M", black: "Dubov, Daniil", event: "Pro Chess League Main Event 2023 Regular Season", date: 2023.03.01

enter image description here

The move Nxh3 generates a double attacks to the white king.

Sample undermine 1

initepd: 5rk1/2q1pp1p/pnPp1bp1/1p1B4/2r2P2/P1N1Q2P/1PP3P1/3RR1K1 b - -, move: Bxc3, pv: 1...Bxc3 2. bxc3 Nxd5, rank: 1, score: 282, undermined_sq: d5, white: "Foisor, Sabina-Francesca", black: "Deac, Bogdan-Daniel", event: "Pro Chess League Main Event 2023 Regular Season", date: 2023.02.17

enter image description here

pv: 1...Bxc3 2. bxc3 Nxd5, rank: 1, score: 282, undermined_sq: d5

Sample undermine 2

initepd: 5rk1/2q2pp1/R1rp1n2/7p/1pBQ1P2/1P5P/2P3P1/3R2K1 w - -, move: Rxc6, pv: 1. Rxc6 Qxc6 2. Qxd6, rank: 1, score: 174, undermined_sq: d6, white: "Pranesh, M", black: "Dubov, Daniil", event: "Pro Chess League Main Event 2023 Regular Season", date: 2023.03.01

enter image description here

pv: 1. Rxc6 Qxc6 2. Qxd6, rank: 1, score: 174, undermined_sq: d6
  • Very cool! This is pretty inline with what I might code in the future. It isn't perfect, because it only detects pieces that literally cannot move (so it won't detect a bishop pinning a queen, for example), but that would take a bit more coding so as to prevent stupid blunders (the queen could just take the bishop). Still pretty awesome though! Mar 16, 2023 at 20:02
  • I intentionally exclude the "partial pin" such as bishop pins queen. The code can be easily revised to include partial pins.
    – ferdy
    Mar 17, 2023 at 1:19
  • Revised code to also detect forks, double-check and undermine.
    – ferdy
    Mar 17, 2023 at 14:05

If you use Stockfish via some sort of GUI, like Scid, Scid vs PC, Fritz or Chessbase, as most people do, then your GUI will have an option to show not just the best sequence of moves but the best n such. I normally have it set to 2 or 3.

If there are at least this number of legal moves then it will still show this number of alternatives even if only one move (say a queen recapture) is good and the other moves are blunders. If there is only one legal move then it just shows that one move.

I don't know what limit Stockfish places on the number of lines it will give you, but whatever number that is, if your bad but tempting move is in one of those lines then you can get it to show you.

If, say, Stockfish can return its best 10 lines and your bad move is number 9 then you can use this to see it. If your bad move is number 11 or worse then not.

the opponent has a fork which loses the game - UNLESS you play an absolutely perfect sequence of 6, hard to find moves

In general this type of situation could be detected by setting the maximum depth for evaluation to a low figure.

  • It would be a useful addition for stockfish to supplement suggested moves with it's view on how difficult a move or sequence of moves is to find, then a GUI could be set to ignore sequences that are beyond the ability of the person using it. Shame, I'm not aware of such a thing existing at the moment
    – Darren H
    Mar 13, 2023 at 15:35
  • I am curious how hard it would be to write a script to do that. Essentially running a bunch of stockfish instances in parallel with different max depths, and ranking moves by some algorithm that weighs how much a move's score changes at each max depth level with how deep the max search depth was.
    – Chuu
    Mar 13, 2023 at 16:06
  • 1
    This answer is correct. By adjusting the number of lines the chess engine shows, you may be able to discover funny lines.
    – Emre Bener
    Mar 14, 2023 at 6:51
  • 1
    Also, at least till version 4.22, ScidVsPC has the "Exclude move" option: in any position you can "forbid" the engine to explore certain moves. It's interesting to leave the engine analyzing for a few seconds, and once you get the best moves, add them to the "Exclude move" list. This is more efficient than just adding more moves to be showed, since the engine won't waste resources in the best moves nad will focuse in the weakest ones. A very useful feature to find interesting opening ideas (specially at amateur level).
    – emdio
    Mar 14, 2023 at 10:11
  • 1
    @Chuu I think this could be a great answer to this question. Running Stockfish multiple times with different Max-Depth should solve this quite nicely. Moves on which they agree are "easy" and moves which only the engine with higher depth find are "hard"
    – Falco
    Mar 14, 2023 at 10:20

No. Stockfish is designed to always assume optimal play, so it treats all the moves it finds "equally" without any consideration for difficulty. Keep in mind that engines are a tool to assist analysis, not a replacement for it. It gives you the answers, but you're still the one posing the questions.

  • 1
    I guess you are right. It's obviously possible to make additional code or alter the engine in order to find these moves, because how else would Lichess be able to have puzzle themes with 200k+ entries unless an algorithm identified a move as a "pin" or "fork". I am thinking about coding this myself though, it shouldn't be too hard Mar 13, 2023 at 17:17
  • 1
    You are simply wrong. OP is asking if it is possible to see alternative lines, even if they are not as good as the best line, which is possible with every chess engine.
    – Emre Bener
    Mar 14, 2023 at 6:50
  • 3
    @Mephisto that's not what OP is asking for. By showing multiple lines you're still the one who has to dig into them and check which ones require more precise play than others.
    – David
    Mar 14, 2023 at 10:00
  • 1
    the question is; "Is there a way to use Stockfish to see tactics that aren't necessarily great?". the answer is; yes, it is possible, as viewing multiple lines could reveal tactics that aren't necessarily great.
    – Emre Bener
    Mar 14, 2023 at 13:23
  • @LukasKawalec: It's not complicated to find puzzles. A puzzle is a sequence of forced moves, where a move is "forced" if all other possible moves are much worse. You can figure that out just by looking at the numerical evaluation (after adjusting for centipawns being a poor metric). Finding good puzzles, or puzzles that effectively train a specific concept, is harder, of course.
    – Kevin
    Mar 14, 2023 at 15:28

Using the "top n lines" approach, one way of finding tactics that "almost" work is to look for moves that are at the same time:

  1. Sacrifice material (a light officer or more)
  2. Get a flat 0.00 evaluation from the engine.

These kind of moves should be interesting to look closer at, since giving up material should normally mean you are losing. Yet, the machine says you aren't. That means there must be some concrete tactic that works out for you in the future.

Ideally, what this reveals is an attack with you winning in almost every line except for a very accurate defence where the opponent holds the draw. Or the reverse situation, of course, with you defending.

In general, positions with unbalanced material that are still drawn with perfect play should be a perfect example of "tactics that aren't necessarily great". The engine helps you with the "still drawn with perfect play" identification part.

  • I think this the closest answer so far, thanks. What parameter are you talking about for sacrificing material? "Light officer"? Mar 14, 2023 at 17:20

I found something pretty cool, which I guess doesn't answer my question, but it's pretty close in nature:

Lichess has a "Boris Trapsky" bot, in which it makes blunders, or sacs pieces that, if taken, lead to a major advantage, or mate. Doing this against an engine would of course be pointless as with correct play it's just a stupid blunder.

I guess in a perfect world you could somehow use this bot to analyze your own games and find those kinds of moves, which is pretty much what I'm looking for. So in the end, I think it is possible, but requires additional coding, and nothing offers this feature out of the box.

Here's the link: https://lichess.org/study/qDFa6puH

Not the answer you're looking for? Browse other questions tagged or ask your own question.