Alan Yorinks
Alan Yorinks
19 min read

Categories

  • python-Banyan
  • arcade

Tags

  • gaming
  • p2p

I recently read a RealPython article about Paul Craven’s Arcade gaming library for Python. After examining the Arcade examples, I was impressed by Arcade’s consistency and ease of use.

Want to quickly get up to speed with Arcade? Check out this 12 part YouTube Arcade tutorial.

The Arcade library was designed to create 2D games to be run on a single computer, and it accomplishes that goal brilliantly. That being said, having run an after-school Scratch club for a local middle school, one of the things my students always wished for was the ability to create multi-player networked games. Sadly, Scratch does not support this, and the students were left disappointed. Thinking of my students, I decided to see if I could use Arcade to create multi-player games. And thus, this multi-player proof of concept began.

In this posting, I will discuss how to create a multi-player game using the Python Arcade gaming library in conjunction with the Python Banyan framework. Banyan augments Arcade by providing the networking and messaging support required for multi-player games.

What will be covered:

  • A short discussion of multi-player software architectures.
  • The self-imposed requirements and goals of the proof-of-concept presented in this posting.
  • How I selected a game to demonstrate the concepts.
  • How to install and run the demo code.
  • Getting into the details:
    • A quick introduction to Python Banyan.
    • Looking at how the code was assembled.

Multiplayer Support Schemes - Client/Server vs. Peer To Peer

Multiplayer games generally are implemented using one of 2 schemes, client/server being one, and peer to peer(p2p) being the other.

With the client/server model, the players connect to a central server. The server receives the individual player’s interaction events (keyboard presses, mouse moves, joystick motion, etc.), updates the game based on the events received, and then generates and sends screen updates to the individual players.

Pros

  • Relatively easy to implement.
  • Cheating is averted since the server monitors all events

Cons

  • It requires a separate server or server process.

With peer to peer (p2p), there is no central server. As each player generates screen events, the local game instance sends screen update requests to each of the players.

Pros

  • No central server required. Players communicate directly with each other.

Cons

  • More difficult to implement than the client/server model.
  • Potential for game cheating since no central authority is in control.

Goals and Requirements Of The Project

Taking the road less traveled, I decided to explore using the p2p model. Here are the requirements I set for myself:

  • The game is intended to be confined to a single LAN. Restricting support to within a single LAN avoids security issues and simplifies routing.
  • The game is to consist of a single Python script run as two separate processes on a single computer, or on two different computers.
  • Player role selection is accomplished using a command-line option.
  • Linux, macOS, and Windows are to be supported.
  • As each player generates game events, screen updates must be fast and in sync with the other player.
  • Compatible with Python 3.7 and above.

The Proof Of Concept

Finding A Game Candidate

Realizing that I am not the best at designing computer games, I wanted to find an existing game that I could modify to illustrate my proof of concept ideas. Turning to the Arcade examples, I chose the Collect Coins that are Bouncing example

But Isn’t That A One-Player Game?

When first looking at the game, it appears that there is only one player. The goal of the game is for the player, using the mouse, to collect all of the randomly bouncing coins. When the female sprite touches a coin, the coin is removed from play, and the score is incremented by 1.

This looks like a single-player game, but what if we viewed the game in a slightly different way. What if the female sprite, which we call player 1, was controlled on one computer, and the coins and their random motion on another computer. We can then consider the coins as player 0. In this way, we have a two-player game with one player, player 1 being human-controlled, and the other player, player 0, being computer-controlled.

Each of the players would need to have a copy of the mouse-controlled player sprite, the 50 coin sprites, and game score text. Each player is responsible for updating their own domain and notifying the peer of these updates. Both player’s screens need to be updated in unison in near real-time!

A Video Demo Of The P2P Game

Before learning how all this works, it may be beneficial to see an actual demo of the proof of concept. The demo was run on a Linux computer with an AMD 3700x processor for one of the peers, and on a Windows 10 laptop using an Intel i7-6600q processor for the second peer. Both computers are running RealVNC to allow for screen-capture of both machines.

