Game of Noughts and Crosses Feedback this is my 2nd project on Python. I created a game of Noughts and Crosses(Tic-Tac-Toe) that you can play against the computer. What are your thoughts on my implementation of the game? Is there anything I've missed or could improve upon?
I did think about adding a figure window and making it more interactive by clicking on where you want to move however that seemed a bit too advanced.
import random    
import sys
import numpy as np

# Today we are going to make an interactive game in Python.
# This is going to be a game of noughts and crosses!

#  Initialise grid and collection of X's or O's needed to win a game.
grid = [[&quot;_&quot;, &quot;_&quot;, &quot;_&quot;], [&quot;_&quot;, &quot;_&quot;, &quot;_&quot;], [&quot;_&quot;, &quot;_&quot;, &quot;_&quot;]]
print(np.asarray(grid))  # To make the output represent a grid instead of a list of lists.
row_os = [&quot;O&quot;, &quot;O&quot;, &quot;O&quot;]
row_xs = [&quot;X&quot;, &quot;X&quot;, &quot;X&quot;]
coords_picked = []

# Create a loop to find all co-ordinates in our grid
all_coords = []
for x in range(3):
    for y in range(3):
        all_coords.append((x, y))

turn = 0
remaining_coords = []

while turn &lt; 10:
    # Find status of each column
    column_1 = [row[0] for row in grid]
    column_2 = [row[1] for row in grid]
    column_3 = [row[2] for row in grid]

    # Find status of 2 diagonals
    main_diag = [row[i] for i, row in enumerate(grid)]
    counter_diag = [row[-i - 1] for i, row in enumerate(grid)]

    # Winning conditions for each player
    if (grid[0] == row_os or grid[1] == row_os or grid[2] == row_os) or (
        column_1 == row_os or column_2 == row_os or column_3 == row_os) or (main_diag == row_os) or (
        counter_diag == row_os):
        print(&quot;The computer has won!&quot;)
        sys.exit()
    elif (grid[0] == row_xs or grid[1] == row_xs or grid[2] == row_xs) or (
        column_1 == row_xs or column_2 == row_xs or column_3 == row_xs) or (main_diag == row_xs) or (
        counter_diag == row_xs):
        print(&quot;Well done, you have won!&quot;)
        sys.exit()
    else:
        # If nobody has won yet, take another turn
        if turn % 2 == 0:
            x_coord = input()
            y_coord = input()
            coords_picked.append((int(x_coord), int(y_coord)))
            grid[int(x_coord)][int(y_coord)] = &quot;X&quot;
            print(np.asarray(grid))
            turn += 1
        else:
            # Computer randomly picks a co-ordinate in the grid that has not been picked yet
            remaining_coords = list(set(all_coords) - set(coords_picked))
            comp_coords = random.choice(remaining_coords)
            grid[comp_coords[0]][comp_coords[1]] = &quot;O&quot;
            coords_picked.append((comp_coords[0], comp_coords[1]))
            print(comp_coords)
            print(np.asarray(grid))
            turn += 1

print(&quot;A draw?! We must play again!&quot;)
I don't use numpy, and I'm getting errors when trying to use it on 3.9, but it seems unnecessary here. If you want to pretty-print a grid, that can be done succinctly using join:
def format_grid(grid):
    return &quot;\n&quot;.join(&quot; &quot;.join(row) for row in grid)

Which, when you replace all uses of asarray, gives output like:
X _ _
_ _ _
_ _ O

I wouldn't import numpy just to help format. That's a heavy dependency for little gain.

These lines:
grid = [[&quot;_&quot;, &quot;_&quot;, &quot;_&quot;], [&quot;_&quot;, &quot;_&quot;, &quot;_&quot;], [&quot;_&quot;, &quot;_&quot;, &quot;_&quot;]]
row_os = [&quot;O&quot;, &quot;O&quot;, &quot;O&quot;]
row_xs = [&quot;X&quot;, &quot;X&quot;, &quot;X&quot;]

Can make use of &quot;sequence multiplication&quot; to reduce the duplication:
SIDE_LENGTH = 3    

grid = [[&quot;_&quot;] * SIDE_LENGTH for _ in range(SIDE_LENGTH)]
row_os = [&quot;O&quot;] * SIDE_LENGTH 
row_xs = [&quot;X&quot;] * SIDE_LENGTH


