Retro

Introduction

This lab introduces Retro, a simple framework for creating Terminal-based games which you will use in this unit's project.

One way to think of a game is as a set of rules that all the players follow. Actually, let's talk about agents rather than players, because sometimes there are characters in games which are not controlled by humans. In Retro, anything that shows up on the game board will be an agent, even if it just sits there for the whole game and doesn't do anything.

The hardest part of creating a game is designing it. Consider the following:

Dream big, but start small. What would be the smallest, simplest version of your game? Start with that.

Games

Retro contains one main class that you'll use to create games: Game. When you create an instance of Game, there are two required arguments:

Here's a very simple game, using the built-in ArrowKeyAgent:

from retro.game import Game
from retro.agent import ArrowKeyAgent

agent = ArrowKeyAgent()
state = {}
game = Game([agent], state)
game.play()

To run this game, copy the code above into a file and run it. Or just run:

$ python -m retro.examples.simple

A game can also be run in debug mode by using the optional argument debug=True when creating a retro.game.Game. When a game runs in debug mode, you will see a sidebar containing log messages, including when there are keystrokes and anytime the code invokes Game.log. Test out debug mode using:

python -m retro.examples.debug

The game loop

Every game you write will use the same Game class. A game's uniqueness comes from the agents it includes. Here is an overview of how agents work within a game:

The game runs as a series of turns. Each turn:

The game ends once Game.end is called.

Agents

An agent is an instance of a class which has certain attributes and methods (see the documentation for the details). The character, position, and color attributes determine how and where the agent is displayed on the game board. The handle_keystroke and play_turn methods determine the agent's behavior within the game.

Example: Nav

            ╔═════════════════════════╗
            ║       O                 ║
            ║ O                      O║
            ║      O                  ║
            ║                         ║
            ║                         ║
            ║                         ║
            ║  O                      ║
            ║                         ║
            ║  O                O     ║
            ║             O           ║
            ║O                        ║
            ║                         ║
            ║                 OO      ║
            ║          O              ║
            ║        O                ║
            ║                  O      ║
            ║                         ║
            ║                O        ║
            ║       O                 ║
            ║                         ║
            ║          O  O           ║
            ║            O            ║
            ║                         ║
            ║  O         O            ║
            ║ O             ^         ║
            ╠═════════════════════════╣
            ║score: 408               ║
            ║                         ║
            ║                         ║
            ║                         ║
            ╚═════════════════════════╝

For this example, we're going to create a simple game in which you are flying a spaceship through a field of asteroids. It's a simple action game; you've probably played something like it before. You can play our version by running:

$ python -m retro.examples.nav

We need to write a few different agent classes to implement this game. First, we'll need a spaceship. Save the following code in spaceship.py:

5class Spaceship:
6 name = "ship"
7 character = '^'
8
9 def __init__(self, board_size):
10 board_width, board_height = board_size
11 self.position = (board_width // 2, board_height - 1)
12
13 def handle_keystroke(self, keystroke, game):
14 x, y = self.position
15 if keystroke.name in ("KEY_LEFT", "KEY_RIGHT"):
16 if keystroke.name == "KEY_LEFT":
17 new_position = (x - 1, y)
18 else:
19 new_position = (x + 1, y)
20 if game.on_board(new_position):
21 if game.is_empty(new_position):
22 self.position = new_position
23 else:
24 game.end()

Spaceship is a pretty simple class. The ship's character is ^, its position starts at the bottom center of the screen, and when the left or arrow key is pressed, it moves left or right. If the ship's new position is empty, it moves to that position. If the new position is occupied (by an asteroid!) the game ends.

Save the following code in nav_game.py:

5from retro.game import Game
6from spaceship import Spaceship
7
8board_size = (25, 25)
9ship = Spaceship(board_size)
10game = Game([ship], {"score": 0}, board_size=board_size)
11game.play()

💻 Now try the game by running python nav_game.py. You should be able to move back and forth. Press Control + C to end the game.

Now we need some asteroids to avoid. Instead of creating a separate class for each asteroid, we can write a single Asteroid class, and then create as many instances as we need. But which part of the game will create new Asteroids? We will also write an AsteroidSpawner class, which is not displayed on the board but which constantly spawns asteroids.

💻 Add the following code to asteroid.py:

5class Asteroid:
6 character = 'O'
7
8 def __init__(self, position):
9 self.position = position
10
11 def play_turn(self, game):
12 if game.turn_number % 2 == 0:
13 x, y = self.position
14 if y == HEIGHT - 1:
15 game.remove_agent_by_name(self.name)
16 else:
17 ship = game.get_agent_by_name('ship')
18 new_position = (x, y + 1)
19 if new_position == ship.position:
20 game.end()
21 else:
22 self.position = new_position

There are a few details to note here. First, Asteroid doesn't have a defined position; the AsteroidSpawner will decide on the Asteroid's initial position and pass it to __init__ (which is called when initializing a new Asteroid). In play_turn, nothing happens unless game.turn_number is divisible by 2. The result is that asteroids only move on even-numbered turns. (This is a good strategy for when you want some agents to move more slowly than others.) Now, if the asteroid is at the bottom of the screen (if y == HEIGHT - 1), it has run its course and should be removed from the game. Otherwise, the asteroid's new position is one space down from its old position. If the asteroid's new position is the same as the ship's position, the ship has crashed into the asteroid, so the game ends.

💻 Replace all the code in nav_game.py with the following:

5from retro.game import Game
6from spaceship import Spaceship
7from asteroid import Asteroid
8
9board_size = (25, 25)
10ship = Spaceship(board_size)
11asteroid = Asteroid((WIDTH // 2, 0))
12game = Game([ship, asteroid], {"score": 0}, board_size=board_size)
13game.play()

💻 Run python nav_game.py. Now there is a single asteroid to avoid.

But we want lots of asteroids! Let's write another agent class for an AsteroidSpawner, which will possibly spawn an asteroid each turn. Paste the following code into asteroid_spawner.py:

5from random import randint
6from asteroid import Asteroid
7
8class AsteroidSpawner:
9 display = False
10
11 def __init__(self, board_size):
12 width, height = board_size
13 self.board_width = width
14
15 def play_turn(self, game):
16 game.state['score'] += 1
17 if self.should_spawn_asteroid(game.turn_number):
18 asteroid = Asteroid((randint(0, self.board_width - 1), 0))
19 game.add_agent(asteroid)
20
21 def should_spawn_asteroid(self, turn_number):
22 return randint(0, 1000) < turn_number

On each turn, AsteroidSpawner adds 1 to the game score and then uses should_spawn_asteroid to decide whether to spawn an asteroid, using a simple but effective algorithm to make the game get progressively more difficult: choose a random number and return True if the number is less than the current turn number. At the beginning of the game, few asteroids will be spawned. As the turn number climbs toward 1000, asteroids are spawned almost every turn.

When should_spawn_asteroid comes back True, AsteroidSpawner creates a new instance of Asteroid at a random position along the top of the screen and adds the asteroid to the game.

💻 One final time, replace all the code in nav_game.py with the following:

5from retro.game import Game
6from spaceship import Spaceship
7from asteroid_spawner import AsteroidSpawner
8
9board_size = (25, 25)
10ship = Spaceship(board_size)
11spawner = AsteroidSpawner(board_size)
12game = Game([ship, spawner], {"score": 0}, board_size=board_size)
13game.play()

You should have a fully-functioning game. The example version (which you can play with python -m retro.examples.nav) has a few extra details (e.g. the asteroids gradually fade into view) which we skipped to keep things simple.

You can read all the details, and also work through another example game, by reading the retro documentation.