The left-hand side of the screen is the Linux computer, and it controls the coins. The right-hand side is the Windows computer, and it controls the female collection sprite.

NOTE: Swapping the players on the computers does not affect performance.

What You Will See

When each instance of the game starts, it generates its own copy of the female collection sprite and the 50 coin sprites. The collection sprite is initially placed at the same screen location for both players. The coins for each player, are initially placed randomly on each screen. Therefore at this time, the coin placements differ for each of the players. This is shown below.

When the left mouse button is clicked, the coins will go into motion. Notice that once that happens, the positions of the coins are aligned on both screens.

After the right mouse button is clicked to enable coin collection when the female sprite touches a coin sprite, the coin is removed, and the score is increased by one.

To view a screen capture of the demo, click on the screen-shot below.

Video Title

Running The Demo For Yourself

Install The Demo

To install the code, open a terminal window. For Linux and macOS type:

sudo pip3 install p2p-arcade

For Windows type:

pip install p2p-arcade

In addition to all the necessary libraries, a command-line executable, p2pa is installed.

If you wish to run the demo using two computers, repeat the steps above on the second computer.

IMPORTANT NOTE: Arcade does not run on a Raspberry Pi because it requires OpenGL 3.3, which is not yet available for the RPi.

Running The Demo On One Computer

If you wish to try the demo on a single computer, open a terminal window and type:

p2pa

This will start the player 0 window.

Next, open another window, but this time type:

p2pa -p 1

The -p command-line option specifies that this instance is to be run as player 1.

Now click the left mouse button to start the coins in motion and then click the right mouse button and collect the coins.

Running The Demo On Two Computers

On the first computer, open a terminal window and type:

p2pa

In the terminal window you will see a Python Banyan informational heading that looks similar to that below:

************************************************************
Arcade p2p player0 using Back Plane IP address: 192.168.2.191
Subscriber Port = 43125
Publisher  Port = 43124
Loop Time = 0.0001 seconds
************************************************************

The value for the IP address of your computer will most likely be different than the one shown above, so jot down the Back Plane IP address displayed.

Now on the second computer, open a terminal window type in a command similar to the following, substituting the IP address you jotted down.

p2pa -p 1 -b 192.168.2.191

The -p command-line option specifies this instance as a player 1 instance, and the -b option specifies the IP address of the active Banyan Backplane.

The Banyan Backplane routes all of the messages for you without having to set up any routing tables.

When you started player 0 on the first computer, the Backplane was automatically invoked. The -b command-line option is used to connect both peers to a common Backplane.

Click the left mouse button, followed by a click of the right mouse button to start collecting coins.

So How Does This All Work?

While reading this section, if you wish to view the source code for the original game example, click on this link. To view the source code for p2p-arcade.py
click here.

Defining And Implementing Game Behavior In Arcade

If you’ve read the RealPython article and/or have watched the videos, you already know that the arcade.Window class provides several methods to capture hardware events, such as mouse or keyboard interaction.

    def on_mouse_motion(self, x, y, dx, dy):
        """ Handle Mouse Motion """

        # Move the center of the player sprite to match the mouse x, y
        self.player_sprite.center_x = x
        self.player_sprite.center_y = y

The arcade.Window class also has an on_update method allowing you to control screen updates with each iteration of the event-loop. This is shown below.

   def on_update(self, delta_time):
        """ Movement and game logic """

        # Call update on all sprites (The sprites don't do much in this
        # example though.)
        self.all_sprites_list.update()

        # Generate a list of all sprites that collided with the player.
        hit_list = arcade.check_for_collision_with_list(self.player_sprite,
                                                        self.coin_list)

        # Loop through each colliding sprite, remove it, and add to the score.
        for coin in hit_list:
            coin.remove_from_sprite_lists()
            self.score += 1

