nullprogram.com/blog/2014/10/19/
For more than a decade now, Emacs has come with a built-in Tetris
clone, originally written by XEmacs’ Glynn Clements. Just run M-x
tetris
any time you want to play. For anyone too busy to waste time
playing Tetris, earlier this year I wrote an autotetris-mode that will
play the Emacs game automatically.
Load the source, autotetris-mode.el
and M-x autotetris
. It will
start the built-in Tetris but make all the moves itself. It works best
when byte compiled.
At the time I had read an article and was interested in trying
my hand at my own Tetris AI. Like most things Emacs, the built-in
Tetris game is very hackable. It’s also pretty simple and easy to
understand. Rather than write my own I chose to build upon this one.
Heuristics
It’s not a particularly strong AI. It doesn’t pay attention to the
next piece in queue, it doesn’t know the game’s basic shapes, and it
doesn’t try to maximize the score (clearing multiple rows at once).
The goal is to continue running for as long as possible. But since
it’s able to get to the point where the game is so fast that the AI is
unable to move pieces fast enough (it’s rate limited like a human
player), that means it’s good enough.
When a new piece appears at the top of the screen, the AI, in memory,
tries placing it in all possible positions and all possible
orientations. For each of these positions it runs a heuristic on the
resulting game state, summing five metrics. Each metric is scaled by a
hand-tuned weight to adjust its relative priority. Smaller is better,
so the position with the lowest score is selected.
Number of Holes
A hole is any open space that has a solid block above it, even if that
hole is accessible without passing through a solid block. Count these
holes.
Maximum Height
Add the height of the tallest column. Column height includes any holes
in the column. The game ends when a column touches the top of the
screen (or something like that), so this should be kept in check.
Mean Height
Add the mean height of all columns. The higher this is, the closer we
are to losing the game. Since each row will have at least one hole,
this will be a similar measure to the hole count.
Height Disparity
Add the difference between the shortest column height and the tallest
column height. If this number is large it means we’re not making
effective use of the playing area. It also discourages the AI from
getting into that annoying situation we all remember: when you
really need a 4x1 piece that never seems to come. Those are the
brief moments when I truly believe the version I’m playing has to be
rigged.
Surface Roughness
Take the root mean square of the column heights. A rougher surface
leaves fewer options when placing pieces. This measure will be similar
to the disparity measurement.
Emacs-specific Details
With a position selected, the AI sends player inputs at a limited rate
to the game itself, moving the piece into place. This is done by
calling tetris-move-right
, tetris-move-left
, and
tetris-rotate-next
, which, in the normal game, are bound to the
arrow keys.
The built-in tetris-mode isn’t quite designed for this kind of
extension, so it needs a little bit of help. I defined two pieces of
advice to create hooks. These hooks alert my AI to two specific events
in the game: the game start and a fresh, new piece.
(defadvice tetris-new-shape (after autotetris-new-shape-hook activate)
(run-hooks 'autotetris-new-shape-hook))
(defadvice tetris-start-game (after autotetris-start-game-hook activate)
(run-hooks 'autotetris-start-game-hook))
I talked before about the problems with global state.
Fortunately, tetris-mode doesn’t store any game state in global
variables. It stores everything in buffer-local variables, which can
be exploited for use in the AI. To perform the “in memory” heuristic
checks, it creates a copy of the game state and manipulates the copy.
The copy is made by way of clone-buffer
on the *Tetris*
buffer.
The tetris-mode functions all work equally as well on the clone, so I
can use the existing game rules to properly place the next piece in
each available position. The game’s own rules take care of clearing
rows and checking for collisions for me. I wrote an
autotetris-save-excursion
function to handle the messy details.
(defmacro autotetris-save-excursion (&rest body)
"Restore tetris game state after BODY completes."
(declare (indent defun))
`(with-current-buffer tetris-buffer-name
(let ((autotetris-saved (clone-buffer "*Tetris-saved*")))
(unwind-protect
(with-current-buffer autotetris-saved
(kill-local-variable 'kill-buffer-hook)
,@body)
(kill-buffer autotetris-saved)))))
The kill-buffer-hook
variable is also cloned, but I don’t want
tetris-mode to respond to the clone being killed, so I clear out the
hook.
That’s basically all there is to it! While watching it feels like it’s
making dumb mistakes, not placing pieces in optimal positions, but it
recovers well from these situations almost every time, so it must know
what it’s doing. Currently it’s a better player than me, which is my
rule-of-thumb for calling an AI successful.