I'd change all_coords, coords_picked, and remaining_coords to be sets from the start. It isn't efficient to create them as lists, then convert them to sets whenever you want to use them. This unfortunately requires that remaining_coords be converted to a list before being given to random.choice, but that's still less conversions being done overall.
You could probably just get rid of remaining_coords too and just use:
comp_coords = random.choice(list(all_coords - coords_picked))

You have it as a global variable, even though you're recalculating it each time anyways. It would be even more efficient though to not recalculate it each time, and instead make a copy of all_coords at the start, then del elements from the set as they're picked. That's probably an unnecessary level of optimization for here though.

You can clean up the winning checks by making use of any and comprehensions. First, change column_1, column_2, and column_3 to:
columns = [[row[i]
           for row in grid]
           for i in range(SIDE_LENGTH)]

Whenever you're postfixing variable names with numbers, you probably want a list instead.
Next, the conditions themselves:
if any((any(row == row_os for row in grid),
        any(col == row_os for col in columns),
        any(diag == row_os for diag in [main_diag, counter_diag]))):
    print(&quot;The computer has won!&quot;)
    sys.exit()
elif any((any(row == row_xs for row in grid),
          any(col == row_xs for col in columns),
          any(diag == row_xs for diag in [main_diag, counter_diag]))):
    print(&quot;Well done, you have won!&quot;)
    sys.exit()

There's probably a better way of writing this still, but I haven't had my coffee yet. Basically, I'm again using a comprehensions (or rather, generator expressions) to reduce duplication. If you're writing grid[0], grid[1], grid[2] manually, you likely want to iterate over grid instead.
You could also wrap that condition up in a function, then use the function in both places since each condition is basically the same.
The inner anys could also be replaced with unpacking to make use of the outer any:
if any((*(row == row_os for row in grid),
        *(col == row_os for col in columns),
        *(diag == row_os for diag in [main_diag, counter_diag]))):

But I'm not sure that that's much better.

You should add in error handling. When testing this, I got multiple errors. I accidentally entered 3 in, and got an IndexError, and accidentally pressed enter without entering a number and got a ValueError. Really, the user entering bad input should be expected and handled.


At the end, I have:
import random
import sys

SIDE_LENGTH = 3


def format_grid(grid):
    return &quot;\n&quot;.join(&quot; &quot;.join(row) for row in grid)


grid = [[&quot;_&quot;] * SIDE_LENGTH for _ in range(SIDE_LENGTH)]
row_os = [&quot;O&quot;] * SIDE_LENGTH
row_xs = [&quot;X&quot;] * SIDE_LENGTH
coords_picked = set()

print(format_grid(grid))  # To make the output represent a grid instead of a list of lists.

# Create a loop to find all co-ordinates in our grid
all_coords = set()
for x in range(SIDE_LENGTH):
    for y in range(SIDE_LENGTH):
        all_coords.add((x, y))

turn = 0
remaining_coords = set()

while turn &lt; 10:
    # Find status of each column
    columns = [[row[i]
               for row in grid]
               for i in range(SIDE_LENGTH)]

    # Find status of 2 diagonals
    main_diag = [row[i] for i, row in enumerate(grid)]
    counter_diag = [row[-i - 1] for i, row in enumerate(grid)]

    # Winning conditions for each player
    if any((any(row == row_os for row in grid),
            any(col == row_os for col in columns),
            any(diag == row_os for diag in [main_diag, counter_diag]))):
        print(&quot;The computer has won!&quot;)
        sys.exit()
    elif any((any(row == row_xs for row in grid),
            any(col == row_xs for col in columns),
            any(diag == row_xs for diag in [main_diag, counter_diag]))):
        print(&quot;Well done, you have won!&quot;)
        sys.exit()
    else:
        # If nobody has won yet, take another turn
        if turn % 2 == 0:
            x_coord = input()
            y_coord = input()
            coords_picked.add((int(x_coord), int(y_coord)))
            grid[int(x_coord)][int(y_coord)] = &quot;X&quot;
            print(format_grid(grid))
            turn += 1
        else:
            # Computer randomly picks a co-ordinate in the grid that has not been picked yet
            remaining_coords = all_coords - coords_picked
            comp_coords = random.choice(list(remaining_coords))
            grid[comp_coords[0]][comp_coords[1]] = &quot;O&quot;
            coords_picked.add((comp_coords[0], comp_coords[1]))
            print(comp_coords)
            print(format_grid(grid))
            turn += 1

print(&quot;A draw?! We must play again!&quot;)