Simple network multiplayer pong with PySDL2 and ZeroMQ

I've been playing around with simple client synchronization for multiplayer games during Ludum Dare 29. I didn't end up building a game to submit but I did pull this tutorial together from some of what I was working on.

Lockstep Synchronization of Input

In any multiplayer real time game, you need to keep your game state in sync between the players. The most obvious approach is to update the other client whenever you alter the game state. Imagine a game of chess, whenever you move a piece, the state on the board updates so you can send a copy of the board to the other player and you'll both be playing the same game again. This works very well for small boards, simple collections of state. Computer games are rarely played on "small boards", they tend to have lots of moving state that has to be updated for all the clients somehow. Sending the entire state whenever it changes, sixty time a second, doesn't work if your state is 1MB and the players are connecting over the internet.

The alternative is to sync a portion of the board. Only send the difference between the current board and the previous one. Many games do this, sync the whole board when you connect and then carefully manage updates to any kind of state.

For many games, the smallest stream of data is the input made by the player. If your game state is deterministic, instead of sending any changes in state, you can forward each player's input to each other player and have both clients agree on the same simulation without exchanging state. This requires only one initial sync of the state. After that, each client takes input and relays it to all the others. After everyone has seen the same input, the next step in the simulation begins and new input is forwarded.

Using ZeroMQ

The PySDL2 pong tutorial is the starting point for this. Read through and understand it first.

First we'll initialize ZeroMQ and create two sockets, one for receiving messages and one for sending them. For testing, both sockets will connect to localhost and which player the client is using is passed as either a 0 or a 1 when starting the script. ./pong 0

import zmq


def run():
    zmq_addresses = ["tcp://", "tcp://"]
    us = int(sys.argv[1])
    zmq_context = zmq.Context.instance()

    in_sock = zmq_context.socket(zmq.PULL)
    out_sock = zmq_context.socket(zmq.PUSH)
    out_sock.connect(zmq_addresses[(us + 1) % 2])


We should decide on a protocol for the two players clients to use. Since we only have a few kinds of input events and the only extra information associated with is which key has been pressed, we send a step value identifying which "frame" of game state the input is for, followed by the kind of event and the key pressed if one is associated with the event. This replaces the direct modification of the player state on events. ZeroMQ deals in bytes, not integers like the SDL tokens, so the tokens chosen are strings encoded by PyZMQ to UTF-8.

def run():
    running = True
    step = 0
    while running:
        local_events = sdl2.ext.get_events()
        # Prepend the step for this frame
        send_events = [str(step)]
        # First list events to forward in (type, key) pairs
        for event in local_events:
            if event.type == sdl2.SDL_QUIT:
                send_events.extend(["QUIT", ""])
            elif event.type == sdl2.SDL_KEYDOWN:
                if event.key.keysym.sym == sdl2.SDLK_UP:
                elif event.key.keysym.sym == sdl2.SDLK_DOWN:
            elif event.type == sdl2.SDL_KEYUP:
                if event.key.keysym.sym == sdl2.SDLK_UP:
                elif event.key.keysym.sym == sdl2.SDLK_DOWN:


        step += 1

Each message ends up looking like this:



Now that we have all the local events, we ask the input socket for any events from the other client. The order is important to prevent deadlocks, we always want to send a message before looking for new ones.

remote_events = in_sock.recv_multipart()
assert remote_events[0] == str(step)

We still need to handle the events from before. Now they are only recorded in the same structure as the ones coming in on the PULL socket.

def handle_events(player, events):
    for event_type, event_key in zip(events[0::2], events[1::2]):
        if event_type == "QUIT":
            running = False
        elif event_type == "KEYDOWN":
            if event_key == "UP":
                player.velocity.vy = -3
            elif event_key == "DOWN":
                player.velocity.vy = 3
        elif event_type == "KEYUP":
            if event_key in ("UP", "DOWN"):
                player.velocity.vy = 0


def run():
    handle_events(players[not us], remote_events[1:])
    handle_events(players[us], send_events[1:])
    # Run the simulation after all events are handled
    step += 1

Now you can start both pong clients up. ./pong 0 and ./pong 1 and they'll start running. Each client has to wait for the other to send its next message before continuing, so they'll stay in sync. Getting this working across a real network or the internet is also very simple, you specify the other client's address as an argument instead of choosing them from the list.

Example Code

A working example is available on GitLab. It has a few minor extensions to the PySDL2 tutorial, the frame rate is limited to about 30 frames per second and the ball/paddles move faster. I wrote it with PyPy but CPython should do fine too.

Some interesting ways to extend it are: allow a client to reconnect, keep track of score, or add additional players. Maybe I'll continue the tutorial for those later.