Home>>Basketball>>How I built a complete NBA game simulator with less than 500 lines of code
BasketballProgrammingPython

How I built a complete NBA game simulator with less than 500 lines of code

Introduction

Basketball is one of my primary interests. I’ve always been attracted to not only the highlight plays, but also the wealth of interesting statistical tidbits out there. Sites like FiveThirtyEight and others do a great job of digging up advanced stats to prove a point. After creating a program to automatically pull NBA stats from stats.nba.com, I decided to create an entire app for creating virtual NBA teams, and pitting them against each other. Here’s how I did it.

The Approach

Clearly, such a complex project couldn’t be coded in a simple script. I would need to really embrace OOP(Object-Oriented Programming) for this. I decided to create 4 main classes: the Player, Team, Game, and GUI.

  • Players store information about their shooting percentages(how often they make shots), and can also simulate “shooting the ball”.
  • Teams contain 5 players, as well as the current score for that team.
  • Game contains 2 teams, as well as logic for the actual game simulation.
  • The GUI displays a list of players and allows the user to create virtual teams, for a simulated match.

Finally, the main loop will read the data for each player, and then start the GUI.

Coding the Player

I had a lot of really fine-grained data at my disposal. Namely, the shooting percentages, number of shot attempts, and number of made shots from 5 different zones on the court. To begin, I created a Player class, and put this data in the constructor:

#Class defining the parameters of the player
class Player:
    def __init__(self, name,RestrictArea_FGpercent,Paint_FGpercent,MidRange_FGpercent,LeftCorner3_FGpercent,RightCorner3_FGpercent,AboveBreak3_FGpercent,\
                 RestrictArea_FGA,Paint_FGA,MidRange_FGA,LeftCorner3_FGA,RightCorner3_FGA,Totals_FGA,AboveBreak3_FGA,\
                 RestrictArea_FGM,Paint_FGM,MidRange_FGM,LeftCorner3_FGM,RightCorner3_FGM,AboveBreak3_FGM,Totals_FGM):

Here, I added all the data available for each player into the constructor. Now, I needed to make all this data attributes of the Player class, so they can be used later.

        self.name = name.strip()   # Remove any whitespace
        self.RestrictArea_FGpercent = float(RestrictArea_FGpercent)
        self.Paint_FGpercent = float(Paint_FGpercent)
        self.MidRange_FGpercent = float(MidRange_FGpercent)
        self.LeftCorner3_FGpercent = float(LeftCorner3_FGpercent)
        self.RightCorner3_FGpercent = float(RightCorner3_FGpercent)
        self.AboveBreak3_FGpercent = float(AboveBreak3_FGpercent)

        self.RestrictArea_FGA = float(RestrictArea_FGA)
        self.Paint_FGA = float(Paint_FGA)
        self.MidRange_FGA = float(MidRange_FGA)
        self.LeftCorner3_FGA = float(LeftCorner3_FGA)
        self.RightCorner3_FGA = float(RightCorner3_FGA)
        self.Totals_FGA = float(Totals_FGA)
        self.AboveBreak3_FGA = float(AboveBreak3_FGA)

        self.RestrictArea_FGM = float(RestrictArea_FGM)
        self.Paint_FGM = float(Paint_FGM)
        self.MidRange_FGM = float(MidRange_FGM)
        self.LeftCorner3_FGM = float(LeftCorner3_FGM)
        self.RightCorner3_FGM = float(RightCorner3_FGM)
        self.AboveBreak3_FGM = float(AboveBreak3_FGM)
        self.Totals_FGM = float(Totals_FGM)  

The self. in each of these lines is saying that each of these attributes is an attribute of an instance of that class, rather than the class in general. This allows each Player to have different shooting percentages. The reason why each parameter has to be converted into a float is because they are read as strings, but Python can’t do math on those.

In order to simplify the rest of the code, I decided to make a player do two very simple things. Shoot a two-pointer, or shoot a three-pointer. Thus, I need to combine all these fine-grained data points into the attempts, made shots, and percentage for two-pointers and three-pointers.

        self.total2PA=self.RestrictArea_FGA+self.Paint_FGA+self.MidRange_FGA
        self.total3PA=self.LeftCorner3_FGA+self.RightCorner3_FGA+self.AboveBreak3_FGA