The on_update method calls the update method within the all_sprites_list. The all_sprites_list iterates through its list of sprites calling each sprite’s update method.

class Coin(arcade.Sprite):

    def __init__(self, filename, sprite_scaling):

        super().__init__(filename, sprite_scaling)

        self.change_x = 0
        self.change_y = 0

    def update(self):

        # Move the coin
        self.center_x += self.change_x
        self.center_y += self.change_y

        # If we are out-of-bounds, then 'bounce'
        if self.left < 0:
            self.change_x *= -1

        if self.right > SCREEN_WIDTH:
            self.change_x *= -1

        if self.bottom < 0:
            self.change_y *= -1

        if self.top > SCREEN_HEIGHT:
            self.change_y *= -1

Hopefully, I did not lose you with the discussion of nested-updates. But in case I did, here is a diagram to illustrate update sequencing.

Adding Multiplayer Functionality

To implement a multi-player game, we need a way for each player to capture both hardware events and screen update information. Each player then must notify its peer of these changes.

To provide a vehicle for player update notifications, we will be using the Python Banyan Framework.

Understanding The Modified Code

So to understand the p2p_arcade.py code, we will:

  • Discuss the basics of Python Banyan
  • Show how the MyGame class is extended to incorporate Python Banyan and the Python threading class.
    • Threading is used to provide concurrency between the Arcade and Banyan event loops.
  • Explain how the data is captured.
  • Show how the captured data is sent, received, and processed by both players.

What Is Python Banyan?

The Python Banyan Framework is a lightweight, reactive framework used to create flexible, non-blocking, event-driven, asynchronous applications. Quite a mouthful, but it very easy to use and has a straightforward API.

Banyan uses a publish/subscribe (pub/sub) messaging model. We will discuss pub/sub in more detail in the sections that follow.

For Those Familiar With MQTT Pub/Sub

Python Banyan, in some ways, is similar to MQTT but has much better performance.

Similar to MQTT’s broker, Python Banyan uses an entity called the Backplane. For the p2p_arcade demo, the Backplane is automatically started for you.

Banyan Components

Banyan entities, known as Banyan Components, communicate with each other by connecting to a common Banyan Backplane. The Backplane is a central point of contact for all the Banyan Components. It simplifies message routing in that all Banyan Components use shared IP Addresses and IP ports for publishing and subscribing to messages.

A Banyan Component is an independent process but is a member of a set of Components that comprise a Banyan Application.

Banyan Applications

The player 0 instance of p2pa runs as one Banyan Component, and the player 1 instance of p2pa is run as a second Banyan Component. Together, both components create a single Banyan Application communicating with one another using LAN connected messaging.

If the Banyan Components reside on a single computer, they will all automatically connect to the local Backplane. If the Banyan Application is distributed across multiple computers, both Banyan Components still need to share a single Backplane.

Banyan Distributed Applications

Banyan Components may be distributed across multiple computers without any code changes. The only difference between a distributed and non-distributed Banyan Application is that we need to point one or both of the Banyan Components to a common Banyan Backplane.

If you recall, when running the p2pa demo across two machines, the -b command-line option to connect player 1 to connect to the same Backplane that player 0 was using.

Pub/Sub

Publishing A Message

With the pub/sub model, a publishing entity gathers data, forms a message containing the data, and then publishes the message to the network. In addition to the data, each message is associated with a message topic at the time of the publication.

Messages are created as a Python dictionary. So, for example, let’s say we wish to publish a message containing the current time containing hours, minutes, and seconds. Here is one way of doing this.

To get the current time, we first need to import datetime, then we call datetime.datetime.now() to retrieve the current time.

import datetime

now = datetime.datetime.now()

Using the results in now, let’s build a Banyan message.

payload = {'hour' = now.hour, 'minute' = now.minute, 'seconds' = now.seconds}

To publish the message we need to call the Banyan method publish_payload.

