DEV Community

TiltedLunar123
TiltedLunar123

Posted on

Stockfish already picks the best move. Getting it to pick a human-looking one was the hard part

so i built a desktop chess gui around stockfish. you against the engine, adjustable strength, move hints, edit the board, the usual. the part that took the longest wasn't the board or the animations. it was making the engine feel like an opponent instead of a wall.

the problem: stockfish at full strength plays the single best move every time. that's correct and it's also boring, and worse, it doesn't feel human even when you crank the rating down. the built-in way to weaken it is UCI_Elo, and that does make it lose more. but the moves it loses with still look like engine moves. it'll slide a rook one square along the back rank because to the engine that's 4 centipawns better, and no club player alive would play that in a casual game.

so i split the problem in two. strength stays with stockfish. the "feel" is a separate layer on top, and it's not allowed to touch the strength.

strength comes from UCI_Elo, flavour comes from re-ranking

when human mode is on, i don't ask stockfish for one move. i ask it for its top 4:

infos = self.engine.analyse(board, limit, multipv=4)
Enter fullscreen mode Exit fullscreen mode

now i've got up to four candidate moves, each with a centipawn score. the top one is the engine's pick. the rest are moves it also considered, ranked slightly lower. those alternatives are the raw material.

every candidate gets a weight, and the base weight is just how close it is to the best move:

diff = best_cp - cp
if diff <= 10:
    w = 1.0
elif diff <= 30:
    w = 0.55
elif diff <= 60:
    w = 0.18
elif diff <= 100:
    w = 0.05
else:
    w = 0.01
Enter fullscreen mode Exit fullscreen mode

the whole thing hinges on that taper. a move that's 80+ centipawns worse than the best basically never gets picked (weight 0.01). so whatever the human-feel layer does next, it physically can't make the engine hang a piece. it can only shuffle the order of moves that are already good.

then i multiply those weights by human-feel factors, and every one is gated on the move already being close to the top:

# early game: develop your pieces, don't shuffle the queen out
if move_num <= 10 and piece is not None and diff <= 30:
    if pt in (chess.KNIGHT, chess.BISHOP):
        w *= 1.18                       # minor piece off the back rank
        if pt == chess.KNIGHT and to_file in (0, 7):
            w *= 0.55                   # knight on the rim looks engine-y
    if pt == chess.QUEEN:
        w *= 0.80                       # early queen sortie is a tell
Enter fullscreen mode Exit fullscreen mode

there's more of these. recapturing on the square your opponent just took on gets a 1.5x bump (people recapture on reflex). castling gets 1.25x. giving check gets a small 1.1x. all gated so they only fire when the move is within about 30cp of best.

last step, i don't just take the highest weight. i do a weighted random pick:

return random.choices(moves, weights=weights, k=1)[0]
Enter fullscreen mode Exit fullscreen mode

so in a quiet equal position it might develop a knight one game and castle the next. that variety is most of what makes it feel less robotic.

the style knob is the same trick, simpler

separate from human mode there's an aggressive/balanced/defensive setting. same idea, flatter math. instead of a random draw i just re-rank the candidates by a bias:

  • aggressive: +25 for a capture, +30 for a check, +5 for pushing a pawn
  • defensive: +15 for a quiet move, +8 for not giving check, +25 for castling

with the same guard: a move more than 80cp below the best can't be biased ahead of it, ever. so aggressive mode grabs the sharp move when it's roughly as good, and shuts up when it isn't.

testing move-taste without launching stockfish

this is the part i actually liked. _human_style talks to a real stockfish subprocess, which is a miserable thing to depend on in CI. so the tests hand the engine a fake:

class FakeEngine:
    def analyse(self, board, limit, multipv=1):
        return self._infos   # canned candidate list
Enter fullscreen mode Exit fullscreen mode

no binary, no download, the inputs are just python-chess objects. that lets me assert the thing i actually care about, which is that the taste layer never costs real strength:

def test_top_move_dominates_when_alternatives_are_much_worse():
    # top move at +100, alternative at -60 (160cp worse)
    picks = [e._human_style(board, _limit()) for _ in range(200)]
    assert picks.count(top) > 150
Enter fullscreen mode Exit fullscreen mode

200 samples, fixed seed, and the top move has to win at least 150 of them. if i ever fat-finger the taper and let a bad move through, that test goes red.

what's still not great

honest version: this is taste, not skill. every bonus only reshuffles moves stockfish already thinks are fine. a real 1400 hangs a knight sometimes. mine never will, because everything's gated on small centipawn diffs. so "human mode" really means "a tidy player who happens to never blunder," which isn't the same thing as a 1400.

and the numbers are all eyeballed. 1.18, 0.55, the +25 for a capture, none of it is fit to real games. i picked values that looked right across a dozen test positions and stopped. the honest upgrade would be to score moves against a database of actual human games at a target rating, or use something like Maia (a net trained to predict the move a human of a given rating actually plays) instead of my hand-waving multipliers.

it works though. in a quiet middlegame it'll castle and develop and recapture like a person, and that was the whole point. not perfect. but it plays like someone's sitting across from you now, which the raw engine never did.

repo's here if you want to poke at it: https://github.com/TiltedLunar123/stockfish-chess

Top comments (0)