Above, I calculated the total 2-point attempts and 3-point attempts by adding up the attempts from each zone which it belongs to.

        if self.total2PA == 0: # prevent division by zero
            self.TwoPTpercent=0
        else:
            self.TwoPTpercent = (self.RestrictArea_FGM+self.Paint_FGM+self.MidRange_FGM)/(self.total2PA)
        if self.total3PA == 0:
            self.ThreePTpercent=0
        else:   
            self.ThreePTpercent = (self.LeftCorner3_FGM+self.RightCorner3_FGM+self.AboveBreak3_FGM)/(self.total3PA)

Now, I calculated the 2-point and 3-point shooting percentages by dividing the amount of shots made by the amount of shots attempted in each zone. If no shots were attempted by a player in a certain zone (this usually happens with three-pointers), the shooting percentage automatically equals zero.

        self.pointsScored = 0
        self.twoPTM = 0
        self.twoPTA = 0
        self.threePTM = 0
        self.threePTA = 0

Here, I’ve initialized the attributes which will be updated, and then reset during and after every simulated game. (M refers to shots made, and A refers to shots attempted. So self.threePTM, for example, is referring to the amount of three pointers the player made during a simulated game.)

        self.fga=self.RestrictArea_FGA+self.Paint_FGA+self.MidRange_FGA+self.LeftCorner3_FGA+self.RightCorner3_FGA+self.AboveBreak3_FGA

Here I calculated the total amount of shots a player takes per game. This will be useful down the line when we have to allocate shot attempts to different players.

        self.lotsOfShots2=random.choices([1,0],weights=[self.TwoPTpercent,1-self.TwoPTpercent],k=300)
        self.lotsOfShots3=random.choices([1,0],weights=[self.ThreePTpercent,1-self.ThreePTpercent],k=300)

There’s a lot to unpack here. Let’s start on the outside, and go in.

random.choices, according to the documentation, “Return(s) a k sized list of elements chosen from the population with replacement. If the population is empty, raises IndexError.” Essentially, it’s choosing something from a list with certain weights k times.

What is being chosen here. Either a 1 or a 0, which we can see in the list [1,0]. A 1 corresponds with a made shot, and a 0 is a missed shot. Now, what would the weights for a made or missed shot be? Clearly, they should be the player’s shooting percentages, which they are. The weight for a 1, or made shot is equal to the 2 or 3-point percentage, while the probability of missing is 1 minus that(a simple probability rule).

What’s actually happening here? 300 shot attempts, from both 2-point and 3-point zones, have been simulated. Why 300? Realistically, no one player is going to shoot more than 30-40 times in a game. Normally, both teams combine for around 200 shot attempts. These simulated shot attempts will be used later, when the game is being “played”.

    def reset_score(self):
        self.pointsScored=0

    def get_score(self):
        return self.pointsScored

    def get_splits(self):
        return(self.twoPTM,self.twoPTA,self.threePTM,self.threePTA)

    def reset_splits(self):
        self.twoPTM = 0
        self.twoPTA = 0
        self.threePTM = 0
        self.threePTA = 0

These are some helpful functions that act as getters and setters. In object-oriented programming, you don’t want a class to expose its attributes. Instead, it’s a good idea to write functions which encapsulate this action. The function reset_score() changes the Player’s pointsScored attribute back to zero. get_score() returns the attribute pointsScored. In Python, it’s not possible to declare a variable private, but in another language it should be private. The functions get_splits() and reset_splits() are a getter and setter for the player’s simulated game stats.

    def shoot2pt(self,turn):
        if self.lotsOfShots2[turn] == 1: #Selecting one shot out of the lots of shots
            return True  #Make shot
        else:
            return False
    def shoot3pt(self,turn):
        if self.lotsOfShots3[turn] == 1: #Selecting one shot out of the lots of shots
            return True  #Make shot
        else:
            return False

The functions shoot2pt() and shoot3pt() are essentially wrapping the data that is stored in lotsOfShots2 and lotsOfShots3. They take the current turn(possession number, in basketball terms) and figure out whether the player would have made the shot(1, so return True), or not made the shot(0, so return False). Since we don’t want the actual game simulation to access the lotsOfShots attributes directly, this function is needed to act as a getter function.

Coding the Team

Compared to the Player, which had a lot of complex logic and arguments being passed into it, there isn’t much going on in a Team. It’s just wrapper functions for managing players, and keeping track of its score.