The publish_payload method takes two parameters. The first is the payload, and the second is a message topic. For this example, we will set the topic to ‘current_time’.

self.publish_payload(payload, 'current_time')

Python Banyan automatically encodes the message for transmission and takes care of all the TCP/IP details for you.

Subscribing To Receive Messages

A subscribing entity may elect to receive all messages for one or several topics of interest. Continuing with our example, to receive messages with the ‘current_time’ topic, we need to subscribe to the ‘current_time’ topic. To set a subscription topic, the set_subscriber_topic method is called, passing in the topic string.

self.set_subscriber_topic('current_time')

A separate call to set_subcriber_topic is used for each topic of interest.

Next, we need to start the Banyan receive-loop. The receive_loop waits to receive any messages with topics that match the topics we have elected to receive.

self.receive_loop()

Because the receive_loop must run continuously without blocking, it will be run in a separate thread from the Arcade event-loop.

Processing Incoming Messages

When the Banyan receive_loop receives a message, it automatically calls a Banyan method called incoming_message_processing().

This method must be overridden for the message to be processed.

When Banyan calls incoming_message_processing, it passes in two parameters. The first is the message topic string, and the second is the decoded message payload.

We can use the message topic to de-reference messages from one another. We can then do whatever we need to properly process the message.

Extending And Modifying MyGame For Multi-Player Functionality

To add Python Banyan behaviors as well as multi-threading behaviors to the MyGame class, we redefine it as:

class MyGame(arcade.Window, threading.Thread, BanyanBase):

As with single inheritance, we need to initialize all of the parent classes by calling the __init__ method for each of the parent classes.

arcade.Window.__init__(self, SCREEN_WIDTH, SCREEN_HEIGHT, title)

threading.Thread.__init__(self)


BanyanBase.__init__(self, back_plane_ip_address=back_plane_ip_address,
                    process_name=process_name, loop_time=.0001)
        # Initialize the python-banyan base class parent.
        #
        # If the backplane_ip_address is == None, then the local IP
        # address is used.
        #
        # The process name is just informational for the Banyan header
        # printed on the console.
        #
        # The loop_time the amount of idle time in seconds for the
        # banyan receive_loop to wait to check for the next message
        # available in its queue.

The Modifying The Coin Class

For the Coin class, we add an attribute, my_index, and provide getter and setter properties for the attribute. We also remove the original code contained in the update method.

class Coin(arcade.Sprite):
    """
    Coin sprite definition
    """

    def __init__(self, filename, sprite_scaling):
        super().__init__(filename, sprite_scaling)

        self.change_x = 0
        self.change_y = 0

        # Assign an index number to each coin
        # so that we can track coins across game instances.
        # We establish this as a property in the methods below.
        self._my_index = None

    @property
    def my_index(self):
        return self._my_index

    @my_index.setter
    def my_index(self, index):
        self._my_index = index

    def update(self):
        """
        Updates will be controlled in the second thread
        using python-banyan.
        """
        return

Why do we need my_index?

Each player is a process running in its own memory space. Each player process has its own copy of the coin_list. When we need to remove a coin from the list for both players, we need a memory independent way of identifying which coin to remove. Coins with matching my_index values represent coins having the same positions on both screens. By using the my_index value to identify a specific coin to remove, both screens will appear to remove the same coin.

Continuing With Modifications To The MyGame Class

