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:
- Which genre? Action? Puzzle? Narrative? Artistic?
- What's the goal? (Is there a goal?)
- What will the board look like?
- How will the user interact with the game?
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:
- A list of agents
- A dict representing the initial game state.
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:
- Each agent's
handle_keystroke
method is called for each keystroke which has been entered since the last turn (if the agent has this method). - Each agent's
play_turn
method is called (if the agent has this method). - In either of these methods, the agent may change its character,
position, or color. The agent also receives the current
Game
as an argument in both methods, so it can look up properties of the game, access other agents, or change the game's state. - Then the game draws the board, displaying each agent's character at its position, in its color. The game also prints out its current state.
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
:
5 class 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
:
5 from retro.game import Game
6 from spaceship import Spaceship
7
8 board_size = (25, 25)
9 ship = Spaceship(board_size)
10 game = Game([ship], {"score": 0}, board_size=board_size)
11 game.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
:
5 class 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:
5 from retro.game import Game
6 from spaceship import Spaceship
7 from asteroid import Asteroid
8
9 board_size = (25, 25)
10 ship = Spaceship(board_size)
11 asteroid = Asteroid((WIDTH // 2, 0))
12 game = Game([ship, asteroid], {"score": 0}, board_size=board_size)
13 game.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
:
5 from random import randint
6 from asteroid import Asteroid
7
8 class 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:
5 from retro.game import Game
6 from spaceship import Spaceship
7 from asteroid_spawner import AsteroidSpawner
8
9 board_size = (25, 25)
10 ship = Spaceship(board_size)
11 spawner = AsteroidSpawner(board_size)
12 game = Game([ship, spawner], {"score": 0}, board_size=board_size)
13 game.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.