Learn Python using Gamedev

| 7 min. (1437 words)

A few weeks ago I was asked if I’d be keen to go to Kiwi Pycon (a Python conference in Dunedin, New Zealand). I decided it would be an interesting experience even though my Python knowledge is a bit lacking. So as an after-work project my colleague Yosan and I decided to put together a small game to help us learn the language. This article covers our initial experiences putting everything together.

The plan

As with other languages I’ve learned I typically like to develop an application which involves a handful of functions such as reading files, networking, user input and visuals. This forces me to become familiar with libraries and language functions which gets me up to speed in a way that re-implementing algorithms and completing tutorial projects would not. It also forces me to understand a bit about Python’s environment with regards to installing dependencies and creating releases.

We looked up a few libraries related to game creation and networking and decided to use pygame as that seemed to provide a functionality that would remove a lot of the tedium from development. It also looked like Python had a range of libraries for networking so we decided to figure it out when we got to it.

Installing Python

Python itself was relatively easy to install. We just took the auto installer from the website and had the runtime ready within a minute.

Installing Pygame

Pygame proved to be a bit frustrating to install. It took several attempts before we managed to download the script and install it in the correct way. We had to find the correct version of the library (that matched the version of Python we had installed) on a list of dependencies that wasn’t easily found, then extract that with the Python package install utility pip3.exe. This seemed harder than it should have been, especially due to the number of different versions of the library and the slight differences in what we would have to do if we had a different version of Python installed.

Eventually we got things set up and looked for a tutorial on getting the basics of a game up and running.

Drawing a sprite

The first thing to do when getting started with anything graphical is just to get something (or anything) rendered to the screen. We found a whole bunch of tutorials of varying complexity on this and based on their examples came up with a basic render loop:

import pygame, sys
from pygame.locals import *

WIDTH = 400
HEIGHT = 400

screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption('Hello World!')

clock = pygame.time.Clock()

thing = pygame.image.load('images/TrashPanda/TrashPanda_front.png')

x = 0
y = 0

while True:
  for event in pygame.event.get():
    if event.type == QUIT:
      pygame.quit()
      sys.exit()

  clock.tick(30)
  screen.fill((0,0,0))
  screen.blit(thing, (x, y))
  pygame.display.flip()

This code produced this:

After that, we focused on capturing user input to move the character. We also created a class for the player character to internalize some of its logic:

class Minion:
  def __init__(self, x, y):
    self.x = x
    self.y = y
    self.vx = 0
    self.vy = 0
  def update(self):
    self.x += self.vx
    self.y += self.vy
    #this keeps the player character within the bounds of the screen
    if self.x > WIDTH - 50:
      self.x = WIDTH - 50
    if self.x < 0:
      self.x = 0
    if self.y > HEIGHT - 50:
      self.y = HEIGHT - 50
    if self.y < 0:
      self.y = 0

  def render(self):
    screen.blit(thing, (self.x, self.y))

User input was captured within the game loop:

for event in pygame.event.get():
  if event.type == QUIT:
    pygame.quit()
    sys.exit()
  if event.type == KEYDOWN:
    if event.key == K_LEFT: cc.vx = -10
    if event.key == K_RIGHT: cc.vx = 10
    if event.key == K_UP: cc.vy = -10
    if event.key == K_DOWN: cc.vy = 10
  if event.type == KEYUP:
    if event.key == K_LEFT and cc.vx == -10: cc.vx = 0
    if event.key == K_RIGHT and cc.vx == 10: cc.vx = 0
    if event.key == K_UP and cc.vy == -10: cc.vy = 0
    if event.key == K_DOWN and cc.vy == 10: cc.vy = 0

And the character’s position was updated and rendered (also in the gameloop):

cc.update()
  cc.render()

Now that we had basic character movement working, we wanted to start building some simple multiplayer functionality.

We decided on a very simple data transfer model:

  • Clients would connect to the server and then continually broadcast the position of their own character
  • The server would then broadcast the location of all characters to all clients

We decided to use TCP sockets as they handle things like connects and disconnects easier than UDP. Also this isn’t exactly a performance critical application.

We managed to find a good article covering writing async servers in Python here.

The basic server code started as this:

import socket
import asyncore
import random
import pickle
import time

BUFFERSIZE = 512

outgoing = []

#additional logic here...

class MainServer(asyncore.dispatcher):
  def __init__(self, port):
    asyncore.dispatcher.__init__(self)
    self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
    self.bind(('', port))
    self.listen(10)

  def handle_accept(self):
    conn, addr = self.accept()
    print ('Connection address:' + addr[0] + " " + str(addr[1]))
    outgoing.append(conn)
    playerid = random.randint(1000, 1000000)
    playerminion = Minion(playerid)
    minionmap[playerid] = playerminion
    conn.send(pickle.dumps(['id update', playerid]))
    SecondaryServer(conn)

class SecondaryServer(asyncore.dispatcher_with_send):
  def handle_read(self):
    recievedData = self.recv(BUFFERSIZE)
    if recievedData:
      updateWorld(recievedData)
  else: self.close()

MainServer(4321)
asyncore.loop()

This defines a MainServer responsible for accepting new TCP connections which it then creates a SecondaryServer for. The secondary servers handles all incoming data from each client. When an incoming packet is received, the data is passed to updateWorld. This is defined below:

class Minion:
  def __init__(self, ownerid):
  self.x = 50
  self.y = 50
  self.ownerid = ownerid

minionmap = {}

def updateWorld(message):
  arr = pickle.loads(message)
  playerid = arr[1]
  x = arr[2]
  y = arr[3]

  if playerid == 0: return

  minionmap[playerid].x = x
  minionmap[playerid].y = y

  remove = []

  for i in outgoing:
    update = ['player locations']

    for key, value in minionmap.items():
      update.append([value.ownerid, value.x, value.y])

    try:
      i.send(pickle.dumps(update))
    except Exception:
      remove.append(i)
      continue

   for r in remove:
     outgoing.remove(r)

updateWorld is simply responsible for updating the dictionary containing the location of each player’s character. It then broadcasts the positions to each player by serializing their positions as an array of arrays.

Now that the client was built we could implement the logic in the client to send and receive updates. When the game is started we added some logic to start a simple socket and connect to a server address. This optionally takes an IP address specified by the command line but otherwise connects to localhost:

serverAddr = '127.0.0.1'
if len(sys.argv) == 2:
 serverAddr = sys.argv[1]
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((serverAddr, 4321))

We then added some logic to the start of the game loop to read from the socket. We utilized the ‘select’ package to read incoming packages from the socket only when they had data. If we had used ‘socket.recv’ the gameloop would halt if the socket didn’t have any packets to read. Using ‘select’ allows the gameloop to continue executing even if there isn’t anything to read:

ins, outs, ex = select.select([s], [], [], 0)
  for inm in ins:
    gameEvent = pickle.loads(inm.recv(BUFFERSIZE))
    if gameEvent[0] == 'id update':
      playerid = gameEvent[1]
      print(playerid)
    if gameEvent[0] == 'player locations':
      gameEvent.pop(0)
      minions = []
      for minion in gameEvent:
        if minion[0] != playerid:
          minions.append(Minion(minion[1], minion[2], minion[0]))

The above code handled two of the serialized payloads the server could possibly produce.

1. The initial packet containing the players server assigned identifier

This is used by the client to identify itself to the server on all position updates. It also used to ignore its own player data that server broadcasts so there isn’t a shadowed version of the player character.

2. The player location payload

This contains a set of arrays containing player identifiers and character positions. When this is retrieved the existing Minion objects are cleared and new Minion objects are created for each of the transmitted ones.

The other Minions are then rendered in the game loop:

for m in minions:
  m.render()

The last thing we had to do was to add some code to the client to tell the server the position of the player. This was done by adding a broadcast at the end of the gameloop to serialize the current players position using ‘pickle‘, then sending this bytestream to the server:

ge = ['position update', playerid, cc.x, cc.y]
  s.send(pickle.dumps(ge))

Once this was complete players connected to the same server could see the other players moving around.

Some additional updates such as displaying different avatars based on the playerid were implemented.

When finished, the current iteration with two players looked like this:

The full code for both client and server is available here.

Of course there’s always of room for development and improvement.  If you found this article interesting, let us know in the comments below.