class Team:
    def __init__(self,name,pct2PA,pct3PA): #team name, %of shots 2pt, %of shots 3pt, possessions per game
        self.name = name
        self.players = []
        self.pct2PA = pct2PA
        self.pct3PA = pct3PA

First, I created the Team class, and passed the team name, percentage of shots that are 2-pointers percentage of shots that are three-pointers, and possessions per game in the constructor. (Note: the variables pct2PA and pct3PA are not actually being used. They are for future expansion).

In the constructor the name is passed in and becomes an attribute, as well as the 2-pointer and 3-pointer shooting splits. An empty list is also initialized, which will store the team’s players.

    def addPlayer(self,player):
        self.players.append(player) #should be a player object
    def reset_scores(self):
        for player in self.players:
            player.reset_score()
    def get_scores(self):
        scores = []
        for player in self.players:
            scores.append(player.get_score())
        return scores
    def get_shooting_splits(self):
        splits = []
        for player in self.players:
            splits.append(player.get_splits())
        return splits
    def reset_splits(self):
        for player in self.players:
            player.reset_splits()

This is the rest of the Team class.

  • The function addPlayer() takes a Player object and appends it to the list of players which the Team stores.
  • The functions reset_scores() and get_scores() are very representative of Object-Oriented structure. Instead of the Game or GUI class calling methods of Player, they instead call a function from the class right under it. So, the Game calls function from Team, and the Team calls functions from Player.
  • This is very evident, especially in the last two functions, where each player in the team is iterated over.

Creating the Game logic

picture of grizzlies vs jazz game in the nba bubble
#game logic
class Game:
    def __init__(self,teams,players,numPossessions): #players is the list of ALL PLAYER OBJECTS. 
        self.team1=teams[0]
        self.team2=teams[1]
        self.teams=teams
        
        self.team1Score=0
        self.team2Score=0

        self.players=players
        self.numPossessions=numPossessions
        self.turnsPlayed=0

        self.fgaOrder=[]

A Game takes in 2 Team objects in a list, a list of all player objects that can exist, and the number of possessions(every time a team gets the ball and attempts to score) as parameters. In the constructor, each team is assigned to team1 or team2, and each team’s score is also set to zero.

Furthermore, the list of all players, and the number of possessions are both made into attributes of the Game. An empty list named fgaOrder is also initialized. What is it? It will be used to determine which player will shoot the ball on each turn.

    def add_players_to_teams(self,team1players,team2players):

        for playerName in team1players:
            for playerObj in self.players:
                if playerObj.name == playerName:
                    self.team1.addPlayer(playerObj)
                    break

        for playerName in team2players:
            for playerObj in self.players:
                if playerObj.name == playerName:
                    self.team2.addPlayer(playerObj)
                    break

The first function in Game is add_players_to_teams(). Of course, there can’t be a game without players on each team. This function takes two lists of player names as parameters(not to be confused with Player objects). Then, in each of the for-loops, a list of all Player objects is iterated through, and if there is a match between the given player name and the Player object, then that Player object is added to team1 or team2. (This does make it possible for there to be the same player on two teams. However, in this case the instance of the Player is the same in both lists so that player’s score counts for both teams.)

        #Once teams are know figure out distribution of possessions for players
        playerFGAs={} # <name> : <fga>
        allAttempts=[]
        
        allPlayerNames=team1players+team2players
        totalFGA=0 #FOR BOTH TEAMS
        for playerName in allPlayerNames:
            for player in self.team1.players:
                if player.name == playerName:
                    playerFGAs[player.name] = player.fga
                    totalFGA+=player.fga

            for player in self.team2.players:
                if player.name == playerName:
                    playerFGAs[player.name] = player.fga
                    totalFGA+=player.fga

Now that we have our teams, we can dole out shot attempts to all the players who are playing. The dictionary playerFGAs stores a player’s name as the key and that player’s average shot attempts as the value. It will be used for determining how many shot attempts each player should get, relative to one another. Furthermore, totalFGA is used to count the number of shot attempts that would happen, if you merely added up the attempts for each player. The for-loops iterate over each team’s players to find these values.

        normFactor=self.numPossessions/totalFGA

Now that we know the theoretical number of shot attempts, we can normalize this to the number of shot attempts we want the game to actually have.

