Making text-based games with Python and curses

2017-12-28

My early attempts at making a roguelike were graphical in nature. Even after giving in and moving to ASCII characters, I still used platforms that required a graphical display. But many people still use computers in text mode, like when Rogue first graced the screens of mainframe terminals. It may seem quaint, but there are often good reasons for it, such as poor vision or the need to remotely access a server. Besides, if you're going to make a text-based game, what are you going to require, 3D acceleration? (At least one roguelike actually does just that. Seriously?)

So it was that when I set out to make Tomb of the Snake, a friend's request to make sure it runs in a terminal just made sense to me. (Turned out he actually meant a real videoterminal connected to a vintage machine, which was kinda crazy; but all I had to do was add checks for color support everywhere. Accessibility is not hard!) And -- my go-to language in recent years -- just so happens to come bundled with a module called curses for making text-based apps, at least on Linux and Mac. There are competing solutions, too, but curses is the most portable and comprehensive, owing to its age: the aforementioned Rogue was already built on it in 1980.

If nothing else, consider it a way to practice pure game design without having to worry about graphics or sound. You'll be surprised how pretty the results can be anyway.

Now, curses normally requires some amount of setup and tear-down code. But Python, being Python, takes care of the boilerplate for us:

	import curses

	def run_in_curses(window):
		window.addstr(1, 0, "Hello, world! Press any key.")
		window.getch()

	if __name__ == "__main__":
		try:
			curses.wrapper(run_in_curses)
		except RuntimeError as e:
			print(e)

That's actually the recommended way to do it, because it's guaranteed to restore your terminal to a working state no matter what happens in your code. All you have left to do is print out the exception, once you can see it. But the official documentation also teaches how to handle things manually, in case you need more flexibility.

Otherwise, all the magic happens inside the callback to `curses.wrapper`, that I named run_in_curses for lack of a better idea. Likewise for its argument, window, which is initialized to the entire terminal. Later we'll see how to manipulate it; for now the most basic stuff is to display some text and wait for a keypress. Note how the line number comes first: coordinates in curses are always inverted like that.

Speaking of coordinates: on a Mac, to the best of my knowledge, the Terminal app has a fixed size, but in Linux emulators are almost always resizable, and your user interface might need a minimum amount of room. Insert this code at the start of run_in_curses:

	h, w = window.getmaxyx()
	window.addstr(0, 0, "Your terminal is %dx%d in size." % (w, h))

Of course, users can also resize the terminal after your game has started. Luckily, the getch method does more than its name suggests, also returning special events such as mouse clicks -- as we'll see later -- and the terminal resizing:

	key = window.getch()
	if key == curses.KEY_RESIZE:
		h, w = window.getmaxyx()
		window.addstr(2, 0, "Terminal resized to %dx%d." % (w, h))
		window.getch()

Either way, it's probably best to make sure your game can run in no more than 80x24 characters, even if it can use a bigger size when available.

But what can you actually do with curses? We've already seen how to show some text anywhere on the screen. It doesn't have to be all plain, either; the addstr method takes an optional argument for specifying bold or reverse-color text:

	window.addstr(3, 0, "Have some bold text.", curses.A_BOLD)
	window.addstr(4, 0, "And some reverse text.", curses.A_REVERSE)
	window.addstr(5, 0, "Or even both at once.",
		curses.A_BOLD | curses.A_REVERSE)

You can even omit the coordinates in front to continue adding text from where you left off last time:

	window.addstr(" Isn't that cool?")

The default attributes are used again if nothing else is given. To avoid repeating yourself, you can turn them on and off globally:

	window.attron(curses.A_BOLD)
	window.addstr(7, 0, "More bold text.")
	window.attron(curses.A_REVERSE)
	window.addstr(8, 0, "Now also reverse.")
	window.attroff(curses.A_BOLD)
	window.addstr(9, 0, "And only reverse.")
	window.attroff(curses.A_REVERSE)

Note how bold text is also bright, and indeed some terminals may not be able to actually bold it. Don't take the attribute names too literally! Moreover, there's no guarantee that any of them are supported: A_DIM for instance doesn't seem to do anything in any Linux terminal emulator.

There's an addch method, too, that works just like addstr but only for single characters. It can still handle Unicode, but is presumably faster. And to simplify the creation of separator lines, there are hline and vline:

	window.hline(11, 0, curses.ACS_HLINE, 50)
	window.vline(curses.ACS_VLINE, 10)

