9
\$\begingroup\$

I wrote a simulator that simulates chess games. The game uses probability to determine the player that is most likely to win. though they might be upsets(a low rated player winning the game). I would love insights and suggestions on how to improve on the algorithm, general review is also welcomed.

NOTE: The names and rating were gotten from chess.com application and the code were written in jupyter-notebook

"""Chess Game simulator"""

import random
import time

NAMES = ['Jimmy', 'Samie', 'Elani', 'Aron', 'Emir', 'Sven', 'Nelson',
         'Antonio', 'Isabel', 'Wally', 'Li', 'Noam', 'Francis', 'Danya',
         'Danny', 'Engine']
         
RATINGS = [600, 900, 400, 700, 1000, 1100,
           1300, 1500, 1600, 1800, 2000,
           2200, 2300, 2650, 2500, 1000]

class Player:
    """This class holds the player information"""

    def __init__(self, name, rating):
        self.name = name
        self.rating = rating
        self.opponent = None

    def __repr__(self):
        return 'Player({}, {})'.format(self.name, self.rating)

class ChessGame():

    def __init__(self, players):
        self.players = players
        self.tournament_rounds = {1: 'Round 1', 2: 'Round 2', 3: 'Semi-final', 4: 'Final'}
        self.game_round = 0

    def next_round(self):
        """Go the next round of the tournament"""

        self.game_round += 1

        try:
            print(self.tournament_rounds[self.game_round])
        except KeyError:
            return

    def make_pair(self):
        """Combine two players randomly to play a chess game"""

        players = self.players[:]
        new_players = []

        while players:
            player1 = random.choice(players)
            players.remove(player1)
            player2 = random.choice(players)
            players.remove(player2)

            player1.opponent = player2
            player2.opponent = player1
            new_players.append(player1)

        self.players = new_players

    def game(self):
        """run the simulation"""

        if len(self.players) < 2:
            print('Exception: Not enough players to play a game')
            return

        self.next_round()
        print('--------------------------------------------')
        self.make_pair()
        for player in self.players:
            print(f'{player.name}({player.rating}) vs {player.opponent.name}({player.opponent.rating})')
        time.sleep(3)
        print('--------------------------------------------')
        self.players = self.score_game()

    def score_game(self):
        """Score the game using probabilities"""

        winners = []
        for player in self.players:
            total_rating = player.rating + player.opponent.rating
            player_chance = player.rating / total_rating

            maximum_range = 1000
            player_range = [i for i in range(0, int(player_chance * maximum_range))]
            choice = random.randint(0, maximum_range)
            if choice in player_range:
                print(f'{player.name} wins this round by checkmate')
                player.rating += self.game_round * 200
                winners.append(player)
            else:
                print(f'{player.opponent.name} wins this round by checkmate')
                player.opponent.rating += self.game_round * 200
                winners.append(player.opponent)

        return winners

CHESS_PLAYERS = [Player(name, rating) for name, rating in zip(NAMES, RATINGS)]