Basically, if you had a team of five Michael Jordans, in a vacuum they would take far too many shots for a normal game. In this case, the normFactor would be between 0 and 1 because each Michael Jordan has to take less shots. On the other hand, if you had a team of five benchwarmers, who do not get a lot of playing time, the normalization factor for their shot attempts would be greater than 1, since they would normally not take enough shots.

This is a phenomenon found a lot in real NBA basketball. It’s fairly common to see a team’s #2 option step up their game in the absence of their team’s alpha player.

        for playerName in allPlayerNames:
            normFGA=int(round(normFactor*playerFGAs[playerName]))
            playerFGAs[playerName]=normFGA #filling out the dict: Now it is normalized

        for playerName in allPlayerNames:
            for attemptNumber in range(1,playerFGAs[playerName]):
                allAttempts.append(playerName) 

Now that we have calculated the normalization factor for shot attempts, we first complete the normalization of the shot attempts in the first for-loop.

In the second section, the outer for-loop iterates through the name of each player playing in the game. The inner for-loop appends a player’s name the amount of times they are supposed to have the ball. For example, let’s just say there are 2 players right now, LeBron James and James Harden. The playerFGAs dictionary reads:

{"LeBron James":3, "James Harden":4}

Now, allAttempts, which is basically of who would shoot on each possession, looks like this:

["LeBron James", "LeBron James", "LeBron James", "James Harden", "James Harden", "James Harden", "James Harden"]

According to this, LeBron would take the first three shots, and James Harden would take the last four. But that’s clearly not how it usually works. In order to make this more realistic, we need to shuffle the list:

        self.fgaOrder=random.sample(allAttempts,len(allAttempts))

Wait a minute! This isn’t shuffling! It’s sampling! But, with a closer look, you can see that the sample is the same size as the original list. Thus, this random sample is basically the same thing as shuffling: It randomly picks players until there are none left.

There’s one more edge case we have to consider, though: What if the number of possessions in the game is longer than the fgaOrder we have already determined?

        # if numPossession is more than length of fgaOrder,  add at the end
        for i in range(1 + self.numPossessions - len(self.fgaOrder)):
               randomPlayerName = random.choice(allPlayerNames)
               self.fgaOrder.append(randomPlayerName)

Here we loop the number of times that fgaOrder has to be appended to. Since a for-loop stops 1 before the end of the range we need to add 1. Then, we add a random player to the end of fgaOrder until there are enough field goal attempts.

    def init_game(self):
        self.team1Score=0
        self.team2Score=0
        self.turnsPlayed=0
        self.team1.reset_scores()
        self.team2.reset_scores()
        self.team1.reset_splits()
        self.team2.reset_splits()
        self.team1.players = []
        self.team2.players = []

The function init_game() simply resets the scores and shooting numbers, and empties each teams’ players list so it can be re-populated with 2 new sets of Players.

Next, we move on to the real essence of the Game, which is the logic for playing a turn(also referred to as a possession here, and in basketball lingo):

    def play_turn(self):
        if self.turnsPlayed >= self.numPossessions:
            return False #ERROR

The first thing that we do in play_turn() is check if we have already played enough turns. If we have, then there is no reason to continue and play_turn() will return False, signaling an error.

        playerNameWithBall = self.fgaOrder[self.turnsPlayed]
        for player in self.team1.players + self.team2.players:
            if player.name == playerNameWithBall:
                playerWithBall = player
                break

Now, we first need to figure out which player has the ball. Thus, we first look up the list fgaOrder for the current turn, in order to find out the name of the player who is supposed to shoot. Then, we iterate over each player which is playing in the game. If the names match, we can set a variable, playerWithBall, which is the corresponding Player object.

       turnoutcome = f"{playerWithBall.name} did not score"

The string turnoutcome is the message the user will see in the GUI. By default, we assume that the player with the ball does not score. However, if the player does make a basket this string will be updated.

        position=random.choice(("3","2")) #Goes to 3pt line or 2pt

