Using Python and Tkinter for desktop games

2018-06-06

What's the most played videogame ever? One might argue for Tetris, or else some edition of Pokemon. But my bet is on the good old Solitaire that came preinstalled on Windows for decades, allowing countless office workers to take their minds off drudgery for a while -- often a long while, given the game's addictive nature.

There's absolutely nothing wrong with that.

We're conditioned to think of videogames as dazzling shows of light and sound. It started with the arcades, nearly four decades ago, and only got worse as time went on. The menu screen of Doom (1993), that opened to a loud attract mode before you had a chance to get your bearings, is emblematic of this trend.

I have considerably more appreciation for the winning screen of Solitaire, with its jumping playing cards. How I used to anticipate that moment! And it was all in complete silence, too, as it was built like an ordinary desktop utility, and GUI toolkits have no sound support, as a general rule.

There's something pure about games that run in a window you can resize at will, with no noises to bother anyone around, only needing a mouse to control. But if you try to make your own, you'll quickly run against a wall: most GUI toolkits are huge and overly complicated to use.

Tkinter, 's interface to the Tk library, is an exception. And unlike others, it's readily available on most modern operating systems.

How to get it: if you're on Mac OS X, Tkinter is already preinstalled as part of Python 2.7; for Windows, it's bundled with most of the installers -- just grab the latest one from python.org. On Linux, it may already be there, or you might have to add it via the package manager. It's usually called "tkinter" or "python-tk". Beware that you'll need the right version for the Python you are using, either 2.7 or 3.x; Linux distributions tend to ship with both.

Now about importing. The modules comprising tkinter were renamed and reorganized between Python 2 and 3, so if your code is to run on either version, you need to check which one the user has:

	import sys

	if sys.version_info.major >= 3:
		from tkinter import *
	else:
		from Tkinter import *

It may look risky, but importing every symbol like that works fine with Tkinter, and might save you a lot of typing if you make a complex GUI. Feel free to use qualified names instead if you prefer.

Now to put something on screen, and what else can we begin with if not a window:

	top = Tk()
	top.title("Python/Tkinter code sample")

	top.mainloop()

The last line is what makes a Tkinter program actually work, instead of closing right away. Any other code will have to come before it. As it is, all you can do right now is close the window, which will end the run.

So let's put something in that window. Extend the above code as follows:

	top = Tk()
	top.title("Python/Tkinter code sample")

	viewport = Canvas(top, width=800, height=600, background="black")
	viewport.pack(fill="both", expand=1)

	top.mainloop()

When creating any widget, the first argument to the constructor is the parent, in our case the main window; the rest is optional configuration. Making the background black proves the window isn't empty; give it a size, too, because the default is tiny. Now all that's left to do is tell the game viewport to fill the entire window, expanding in both directions when the latter is resized.

I'll show you later how to take that into account (or make the window fixed-size for that matter). For now, let's draw a thing or two on top of that background:

	viewport.create_rectangle(
		40, 30, 360, 270, fill="brown", outline="red")
	viewport.create_oval(
		200, 150, 600, 450, outline="yellow", width=5)
	viewport.create_line(
		80, 500, 700, 250, 650, 75, 720, 60,
		fill="cyan", width=3)

Clear enough, right? But as always, the devil is in the details:

There are other item types you can add to a canvas widget, like arcs and polygons, but one is especially useful: text.

	viewport.create_text(
		750, 550,
		text="Hello, Tkinter!\nNeat, eh?",
		anchor="se", justify="center",
		font="Times 24", fill="white")

No worries, most of these options are, well, optional; you can see for yourself how they interact. The font can be anything installed on the computer where the game will be running -- you can even get a list and look through it. But for maximum portability, stick to one of Times, Helvetica or Courier, that are guaranteed to always be there (substituted, of course).

Wait, what about images? That takes an extra step in Tkinter... and a caveat:

	ihelp = PhotoImage(file="help-browser.gif")
	viewport.create_image(50, 550, image=ihelp)

Make sure to keep a reference to the image object, otherwise Python will garbage-collect it and your icon will vanish from the game. Don't just pass the constructor to create_image! More importantly, stick to GIF files. PNG support will be in upcoming versions, but upgrades will take a while.

But enough with the downsides. You know what's cool about all this? Every item you can draw isn't just a bunch of pixels to paint onto the canvas and forget; they're objects that stick around, can be manipulated, and even react to the mouse if you set them up.

	bhelp = viewport.create_rectangle(350, 525, 450, 575,
		fill="gray", activefill="white", tags="meta button")
	viewport.tag_bind(bhelp, "<Button-1>",
		lambda event: viewport.move(bhelp, 5, -10))

Each create_whatever method returns a numeric ID you can use to mention the item in question. Another way is to give them tags, as shown. Binding an event to a tag instead of an ID causes all items with that tag, present or future, to handle it. A special tag "all" always exists, and is very handy when you need to, say, viewport.delete("all").