CHESS_SIM = ChessGame(CHESS_PLAYERS)
CHESS_SIM.game()
print()
CHESS_SIM.game()
print()
CHESS_SIM.game()
print()
CHESS_SIM.game()
\$\endgroup\$
2
  • 5
    \$\begingroup\$ It's unrealistic that there is never a draw, each game is win by someone. That's probably why chess tournaments use the Swiss System for drawing the rounds. \$\endgroup\$ Jan 15, 2021 at 5:57
  • 1
    \$\begingroup\$ Given that we're talking chess, the rating points you're using are probably based on ELO or glicko rating system (that's what chess.com uses afaik, ELO is used by FIDE). You should look up how those work, because Daniel Naroditsky (a 2600 GM) having a 72% chance to win against a chess beginner with 1000 points is not exactly realistic. \$\endgroup\$
    – Voo
    Jan 15, 2021 at 22:14

4 Answers 4

8
\$\begingroup\$

Use immutable sequences

Turn

NAMES = ['Jimmy', 'Samie', 'Elani', 'Aron', 'Emir', 'Sven', 'Nelson',
         'Antonio', 'Isabel', 'Wally', 'Li', 'Noam', 'Francis', 'Danya',
         'Danny', 'Engine']
         
RATINGS = [600, 900, 400, 700, 1000, 1100,
           1300, 1500, 1600, 1800, 2000,
           2200, 2300, 2650, 2500, 1000]

into tuples instead of lists, because they're constants.

Use data classes

class Player:
    """This class holds the player information"""

    def __init__(self, name, rating):
        self.name = name
        self.rating = rating
        self.opponent = None

    def __repr__(self):
        return 'Player({}, {})'.format(self.name, self.rating)

does not need an explicit __init__ if you make it a @dataclass:

@dataclass
class Player:
    """This class holds the player information"""
    name: str
    rating: int
    opponent: Optional['Player']

though I don't think that it's appropriate to represent opponent as an attribute of a player. That belongs to the game state.

Sequence representation

Consider changing

    self.tournament_rounds = {1: 'Round 1', 2: 'Round 2', 3: 'Semi-final', 4: 'Final'}

into a global (or class-static) tuple of strings:

TOURNAMENT_ROUNDS = ('Round 1', 'Round 2', 'Semi-final', 'Final')

The indices as keys are redundant.

Iterators and responsibilities

def next_round(self):
    """Go the next round of the tournament"""

    self.game_round += 1

    try:
        print(self.tournament_rounds[self.game_round])
    except KeyError:
        return

and

            print(f'{player.name} wins this round by checkmate')
            player.rating += self.game_round * 200

seem dicey to me in terms of OOP representation. You'd be better-off having the game class yield a generator of rounds. This will make the game instance reusable.

Your score_game has the following responsibilities baked in - game simulation, and game presentation. These need to be separated - your score_game should not be printing. Also, score_game is itself not a great name, since it's not just scoring the game - it's actually running the game simulation.

Random selection

        maximum_range = 1000
        player_range = [i for i in range(0, int(player_chance * maximum_range))]
        choice = random.randint(0, maximum_range)
        if choice in player_range:

needs to be re-thought. Currently you're constructing a list, in memory, of every single number from 0 through (up to) 1000 only to do a membership test and then throw the whole thing away. Instead, just generate a random number and compare it to an upper bound.

Sleepy code

Don't arbitrarily add sleep. If you want to space out your console interface between logical operations, wait for a keypress.

Suggested

"""Chess Game simulator"""

from dataclasses import dataclass
from random import shuffle, randrange
from typing import ClassVar, Tuple, Iterable, Dict


@dataclass
class Player:
    name: str
    rating: int

    def __str__(self):
        return f'{self.name}({self.rating})'


CHESS_PLAYERS = [
    Player(name, rating)
    for rating, name in (
        ( 600, 'Jimmy'),
        ( 900, 'Samie'),
        ( 400, 'Elani'),
        ( 700, 'Aron'),
        (1000, 'Emir'),
        (1100, 'Sven'),
        (1300, 'Nelson'),
        (1500, 'Antonio'),
        (1600, 'Isabel'),
        (1800, 'Wally'),
        (2000, 'Li'),
        (2200, 'Noam'),
        (2300, 'Francis'),
        (2650, 'Danya'),
        (2500, 'Danny'),
        (1000, 'Engine'),
    )
]


class Match:
    def __init__(self, p1: Player, p2: Player):
        self.p1, self.p2 = p1, p2
        total = self.p1.rating + self.p2.rating
        if randrange(total) < self.p1.rating:
            self.winner = self.p1
        else:
            self.winner = self.p2

    def __str__(self):
        return f'{self.p1} vs {self.p2}'

    @property
    def outcome(self) -> str:
        return f'{self.winner} wins this round by checkmate'


class Round:
    def __init__(self, name: str, matches: Iterable[Match]):
        self.name = name
        self.matches = tuple(matches)

    def print(self):
        print(self.name)
        print('-'*45)
        print('\n'.join(str(match) for match in self.matches))
        print('-'*45)
        print('\n'.join(match.outcome for match in self.matches))


class ChessGame:
    ROUND_NAMES: ClassVar[Tuple[str]] = ('Round 1', 'Round 2', 'Semi-final', 'Final')

    def __init__(self, players: Iterable[Player]):
        self.players: Tuple[Player] = tuple(players)

    def __iter__(self) -> Iterable[Round]:
        players = list(self.players)

        for i_round, name in enumerate(self.ROUND_NAMES):
            shuffle(players)
            half = len(players)//2
            round = Round(
                name,
                (
                    Match(p1, p2)
                    for p1, p2 in zip(players[:half], players[half:])
                )
            )
            yield round

            for match in round.matches:
                match.winner.rating += (i_round + 1) * 200

            players = [match.winner for match in round.matches]

    def print_run(self):
        for round in self:
            round.print()
            print()


def main():
    sim = ChessGame(CHESS_PLAYERS)
    sim.print_run()


if __name__ == '__main__':
    main()

The biggest 'gotcha' here is that, whereas the ChessGame is now reusable, player instances (just as in your original code) are mutated, so if you want to do repeated simulations you have to make copies of your player instances.

\$\endgroup\$
1
  • 3
    \$\begingroup\$ I really love the idea of a dataclass, great review. \$\endgroup\$ Jan 14, 2021 at 21:53
7
\$\begingroup\$

In addition to what @Reinderien mentioned, here are a few things I noticed.

It would be beneficial to store the players and ratings in a more meaningful format

PLAYERS = [
    Player(name, rating)
    for name, rating in [
        ('Jimmy', 600), ('Samie', 900),
        ('Elani', 400), ('Aron', 700),
        ('Emir', 1000), ('Sven', 1100),
        ('Nelson', 1300), ('Antonio', 1500),
        ('Isabel', 1600), ('Wally', 1800),
        ('Li', 2000), ('Noam', 2200),
        ('Francis', 2300), ('Danya', 2650),
        ('Danny', 2500), ('Engine', 1000)
    ]
]

The tournament round naming is quite brittle and could be fixed by solving the problem in the reverse order.

def round_name(self) -> str:
    if len(self.players) == 2:
        return 'Final'
    if len(self.players) == 4:
        return 'Semi-final'
    return f'Round {self.round}'
\$\endgroup\$
4
\$\begingroup\$

Your current way of pairing players is \$O(n^2)\$ due to using the remove method. Each remove is \$O(n)\$ since all the values that come after the removed value need to be moved back 1 index position. To make the whole function \$O(n)\$, you could use random.shuffle instead of random.choice. You could do something like:

random.shuffle(self.players)
for n in range(len(self.players) // 2):
    pair opponent n with opponent n + len(self.players) // 2
del self.players[len(self.players) // 2:]

This also would deal with odd numbers of players by leaving out whoever was the last player in self.players after shuffling.

Also, you could write a method that simulates each game until the tournament is over instead of manually calling CHESS_SIM.game.

\$\endgroup\$
2
  • \$\begingroup\$ The problem is what if the games are spaced between 2 days interval?. I thought about this, I just figured that explicitly calling a simulation when needed would be fine. I really don't wanna pause a simulation to the exact day of the match. \$\endgroup\$ Jan 14, 2021 at 21:56
  • 2
    \$\begingroup\$ You can have your cake and eat it too: an __iter__ approach such as the one I've shown is both centralized and "pauseable" just like any standard Python iterator. \$\endgroup\$
    – Reinderien
    Jan 14, 2021 at 22:41
2
\$\begingroup\$

In terms of chess you seem to have missed some things (in addition it would be rare with such a tournament) that will require updates in the logic:

  • If the number of players is not a power of two (there is likely to be some no-show) you will fail in make_pair as you will have an odd number of players and select one player and then not have an opponent.
  • I would also use pre-tournament ratings for win-chance and do rating updates afterwards; as players don't become stronger by beating a weaker opponent during the tournament.
  • The loser normally loses rating correspondingly, and keeping track of rating is important even if they don't play more in that tournament.
  • White is more likely to win, so you should adjust win-chance.
  • At least the final would often have more than one game (due to color).
  • Games can end in a draw (with more complicated rating update); obviously that will require some way to determine the winner - in some cases that would be a blitz chess playoff that doesn't influence rating. Another option would be to have more normal games between the players (as above), and then blitz if even.
  • Games in rated tournaments are rarely won by check-mate (even if not draw), thus just write "wins" not "wins by checkmate". In general avoid adding such unnecessary details.

The simplest solution for many of these items would be to view it as a tennis-tournament instead.

\$\endgroup\$
2
  • \$\begingroup\$ Am fairly new to chess, what do you mean by white is more likely to win? \$\endgroup\$ Jan 15, 2021 at 18:42
  • 1
    \$\begingroup\$ In practice white, who starts, is as far as I recall more likely to win when the two players are of equal strength. \$\endgroup\$ Jan 15, 2021 at 22:11

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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