Here, the player is randomly either going to shoot a 3-pointer or a 2-pointer. Right now, there is a 50/50 chance of getting either, which is reasonably close to how the NBA really operates, however, this can be changed to be a weighted choice.

        if position == "3":  #if they are going to shoot a 3
            make=playerWithBall.shoot3pt(self.turnsPlayed)
            if make == True: #true is make
                #print(f"{playerWithBall.name} made a 3")
                turnoutcome = f"{playerWithBall.name} made a 3"
                if playerWithBall in self.team1.players: 
                    self.team1Score +=3
                    playerWithBall.pointsScored+=3
                    playerWithBall.threePTM += 1
                    playerWithBall.threePTA += 1
                    playerWithBall=random.choice(self.team2.players) #Inbound ball to person on other team
                elif playerWithBall in self.team2.players:
                    self.team2Score +=3
                    playerWithBall.pointsScored+=3
                    playerWithBall.threePTM += 1
                    playerWithBall.threePTA += 1
                    playerWithBall=random.choice(self.team1.players)
                else:
                    #print("Apparently the ball went to the ref or something.")
                    pass
            elif make == False:  #Miss shot
                playerWithBall.threePTA += 1
                playerWithBall=random.choice(self.team1.players+self.team2.players)

Now, this section essentially is simulating the process of shooting a 3-pointer. First, the variable “make”, which is boolean, determines whether the player makes the shot or not. Remember, the function shoot3pt() uses the precomputed list lotsOfShots to find whether or not a player would make the shot on the given turn number. If the Player did make the shot, the turnoutcome string is updated to reflect that. Then, the appropriate team score, player score and shooting splits are updated. Then, since the player made a shot, the ball should be given to a player on the other team to inbound. However, if the player misses, then that player records a three point field goal attempt. The ball is rebounded by any player on the court. However, in practice, there is a higher chance that it will go to the defensive team. (See offensive and defensive rebounding % for advanced statistics on this).

        if position == "2":  #if they are going to shoot a 2
            make=playerWithBall.shoot2pt(self.turnsPlayed)
            if make == True: #true is make
                #print(f"{playerWithBall.name} made a 2")
                turnoutcome = f"{playerWithBall.name} made a 2"
                if playerWithBall in self.team1.players: 
                    self.team1Score +=2
                    playerWithBall.pointsScored+=2
                    playerWithBall.twoPTM += 1
                    playerWithBall.twoPTA += 1
                    playerWithBall=random.choice(self.team2.players)
                elif playerWithBall in self.team2.players:
                    self.team2Score +=2
                    playerWithBall.pointsScored+=2
                    playerWithBall.twoPTM += 1
                    playerWithBall.twoPTA += 1
                    playerWithBall=random.choice(self.team1.players)
                else:
                    #print("Apparently the ball went to the ref or something.")
                    pass
            elif make == False:
                playerWithBall.twoPTA += 1
                playerWithBall=random.choice(self.team1.players+self.team2.players)

The exact same process is repeated if the player with the ball shoots a 2-pointer. (You may have noticed we crossed 500 lines of code in this code snippet. That’s because the line numbers include comments, old code, and whitespace. In reality there are 498 lines of actually functioning code).

        self.turnsPlayed+=1 

        # print(self.team1Score,self.team2Score,turnoutcome)
        return [self.team1Score,self.team2Score,turnoutcome]

Finally, at the end of play_turn(), we increment the amount of turns played by one, and return each team’s score along with the turn’s outcome.

    def get_team1_scores(self):
        return self.team1.get_scores()

    def get_team2_scores(self):
        return self.team2.get_scores()

    def get_team1_splits(self):
        return self.team1.get_shooting_splits()

    def get_team2_splits(self):
        return self.team2.get_shooting_splits()

To wrap up the Game class, we have some getter functions that will be used in conjunction with the GUI class.

    def del_from_team1(self,playerName):
        idx= 0
        for player in self.team1.players:
            if player.name == playerName:
               print("In Team 1 Delete for player " + playerName)
               del self.team1.players[idx]
               break
            idx+=1

    def del_from_team2(self,playerName):
        idx= 0
        for player in self.team2.players:
            if player.name == playerName:
                print("In Team2 Delete for player " + playerName)
                del self.team2.players[idx]
                break
            idx+=1

Finally, both of these setter functions deletes a Player from a Team, given a string which is the player’s name. It does this by iterating through the Players until the one to be deleted is found. And that’s it! Our Game class is complete!

Game over. LeBron James and Danny Green High Five each other. They are wearing special Kobe jerseys in the 2020 NBA Bubble.

The main() function – It’s all about the data