Often however you just want the graphics to sit there looking pretty and let the canvas itself handle interactions:

	ticker = viewport.create_text(
		500, 50, text="Double-click anywhere",
		anchor="center", justify="center",
		font="Helvetica 18 bold", fill="white")
	viewport.bind("<Double-Button-1>",
		lambda e: viewport.itemconfigure(
			ticker, text="received {0}x{1}".format(e.x, e.y)))

You can even control when items are and aren't interactive:

	viewport.itemconfigure(
		"button", disabledfill="darkgray", state="disabled")

Other states include "normal", or even "hidden", and there are other things you can do with items, such as reposition them in absolute terms, or decide which show up on top of others (note that the opposite of lower is lift, because raise is a keyword in Python):

	viewport.coords(ticker, 550, 75)
	viewport.lower(ticker)

Anyway, before I show you how to add the occasional menu and button (briefly, because anything more would take a whole book), let's talk a moment about resizing the window. You can query the game's size at any time with viewport.winfo_width() and viewport.winfo_height() (only after the main loop starts), or you can handle the Configure event:

	viewport.bind("<Configure>", lambda e: viewport.itemconfigure(
		ticker, text="size: {0}x{1}".format(e.width, e.height)))

Beware that it fires a lot, not just when you resize the window, and if you redraw the screen every time, that's going to show on older computers for anything more than a couple hundred items or so. The canvas widget has a scale method, but it's not so good for this use case, either. If all this seems too complicated, you might want to just make the window fixed-size, like this:

	top.resizable(0, 0)

Now for something entirely different: how do you prevent the player from accidentally closing the window just when the game was going well? For that, you first need to import another module:

	from tkinter.messagebox import askyesno, showinfo

works in Python 3, and in Python 2 you do instead:

	from tkMessageBox import askyesno, showinfo

Either way, now you can write a function to handle it:

	def confirmed_quit(window):
		answer = askyesno(
			parent=window, message="Quit game?",
			title="Are you sure?", icon="question")
		if answer:
			window.destroy()

	top.bind("<Key-Escape>", lambda event: confirmed_quit(top))
	top.protocol("WM_DELETE_WINDOW", lambda: confirmed_quit(top))

That's how you can do key events, too; note how closing the window works differently, but to us it's the same thing in the end.

Last but not least, let's see how to make and show a menu. Just one, and small at that:

	game_menu = Menu(top, tearoff=0)
	game_menu.add_command(
		label="Help", underline=0, accelerator="F1",
		command=lambda:showinfo(
			"Help", "See below", parent=top))
	game_menu.add_separator()
	game_menu.add_command(
		label="Quit", underline=0, accelerator="Ctrl-Q",
		command=lambda:confirmed_quit(top))

To show it off, the most obvious way is via right-click:

	top.bind('<3>', lambda e: game_menu.post(e.x_root, e.y_root))

Beware that on Macs that would be button 2 instead. Also note the use of x_root and y_root instead of the usual x and y, because the post method of a menu expects screen-absolute coordinates. Finally, the accelerator keys are just there for the show, you need to do the actual key bindings yourself:

	top.bind("<Key-F1>", lambda event: showinfo(
		"Help", "See below", parent=top))
	top.bind("<Control-q>", lambda event: confirmed_quit(top))

The trouble with that is, people would have to guess the game has a context menu (or read the manual). A better solution is to have a button on screen that they can see right away. And for that we first need to import another module:

	from tkinter import ttk # For Python 3
	# or
	import ttk # For Python 2

We could do without that step, but this way we get modern, native widgets on all platforms, better-looking and more accessible:

	menu_button = ttk.Menubutton(
		viewport, text="Game", underline=0, menu=game_menu)
	viewport.create_window(32, 32, window=menu_button, anchor="nw")
	top.bind("<Key-F10>",
		lambda event: menu_button.event_generate("<<Invoke>>"))
	top.bind("<Key-Menu>",
		lambda event: menu_button.event_generate("<<Invoke>>"))

Note how we declare the menu button as subordinate to the canvas widget, so we can put it there, and not in some toolbar. It otherwise behaves like any other canvas item. And of course you can also have an ordinary button that does something itself when pressed:

	help_button = ttk.Button(
		viewport, text="Help", underline=0,
		image=ihelp, compound="left",
		command=lambda: showinfo(
			"Help", "See below", parent=top))
	viewport.create_window(
		800 - 32, 600 - 32,
		window=help_button, anchor="se")

Without the compound argument, the image would replace the button text entirely (but it's still a good idea to leave it there). And of course you'd need to see about repositioning the button when the window resizes -- the price we pay for not going into the much more complex topic of GUI layouts.

But you can learn all that and more from tkdocs.com -- an ample online book that also covers more languages (Tcl, Perl and Ruby) and points to the reference documentation where suitable. Speaking of which, Python's reference manual links to even more tutorials you can use as a starting point. Hope this helps!