💾 Archived View for gemini.ctrl-c.club › ~nttp › writing › python-curses-guide.gmi captured on 2023-04-26 at 14:05:23. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2020-10-31)

-=-=-=-=-=-=-

Making text-based games with Python and curses

28 December 2017

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 Python -- 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.

Errata: As of October 2020, it turns out that terminal emulators support A_DIM and flash/bell just fine. It's just the curses binding for Python that's broken. Tip: never use the extra attribute argument to addch/addstr and always turn them on and off with the dedicated functions; that's what they're for.

More writing