[Python] 8. Network Multiplay Tic-Tac-Toe Game Ver.8 (with socket!)
Step-by-Step Breakdown of the Multiplayer Tic-Tac-Toe Server
In this enhanced version of our Tic-Tac-Toe game, we’re building a server that can manage multiple games between human players over the network. To achieve this, we rely on Python’s socket
and threading
libraries.
Introduction to socket
and threading
in Python
Before we begin creating a networked Tic-Tac-Toe game, it’s important to understand two core Python libraries that make networking and multithreading possible: socket
and threading
. These libraries are essential for building client-server applications that can handle multiple connections simultaneously.
1. Python socket
Programming
The socket
module in Python provides a way to create connections between two machines, allowing them to communicate over a network. Sockets are the backbone of any network communication, including client-server architectures like the one we’ll be using.
Basic Concepts:
- Server: Listens for incoming client connections and manages communication.
- Client: Connects to a server and interacts with it by sending and receiving data.
Socket Types:
socket.AF_INET
: Refers to the address family for IPv4 addresses (e.g., 127.0.0.1).socket.SOCK_STREAM
: Specifies that we are using TCP (Transmission Control Protocol), which ensures reliable and ordered communication between the server and client.
Basic Socket Operations
Let’s look at how to create a simple server and client using the socket
module.
Server Example
import socket
def start_server():
# Create a TCP/IP socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Bind the socket to a public host, and a specific port
server_socket.bind(('localhost', 12345))
# Listen for incoming connections
server_socket.listen(5) # The argument specifies the maximum number of queued connections
print("Server is listening on port 12345...")
# Wait for a connection
client_socket, address = server_socket.accept()
print(f"Connection from {address} has been established!")
# Send a message to the client
client_socket.send(bytes("Hello from the server!", "utf-8"))
# Close the connection
client_socket.close()
if __name__ == "__main__":
start_server()
Explanation:
socket.socket()
: Creates a new socket object. The parameters specify the use of IPv4 (AF_INET
) and TCP (SOCK_STREAM
).bind()
: Binds the socket to the localhost (127.0.0.1
) on port12345
.listen()
: Tells the server to listen for incoming connections. The argument (5
) specifies the maximum number of queued connections.accept()
: Waits for a client to connect. When a client connects, it returns a new socket object (client_socket
) and the address of the client.send()
: Sends data to the client over the socket.
Client Example
import socket
def start_client():
# Create a TCP/IP socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Connect to the server
client_socket.connect(('localhost', 12345))
# Receive data from the server
message = client_socket.recv(1024) # Buffer size of 1024 bytes
print(message.decode("utf-8"))
# Close the connection
client_socket.close()
if __name__ == "__main__":
start_client()
Explanation:
connect()
: Connects to the server at the specified IP address and port (localhost
,12345
).recv()
: Receives data from the server. The argument specifies the buffer size (i.e., the maximum number of bytes to read at once).close()
: Closes the socket connection.
Running the Server and Client
- Run the server program first.
- Then, run the client program to connect to the server.
- The client will receive the message from the server, and the connection will close.
2. Python threading
Programming
The threading
module allows us to run multiple threads (smaller units of a process) concurrently. This is especially useful for networked applications where we want to handle multiple clients at the same time.
Why Use threading
?
In networked applications, the server needs to handle multiple client connections at once. If the server only handled one client at a time, it would be inefficient because it would block other clients from connecting until the current client disconnects. With threading
, we can spawn a new thread for each client, allowing them to interact with the server simultaneously.
Basic Example of threading
Here’s a simple example that shows how to use threading
to run multiple tasks at the same time:
import threading
import time
def print_numbers():
for i in range(5):
time.sleep(1)
print(f"Number: {i}")
def print_letters():
for letter in "abcde":
time.sleep(1.5)
print(f"Letter: {letter}")
# Create two threads
thread1 = threading.Thread(target=print_numbers)
thread2 = threading.Thread(target=print_letters)
# Start the threads
thread1.start()
thread2.start()
# Wait for both threads to complete
thread1.join()
thread2.join()
print("Both threads have finished.")
Explanation:
threading.Thread()
: Creates a new thread. Thetarget
parameter specifies the function to run in that thread.start()
: Starts the execution of the thread.join()
: Waits for the thread to finish before moving on to the next line of code.
In this example, print_numbers()
and print_letters()
are executed concurrently in separate threads.
3. Combining socket
and threading
Now that we understand the basics of both socket
and threading
, we can combine these concepts to create a server that can handle multiple clients simultaneously. Each time a new client connects, we’ll create a new thread to handle that client, allowing the server to communicate with multiple clients concurrently.
Multithreaded Server Example
import socket
import threading
def handle_client(client_socket):
client_socket.send(bytes("Hello, Client!", "utf-8"))
while True:
message = client_socket.recv(1024).decode("utf-8")
if not message:
break
print(f"Received from client: {message}")
client_socket.send(bytes(f"Echo: {message}", "utf-8"))
client_socket.close()
def start_server():
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('localhost', 12345))
server_socket.listen(5)
print("Server is listening on port 12345...")
while True:
client_socket, address = server_socket.accept()
print(f"Connection established with {address}")
# Create a new thread for each client
client_thread = threading.Thread(target=handle_client, args=(client_socket,))
client_thread.start()
if __name__ == "__main__":
start_server()
Explanation:
handle_client()
: This function runs in a separate thread for each client. It listens for messages from the client, echoes them back, and closes the connection when the client disconnects.start_server()
: The main server function that listens for connections and starts a new thread for each client.
4. Multiplayer Tic Tac Toe Game
You can now move on to the next section, where we will apply the concepts of socket
and threading
to build a fully functioning multiplayer Tic-Tac-Toe game!
Classes Overview:
Board
Class:- Manages the game board’s state and provides methods to update the board, check for a winner, and check for a draw.
GameRoom
Class:- Manages a single game session between two players, sending board updates and processing moves.
TicTacToeServer
Class:- Manages incoming player connections and pairs them into game rooms. It handles multiple clients by creating separate threads for each game room.
Detailed Class Explanations
1. Board
Class
This class is responsible for maintaining and updating the state of the Tic-Tac-Toe board. It provides methods for displaying the board, updating it when a player makes a move, and checking for a winner or a draw.
class Board:
def __init__(self):
self.board = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
__init__()
: Initializes the board as a list of strings, where each element is a position (from “1” to “9”). These positions will be replaced with “X” or “O” when a player makes a move.
def display(self):
return f"\n{self.board[0]} | {self.board[1]} | {self.board[2]}\n--+---+--\n{self.board[3]} | {self.board[4]} | {self.board[5]}\n--+---+--\n{self.board[6]} | {self.board[7]} | {self.board[8]}"
display()
: This method returns the current state of the board as a formatted string, making it easy for both players to see the game’s current progress.
def update(self, position, marker):
self.board[position] = marker
update()
: Updates the board at a specific position (0 to 8) with either “X” or “O”. This is called when a player makes a move.
def is_winner(self, marker):
winning_combinations = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6]
]
for combo in winning_combinations:
if self.board[combo[0]] == self.board[combo[1]] == self.board[combo[2]] == marker:
return True
return False
is_winner()
: This method checks whether the current player has won. It compares all possible winning combinations (rows, columns, and diagonals) to see if any contain the same marker (either “X” or “O”).
def is_draw(self):
return all(spot in ["X", "O"] for spot in self.board)
is_draw()
: Checks if the board is full (i.e., all positions are either “X” or “O”). If the board is full and there’s no winner, the game is declared a draw.
2. GameRoom
Class
The GameRoom
class is where the actual game between two players takes place. This class handles game logic, communication between players, and the flow of the game (turns, checking for a winner, etc.).
class GameRoom:
def __init__(self, player1_socket, player2_socket):
self.board = Board()
self.players = [(player1_socket, "X"), (player2_socket, "O")]
self.current_player = 0
-
__init__()
: Initializes a new game room with two players. Theplayers
list stores each player’s socket and their respective marker (“X” or “O”). Thecurrent_player
is used to track whose turn it is. -
Player Sockets: Each player has a socket connection. This allows the server to send and receive data (like moves) from the client.
def send_board_to_players(self):
board_state = self.board.display()
for player_socket, _ in self.players:
player_socket.send(bytes(board_state, "utf-8"))
send_board_to_players()
: Sends the current state of the board to both players. The board is displayed after each move to keep both players updated.
def handle_game(self):
game_over = False
while not game_over:
current_socket, current_marker = self.players[self.current_player]
opponent_socket, _ = self.players[(self.current_player + 1) % 2]
self.send_board_to_players()
current_socket.send(bytes("Your move: ", "utf-8"))
move = current_socket.recv(1024).decode("utf-8")
handle_game()
: This is the core of the game. It runs in a loop until the game is over. It alternates between players, prompting each player for their move and processing it.- Player Turns: The current player is prompted to make a move using
recv()
, which listens for data from the player’s socket. The move is then processed, and the board is updated.
if move.isdigit() and int(move) in range(1, 10):
move = int(move) - 1
if self.board.board[move] not in ["X", "O"]:
self.board.update(move, current_marker)
if self.board.is_winner(current_marker):
self.send_board_to_players()
current_socket.send(bytes(f"Player {current_marker} wins!", "utf-8"))
opponent_socket.send(bytes(f"Player {current_marker} wins!", "utf-8"))
game_over = True
elif self.board.is_draw():
self.send_board_to_players()
current_socket.send(bytes("It's a draw!", "utf-8"))
opponent_socket.send(bytes("It's a draw!", "utf-8"))
game_over = True
else:
self.current_player = (self.current_player + 1) % 2
else:
current_socket.send(bytes("Invalid move. Try again.", "utf-8"))
else:
current_socket.send(bytes("Invalid move. Try again.", "utf-8"))
- Move Validation: It checks if the move is valid (i.e., it’s a number between 1 and 9, and the position hasn’t already been taken). If valid, the board is updated.
- Winner or Draw: After each move, it checks whether the player has won or if the game has ended in a draw. If so, both players are informed, and the game ends.
- Turn Switching: If the game isn’t over, the turn switches to the next player using
self.current_player = (self.current_player + 1) % 2
.
3. TicTacToeServer
Class
The TicTacToeServer
class manages incoming player connections and pairs them up for games. Once two players are connected, they are placed into a game room to play against each other.
class TicTacToeServer:
def __init__(self):
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server_socket.bind(('localhost', 12345))
self.server_socket.listen(10) # Allow multiple clients to connect
self.waiting_player = None # Keep track of waiting players
__init__()
: Initializes the server, binds it tolocalhost
on port12345
, and starts listening for connections. The server can handle up to 10 clients simultaneously. Thewaiting_player
variable stores a player who is waiting for an opponent.
def handle_client(self, client_socket):
if self.waiting_player:
print("Pairing players to start a game...")
game_room = GameRoom(self.waiting_player, client_socket)
threading.Thread(target=game_room.handle_game).start()
self.waiting_player = None # Reset waiting player
else:
print("Waiting for another player to join...")
self.waiting_player = client_socket
handle_client()
: This method is responsible for pairing players. When a client connects, if there’s already a player waiting, they are paired up and placed into aGameRoom
. A new thread is started to handle the game in that room. If no players are waiting, the current player is stored inwaiting_player
until another player joins.
def start(self):
print("Server is running and waiting for players...")
while True:
client_socket, address = self.server_socket.accept()
print(f"Player
connected from {address}")
threading.Thread(target=self.handle_client, args=(client_socket,)).start()
start()
: The main server loop waits for player connections usingaccept()
. When a player connects, a new thread is created to handle that player usinghandle_client()
.
Step 3: Updating the Client
The client code allows a player to connect to the server, wait for an opponent, and play the game. The client receives the current state of the board, makes a move, and sends that move back to the server.
import socket
class TicTacToeClient:
def __init__(self):
self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.client_socket.connect(('localhost', 12345))
def start(self):
while True:
board_state = self.client_socket.recv(1024).decode("utf-8")
print(board_state)
if "wins" in board_state or "draw" in board_state:
break
move = input("Enter your move (1-9): ")
self.client_socket.send(bytes(move, "utf-8"))
if __name__ == "__main__":
client = TicTacToeClient()
client.start()
Client Explanation:
- Receiving Board State: The client receives updates about the game board from the server using
recv()
, which allows the player to see the current state of the game. - Sending Moves: The player inputs a move, which is then sent to the server via
send()
for validation and processing.
Conclusion
We’ve now expanded the Tic-Tac-Toe game server to support multiple games at once, allowing any number of players to connect, be paired up, and play games simultaneously. Each game is handled in its own GameRoom
thread, and the server manages all connections efficiently. This architecture is highly scalable and can be further expanded with more features like game statistics, matchmaking, or even AI players mixed into the network!
By implementing socket programming and threading, you’ve built a robust multiplayer game server. Keep experimenting and building on this foundation for more advanced projects!
Happy coding! 😄