Modifications To setup

   def setup(self):
        """
        Initialize the game
        """
        # Sprite lists
        self.all_sprites_list = arcade.SpriteList()
        self.coin_list = arcade.SpriteList()

        # Set up the player 1
        # Character image from kenney.nl
        self.player_sprite = arcade.Sprite(":resources:images/animated_characters/female_person/femalePerson_idle.png",
                                           SPRITE_SCALING_PLAYER)
        self.player_sprite.center_x = 50
        self.player_sprite.center_y = 50
        self.all_sprites_list.append(self.player_sprite)

        # Create the coins which constitute player 2
        for i in range(COIN_COUNT):
            # Create the coin instance
            # Coin image from kenney.nl
            coin = Coin(":resources:images/items/coinGold.png", SPRITE_SCALING_COIN)

            # Position the coin
            coin.center_x = random.randrange(SCREEN_WIDTH)
            coin.center_y = random.randrange(SCREEN_HEIGHT)
            coin.change_x = random.randrange(-3, 4)
            coin.change_y = random.randrange(-3, 4)
            
            # add the index number to each coin
            coin.my_index = i

            # Add the coin to the lists
            self.all_sprites_list.append(coin)
            self.coin_list.append(coin)

When creating the coins, we set each coin’s my_index value. Also, we moved score initialization from the __init__ method to here.

Modifications To on_mouse_motion

    def on_mouse_motion(self, x, y, dx, dy):
        """
        Mouse motion detection and handling
        :param x: x position
        :param y: y position
        :param dx: change in x
        :param dy: change in y
        """
        # Move the center of the player sprite to match the mouse x, y
        # by publishing the position as a banyan message.
        if self.player == 1:
            payload = {'p1_x': x, 'p1_y': y}
            topic = 'p1_move'
            self.publish_payload(payload, topic)

Here we remove updating the position of the female sprite directly. Player 1 captures the current x and y positions of the mouse, assembles a Banyan message containing those values, and then publishes the message with a topic of ‘p1_move’ to indicate player 1 moved on the screen.

Both players will update the player position on their respective screens when the message is received and processed. This will be discussed a little further down.

Adding Code To Process Mouse Button Presses

    def on_mouse_press(self, x, y, button, modifiers):
        # start the coins in motion
        if button == arcade.MOUSE_BUTTON_LEFT:
            payload = {'go': True}
            self.publish_payload(payload, 'enable_coins')

        # start collision detection if we've started the coins in motion
        if self.go:
            if button == arcade.MOUSE_BUTTON_RIGHT:
                # self.run_collision_detection = True
                payload = {'collision': True}
                self.publish_payload(payload, 'enable_collisions')

Here we detect left or right mouse button presses. An appropriate Banyan message is assembled for each press type and published.

When the enable_coins message is received in the Banyan receive_loop thread, the go attribute is set to True, enabling coin motion.

When the enable_collisions message is received in the Banyan receive_loop, the run_collision_detection is set to True, enabling collision detection.

Modifying arcade.Window.on_update

    def on_update(self, delta_time):
        """
        Update the sprites.
        :param delta_time:
        """

        # update the female collection sprite.
        self.all_sprites_list.update()

        # If we started the coins in motion, and this is the "coin" player,
        # gather all the current positions of the coins in the coin_list.
        if self.go:
            if self.player == 0:
                # build a list of all the coin positions using a list comprehension.
                # publish this list with the updated coin positions.
                with self.the_lock:
                    coin_updates = [[self.coin_list.sprite_list[i].center_x,
                                     self.coin_list.sprite_list[i].center_y] for i in range(len(self.coin_list))]
                    payload = {'updates': coin_updates}
                    self.publish_payload(payload, 'update_coins')

Here, we removed the check for sprite collision detection and the code to increase the score.

We added code to check if the sprites are currently in motion using the go attribute. Then player 0, the computer-controlled coins player, builds an array of all of the remaining coins’ center x and y positions and then publishes that information using the update_coins topic string.

The data is gathered using a list comprehension for speed and clarity. Because both the arcade event-loop and Banyan event-loop require access to the coin_list, we want to make sure that only one thread at a time has access to the coin_list. This ensures the data integrity of the coin_list for both threads.

The Banyan Thread

Starting The Thread

    # Process banyan subscribed messages
    def run(self):
        """
        This thread continually attempts to receive
        incoming Banyan messages. If a message is received,
        incoming_message_processing is called to handle
        the message.

        """
        # start the banyan loop - incoming messages will be processed
        # by incoming_message_processing in this thread.
        self.receive_loop()