Finally, we have main(), the function Python actually calls when this code is run. As the heading says, a large part of this function is collecting the player’s data. As stated previously, I created a Python script that used Selenium to automatically find player’s data from stats.nba.com. It produces a SQL database as well as a CSV data file. For simplicity, we will be working with the CSV. Here’s what it looks like (If it’s too small on your screen, view it using the download button):

We can see that each column header is a certain type of value, such as age, Paint_FGA, or name. We want to get this data into a bunch of Player objects, so each row will correspond with a Player. Now that we have figured out what to do, let’s see how it’s done.

def main():
    
    #Getting REAL data
    players=[]#list of the objects of every NBA player
    playernames=[] #list of all player names FOR GUI

First, we define the main function, and then define two lists: players and playernames. The former will be passed into a Game object later, and the playernames will be shown in the GUI, so the user can select players to join each team.

    with open("players.csv") as rawData:
        csv_reader=csv.DictReader(rawData,delimiter = ",")
        line_count=0
        for row in csv_reader:
            #print(row["Name"])
            if "-" in row.values():
                continue
            playernames.append(row["Name"].strip())

First, we open a file reader object, named as rawData. Then we create a csv DictReader object which will allow us to easily parse the file, without having to write a lot of complex parsing and logic code. Then, it’s as simple as iterating over every row(remember, that’s every player) in the CSV file. If there is a dash in the row, indicating a blank value, that player is skipped with the continue operator. Then, the player’s name is appended to the playernames list. .strip() is a function which removes any extra whitespace in the front and back of a string.

            p=Player(row["Name"],row["RestrictArea_FGpercent"],row["Paint_FGpercent"],row["MidRange_FGpercent"],
                     row["LeftCorner3_FGpercent"],row["RightCorner3_FGpercent"],row["AboveBreak3_FGpercent"],
                     row["RestrictArea_FGA"],row["Paint_FGA"],row["MidRange_FGA"],row["LeftCorner3_FGA"], 
                     row["RightCorner3_FGA"],row["Totals_FGA"],row["AboveBreak3_FGA"],row["RestrictArea_FGM"],
                     row["Paint_FGM"],row["MidRange_FGM"],row["LeftCorner3_FGM"],row["RightCorner3_FGM"],
                     row["AboveBreak3_FGM"],row["Totals_FGM"])
            players.append(p)

Next, we create a Player object p, and appended it to the list players. Of course, all that parameters that a Player requires is passed into the constructor through the CSV file reader.

    team1name="The Easy Breezies"
    team1_2pct2PA=0.5
    team1_2pct3PA=0.5

    team2name="The Jolly Follies"
    team2_2pct2PA=0.5
    team2_2pct3PA=0.5
    
    poss= 200

Finally, we write the parameters for the teams and game. Try editing the code so that you can change the team name in the GUI!

    team1=Team(team1name,team1_2pct2PA,team1_2pct3PA)
    team2=Team(team2name,team2_2pct2PA,team2_2pct3PA) 
    
    teams=[team1,team2]

    game=Game(teams,players,poss) #initialize a game

    #GUI
    gui=GUI(game)

    gui.add_players(playernames)
    gui.start_gui()# gui starts in end because it takes over 
    



if __name__ == "__main__":
    main()

Finally, we initialize each team, the game, and the GUI. The if __name__ == "__main__" section at the end allows us to know if the file is being run. Otherwise, the main() function will not run, and this code would have to be imported as a library to be called in a separate program.

Wrapping it up

You may have noticed that I haven’t covered the GUI class yet. That’s because a GUI is quite subjective with how the creator wants it to look like. I have actually written this both in Java and Python, and in both, the GUIs are extremely different.

As you can see, even though both of these GUIs accomplish the exact same functions, their visual layout is completely different. There are many design decisions that had to be made in order to deal with the constrains that both Java and Python GUIs allow.

You can find the full code for the Python basketball simulator(Manav’s Basketball Association, or the MBA) on GitHub here. This repo also contains the code for the Web detective which pulls the data.

The Java version contains some extra features such as a year selector. In this repo I’ve prepared data CSV files going from the 2011-12 season to the 2020-21 season. There are also executable JAR files under Releases so you can try it out without compiling. Credit to Dev Kodre for building the GUI for this project.

Thanks for reading! If you have any questions, don’t hesitate to put them in the comments!

Damian Lillard GIF

1 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *