Skip to content

Instantly share code, notes, and snippets.

@taitep
Created July 30, 2024 11:17
Show Gist options
  • Save taitep/874823d7bfb7038e1efa64940613fdf4 to your computer and use it in GitHub Desktop.
Save taitep/874823d7bfb7038e1efa64940613fdf4 to your computer and use it in GitHub Desktop.
Protocol for tic-tac-tcp

Tic-tac-tcp

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.

Table of Contents

Game modes

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.

Protocol

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.

Packets

C2S

  • Error: 0xEE = type:byte, data:tree(type) data:
    • UnexpectedPacket: 0x00 = packet_identifier
    • InvalidCoordinate: 0x01 (no extra data)
  • 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)

S2C

  • 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)
  • 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

Types

  • 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 like 0b00011010. 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: If draw is true the game was a draw, otherwise winner won. if forefit 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.

Board

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.

Ordering

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

License

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment