Tic-tac-tcp is my protocol for tic-tac-toe over TCP.
This project is under the MIT license. Please click here to get the full license text.
The default port for the server should be 7777
, but both server and client should also support using other ports.
Tic-tac-tcp has two gamemodes: standard and competition. It is detailed how a client says what mode to play in the protocol.
Normal mode is a regular game of tic-tac-toe. The board starts empty and there is one match. The rules for determining who is X and who is O can be determined by the server.
In competition mode there is a score system. The total number of games in a competition is 8. Four initial positions are played: empty, middle, corner and edge. For each starting position each player gets to play X (go first) once. This results in a total of 8 matches. The X player will be forced to play the first move of the start position instead of taking their own turn. This means that the first player to take an actual turn will be O. The server may put the games in any order. The games starting with an empty board are worth one extra point if won by O. If the game is a draw the game is worth zero points. Most points at the end of the competition wins.
The tic-tac-tcp protocol is based on a binary tcp connection,
A packet has two parts. The packet type (one byte) followed by the packet data. The amount of data parsed is dynamic even for the same packet type in some situations. Packet types are separate between C2S (client-to-server) and S2C (server-to-client). This means that there can be one packet type 0x0A
for C2S and one 0x0A
for S2C that can mean entirely different things.
Each packet type can define what data is part of the packet data, how much place that data takes and in what order it comes. The data is written as types, which along with packets are listed how they should be interpreted. Some types, such as the S2C and C2S error data types can just like a packet have one part be a one-byte identifier for the exact type and then be built of different types depending on that identifier. Both when it comes to determining what packet type to read and for other sub-types any byte not assigned to a such type should be treated as invalid. This means that parsing this protocol should be doable with most general parsing algorithms that parse to an AST for a programming language, such as recursive descent.
- Error:
0xEE
=type:byte, data:tree(type)
data:- UnexpectedPacket:
0x00
=packet_identifier
- InvalidCoordinate:
0x01
(no extra data)
- UnexpectedPacket:
- ClientDisconnect:
0xDD
(no extra data) - GameRequest:
0x01
=name:String, competition:Bool
(if competition flag is set, the player wants to play competition mode) - MakeMove:
0x02
=position:Coordinate
- Forefit:
0x03
(no extra data)
- Error:
0xEE
=type:byte, data:tree(type)
data:- UnexpectedPacket:
0x00
=packet_identifier
- InvalidCoordinate:
0x01
(no extra data) - GameCapReached:
0x80
(no extra data) - OpponentDisconnected:
0x81
(no extra data) - CompetitionUnsupported:
0x82
(no extra data)
- UnexpectedPacket:
- ServerShutdown:
0xDD
(no extra data) - PlayerAccept:
0x00
(no extra data) - OpponentFound:
0x01
=name:String
- InitState:
0x03
=initial:Board
(initial is the initial board state, used in competitive mode. not needed in standard mode.) - PlayerSymbol:
0x04
=symbol:Player
(tells the player if they will be playing X or O for the upcoming game.) - YourTurn:
0x05
=opponent_moved:Bool, opponent_move:Coordinate
(tells you its your turn and what move the opponent made. opponent_moved being false means that the opponent did not make a move since your last turn or the game start, and opponent_move does not matter.) - IllegalMove:
0x06
(no extra data) - GameOver:
0x07
=result:Result, ending_state:Board
- CompetitionOver:
0x08
=score:Score, opponent_score:Score
- String: 0-delimited utf-8 string. Any number of bytes ended by a zero.
- Bool: Single bit. 8 bools can be grouped in a single byte. If less bools then 8 are next to each other, pad byte by adding zeros on the left. This means that
True True False True False
would look like0b00011010
. Other types less than a byte can also be added to this byte along with bools, but they have to be aligned to their size. - Coordinate: 2 2-bit numbers. First one is X, second is Y. Each value can go from 0-2 representing coordinates on the game board. X coordinates go from left to right, Y values go from up to down. Send
Error(InvalidCoordinate)
if either X or Y of coordinate you receive is 3. - Player: A bool where false means player X and true means player O
- Board: See description below.
- Result =
draw:bool, forefit:bool, winner:Player
: Ifdraw
is true the game was a draw, otherwisewinner
won. ifforefit
is true the winner won by the opponent giving up (sending Forefit as their move). - Score: 4-bit integer representing a players score in a competition.
9 pairs of one bool and one Player. Each pair is a square. If the bool is false, the square is empty. If the bool is true the square is taken by the Player. Order is like this:
|0|1|2|
|3|4|5|
|6|7|8|
The total size of a board is 18 bits. It must start aligned to a byte and the extra 2 bits at the end can optionally be in a bool cluster.
Beyond what packets and types exist and how they are transmitted, knowing when these packets should be sent is of course neccesary. I have split up the defenitions into functions in a form of pseudocode language. Each function will take the clients that it interacts with where you would normally see arguments (some of these functions also do take regular arguments). The pseudocode uses a variation of python syntax/stdlib with some more compact syntax for just sending packets.
# Both client and server should handle error packets by simply closing the connection
# If the client or the server receives a packet that is not supposed to be there according to the following protocol, send an Error(UnexpectedPacket(identifier)) where identifier is the binary representation of the received packet type. After that close the connection.
# If the client or server receives an invalid (out-of-bounds) coordinate in a situation where the coordinate matters, send an Error(InvalidCoordinate) and close the connection.
# If the client wants to disconnect, it sends ClientDisconnect() to the server, which then sends an Error(OpponentDisconnected) to the clients opponent (if it has one) and closes the match.
# When the server shuts down it has to send ServerShutdown to all clients.
# If a client asks for competition mode on a server that does not support it, the server sends Error(CompetitionUnsupported) and closes the connection.
# When a client first connects. Collects client info.
def client_connect(C):
# Player sends their GameRequest with player name and gamemode info
game_request = (C -> S: GameRequest)
# Server checks if the game queue (or maybe simply an onging game list) is full
if server.queue(game_request.game_mode).full:
# Send error if it is full
S -> C: Error(GameCapReached)
else:
# Accept player and add to queue or similar if it isnt
S -> C: PlayerAccept()
server.queue(game_request.game_mode).add(C, game_request)
# When two clients have connected and said they wanted to play standard mode.
def start_standard((C1, p1_info), (C2, p2_info)):
# Send opponent name to both players
S -> C1: OpponentFound(p2_info.name)
S -> C2: OpponentFound(p1_info.name)
# Decide who plays X and who plays O
(server):
client1_symbol = random.choice([Player.X, Player.O]) # Randomization not required, you could for example always have player one play as X
client2_symbol = not client1_symbol
# Send info about what symbol the players are playing
S -> C1: PlayerSymbol(client1_symbol)
S -> C2: PlayerSymbol(client2_symbol)
if client1_symbol == player.X:
X = C1
O = C2
else:
X = C2
O = C1
run_single_game(X, O, Board.empty())
# When two clients who want to play competition have been found
def start_comp((C1, p1_info), (C2, p2_info)):
# Send opponent name to both players
S -> C1: OpponentFound(p2_info.name)
S -> C2: OpponentFound(p1_info.name)
# Initialize score counting
c1_score = 0
c2_score = 0
# Loop through the different starting positions used in competetive
start_positions = [Board.empty(), Board.single_filled(middle), Board.single_filled(corner), Board.single_filled(edge)]
for position in start_positions:
_, winner_1 = run_single_game(C1, C2, position, is_P1_O=position != Board.empty())
_, winner_2 = run_single_game(C2, C1, position, is_P1_O=position != Board.empty())
# Calculate scores
if position == Board.empty():
if winner_1 == Player.X:
c1_score += 1
elif winner_1 == Player.O:
c2_score += 2
if winner_2 == Player.X:
c2_score += 1
elif winner_2 == Player.O:
c1_score += 2
else:
if winner_1 == Player.X:
c1_score += 1
elif winner_1 == Player.O:
c2_score += 1
if winner_2 == Player.X:
c2_score += 1
elif winner_2 == Player.O:
c1_score += 1
# Send score info to players
S -> C1: CompetitionOver(c1_score, c2_score)
S -> C2: CompetitionOver(c2_score, c1_score)
def run_single_game(P1, P2, board: Board, is_P1_O=False):
p1_player = Player.X
p2_player = Player.O
if is_P1_O:
p1_player = Player.O
p2_player = Player.X
# Game loop
forefit = False
move_has_been_made = False
last_move = None
winner = None
while not board.game_over:
# Notify player one its their turn
S -> P1: YourTurn(move_has_been_made, last_move)
# Make sure player one does not play an illegal move
decision = None
while board.is_illegal(decision)
# Player one sends their decision to either make a move or forefit
decision = (P1 -> S: MakeMove|Forefit)
# Handle forefit
if decision.type == Forefit:
forefit = True
winner = p1_player
break
# Make sure move sent isnt illegal
if board.is_illegal(decision): S -> P1: IllegalMove()
move_has_been_made = True
last_move = decision.position
board.perform(decision, p1_player)
if board.game_over: break
# Notify player two its their turn
S -> P2: YourTurn(True, last_move)
decision = None
while board.is_illegal(decision)
# Player two sends a decision to either make a move or forefit
decision = (P2 -> S: MakeMove|Forefit)
# Handle forefit
if decision.type == Forefit:
forefit = True
winner = p2_player
break
# Make sure move sent isnt illegal
if board.is_illegal(decision): S -> P2: IllegalMove()
last_move = decision.position
board.perform(decision, p2_player)
if forefit:
S -> (P1, P2): GameOver(Reason(False, forefit, winner), board)
else:
draw, winner = board.get_result()
S -> (P1, P2): GameOver(Reason(draw, False, winner), board)
return draw, winner
Copyright 2024 taitep [email protected]
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.