When the Banyan thread begins, it starts the Banyan receive_loop. The receive_loop internally runs forever, and therefore this thread will stay alive for the lifetime of the program

Processing Incoming Banyan Message

    def incoming_message_processing(self, topic, payload):
        """
        Process the incoming Banyan messages

        :param topic: Message Topic string.

        :param payload: Message Data.
        """
        if self.external_message_processor:
            self.external_message_processor(topic, payload)
        else:
            # update the coins positions
            if topic == 'update_coins':
                # get the new coordinates
                the_coordinates = payload['updates']
                # update the coin positions with the new coordinates
                with self.the_lock:
                    for i in range(len(the_coordinates)):
                        try:
                            self.coin_list.sprite_list[i].center_x = the_coordinates[i][0] \
                                + self.coin_list.sprite_list[i].change_x
                            self.coin_list.sprite_list[i].center_y = the_coordinates[i][1] \
                                + self.coin_list.sprite_list[i].change_y

                            # If we are out-of-bounds, then 'bounce'
                            if self.coin_list.sprite_list[i].left < 0:
                                self.coin_list.sprite_list[i].change_x *= -1

                            if self.coin_list.sprite_list[i].right > SCREEN_WIDTH:
                                self.coin_list.sprite_list[i].change_x *= -1

                            if self.coin_list.sprite_list[i].bottom < 0:
                                self.coin_list.sprite_list[i].change_y *= -1

                            if self.coin_list.sprite_list[i].top > SCREEN_HEIGHT:
                                self.coin_list.sprite_list[i].change_y *= -1
                        # this should not happen, but if it does,
                        # just ignore and go along our merry way.
                        except (TypeError, IndexError):
                            continue

                # perform hit detection if enabled with the right mouse button.
                with self.the_lock:
                    if self.run_collision_detection:
                        hit_list = arcade.check_for_collision_with_list(self.player_sprite,
                                                                        self.coin_list)
                        # hit detected
                        if hit_list:
                            for coin in hit_list:
                                # publish a remove_coin message using the coin
                                # index as the payload. The index is necessary
                                # so that we can remove coins for both players.
                                coin_index = coin.my_index
                                payload = {'coin': coin_index}
                                self.publish_payload(payload, 'remove_coin')
                                # allow time for the message to be published cleanly
                                time.sleep(.0001)

            # move player `1` on the screen
            elif topic == 'p1_move':
                self.player_sprite.center_x = payload['p1_x']
                self.player_sprite.center_y = payload['p1_y']
            # now actually remove the coin identified in hit detection
            # by using its index.
            elif topic == 'remove_coin':
                with self.the_lock:
                    coin_index = payload['coin']
                    for coin in self.coin_list.sprite_list:
                        if coin_index == coin.my_index:
                            coin.remove_from_sprite_lists()
                            self.score += 1
            elif topic == 'enable_coins':
                self.go = True
            elif topic == 'enable_collisions':
                self.run_collision_detection = True

The incoming_message_processing method is called by the receive_loop when an incoming message arrives.

Each message is processed in accordance with its topic string.

The ‘update_coins’ message updates each coin’s screen position. This code was originally in the Coin class update method. In addition, it duplicates the original ‘bounce logic’ as well. Sprite collision is performed here so that coins can be removed from the coin list, and the score increased when a collision is detected.

The ‘p1_move’ message updates the collection sprite’s position on the screen.

The enable_coins and enable_collisions messages set their respective flags to enable game functionality.

NOTE: Both player 0 and player 1 receive and process these messages. However, the generation of these messages in the Arcade event-loop thread is typically generated by only one player.

Concluding Comments

We have seen that Arcade can be enhanced to become a multi-player p2p game engine with the use of Python Banyan and the Python threading library. There were no changes made to the Arcade library nor to the Python Banyan Framework.

Now it’s up to you to try your hand at creating a multi-player p2p game!