The constants used are two special characters that terminals, real or emulated, are supposed to support (and if they don't, a simple minus sign and vertical bar will be used instead). As usual, you can omit the coordinates, but beware that these two methods don't move the cursor.

Anyway, now that you have a handle on putting things on the screen, let's see about getting input. Conveniently, getch returns ASCII character codes for those keys that have one; you can test the others against predefined constants:

	window.erase()
	window.addstr(0, 0, "Press a key")
	
	key = window.getch()
	while key != ord('q') and key != 27: # Escape
		if key == curses.KEY_UP:
			window.addstr(1, 0, "Up!  ")
		elif key == curses.KEY_DOWN:
			window.addstr(1, 0, "Down!")
		elif 32 <= key <= 127:
			window.addstr(1, 0, chr(key).ljust(5))
		else:
			window.addstr(1, 0, str(key).ljust(5))
		key = window.getch()
	window.addstr(1, 0, "Done!")
	window.getch()

Mouse input is signaled the same way, but reading it takes some extra steps:

	curses.mousemask(
		curses.BUTTON1_CLICKED | curses.BUTTON1_DOUBLE_CLICKED)	
	window.addstr(" Now try clicking the mouse.")
	key = window.getch()
	while key == curses.KEY_MOUSE:
		device, x, y, z, button = curses.getmouse()
		if button == curses.BUTTON1_CLICKED:
			window.addstr(2, 0, "Single click at %dx%d" % (x, y))
		elif button == curses.BUTTON1_DOUBLE_CLICKED:
			window.addstr(2, 0, "Double click at %dx%d" % (x, y))
		key = window.getch()

The `device` variable identifies the pointing device used, in case you have more than one; z is currently unused. Don't forget to set the event mask first! I did, and couldn't figure out why nothing was happening.

Sadly, none of that works in the Terminal app on Macs, because it doesn't pass any mouse events to your code. So make sure your text-based game can be driven entirely with the keyboard.

By the way, almost forgot to show you how to get an entire character string as input:

	window.addstr(5, 0, "Now, what's your name?")
	curses.echo()
	answer = window.getstr(5, 23, 50).decode()
	curses.noecho()
	window.addstr(6, 0, "Well, hello, %s!" % (answer,))

Remember to temporarily enable the echoing of keys, so people can see what they're typing. As for the call to decode, that's because the getstr method returns raw bytes, and we want a proper string. The coordinates are optional as always; the third (undocumented) argument limits how many characters you can enter. Without it, you could just go on typing, messing up your nicely set up text layout and who knows what else.

So far so good, except the display is looking rather monochrome. Let's see if curses.wrapper managed to initialize color support, and what it found:

	if curses.has_colors():
		msg = ("Colors: %d Pairs: %d Can change: %d"
			% (curses.COLORS, curses.COLOR_PAIRS,
				curses.can_change_color()))
		window.addstr(10, 2, msg)
		window.getch()

No terminal emulator I have access to seems to support more than the 8 standard colors, but at least the Linux console allows me to change them at will, and apparently I can always rely on 64 color pairs. Wait, what? Turns out, in curses you can't just give the colors to use directly. Instead, you define foreground/background pairs in advance and use them as attributes:

	if curses.has_colors():
		curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
		attr = curses.color_pair(1) | curses.A_BOLD
		window.addstr(" And... we have color!", attr)
		window.getch()

I skipped color pair zero because that's hardcoded to the default white-on-black and can't be redefined, but the rest are up to you. I'll just point out that A_BOLD effectively doubles the number of available foreground colors, and provides needed contrast. See the reference manual on python.org for more constants.

You'll want to do that anyway, because there's a lot more to _curses_ than this quick guide can cover. One complex topic I promised to mention is windows. Yes, really. If you've ever used a multiplexer such as screen or tmux, you know that a terminal can be subdivided into smaller areas, each showing a different app. With _curses_, you can do that within your own game (and in fact screen uses curses itself). Too bad the Python module has a bug, present in both 2.7 and 3.3: if you try to explicitly add a character in the bottom right cell of a window, it will crash. That doesn't apply when drawing a box around the window -- see below -- but it was enough to make me give up and just apply offsets manually.

Other small tricks you'll find useful:

	from curses.textpad import rectangle # This goes near the top.

	rectangle(window, 8, 0, 12, 60)

With that, surprisingly enough, you have enough to make a good-looking roguelike, with some modern amenities like a pop-up menu and (primitive) input box. Much more is possible, such as scrolling text, overlapping windows or color cycling. But all that can come as the need arises. See the official documentation, and remember: don't panic! It's all much less daunting than it may seem at first sight.