Tetris-playing AI the Polylith way - Part 2

Tetris AI

The focus in this second part of the blog series is to showcase the benefits of getting quick feedback when working with code. We'll do this by implementing the removal of complete rows when a Tetris piece is placed on the board.

For example, if we rotate the red piece in the image above and place it in the third position, the two bottom rows should be cleared:

Tetris AI with piece dropped Right arrow Tetris AI with cleared rows

The resulting source code from this second blog post in the series can be found here:

REPL-driven development

If you've read part one of the blog series, you already know that all code will be implemented in both Python and Clojure, so let's start with the latter!

Clojure has something called a REPL (Read Eval Print Loop) that lets you write code in small steps, while getting quick feedback on whether the code works or not.

We'll start by creating a clear-rows namespace in the board component:

▾ tetris-polylith
  ▸ bases
  ▾ components
    ▾ board
      ▾ src
        clear-rows.clj
        core.clj
        interface.clj
      ▸ test
    ▸ piece
  ▸ development
  ▸ projects

Where we add a board row:

(ns tetrisanalyzer.board.clear-rows)

(def row [1 1 1 0 1 1 1 0 1 1])

In Clojure, we only need to compile the code that has changed. Since we've added a new namespace and a row, we need to send the entire namespace to the REPL, usually via a key-shortcut, to get it compiled to Java bytecode.

A complete row contains no empty cells (zeros). We can use the some function to detect the presence of empty cells:

(some zero? row) ;; true

Here at least one empty cell has been found, which means the row is not complete. Let's also test whether we can identify a complete row:

(ns tetrisanalyzer.board.clear-rows)

(def row [1 1 1 1 1 1 1 1 1 1])

(some zero? row) ;; false

Yes, it seems to work!

Now we can create a function from the code:

(ns tetrisanalyzer.board.clear-rows)

(defn incomplete-row? [row]
  (some zero? row))

(comment
  (incomplete-row? [1 1 1 1 1 1 1 0 1 1]) ;; true
  (incomplete-row? [1 1 1 1 1 1 1 1 1 1]) ;; false
  #__)

Here I've added a comment block with a couple of calls to the function. From the development environment, we can now call one function at a time and immediately see the result, while the functions don't run if we reload the namespace. It's quite common in the Clojure world to leave these comment blocks in production code so that functions can be easily called, while also serving as documentation.

We'll clean up the comment block and instead add a board so we have something to test against (commas can be omitted):

(ns tetrisanalyzer.board.clear-rows)

(defn incomplete-row? [row]
  (some zero? row))

(def board [[0 0 0 0 0 0 0 0 0 0]
            [0 0 0 0 0 0 0 0 0 0]
            [1 1 1 1 1 1 1 1 1 1]
            [1 1 1 1 1 1 0 0 1 1]
            [1 0 1 1 1 1 1 1 1 1]
            [1 1 1 1 1 1 1 1 1 1]])

Now we can calculate the rows that should not be removed:

(def remaining-rows (filter incomplete-row? board)) ;; ([0 0 0 0 0 0 0 0 0 0]
                                                    ;;  [0 0 0 0 0 0 0 0 0 0]
                                                    ;;  [1 1 1 1 1 1 0 0 1 1] 
                                                    ;;  [1 0 1 1 1 1 1 1 1 1])

The next step is to create the two empty rows that should replace the removed ones, which we finally put in empty-rows:

(def board-width (count (first board)))
(def board-height (count board))
(def num-cleared-rows (- board-height (count remaining-rows))) ;; 2
(def empty-row (vec (repeat board-width 0))) ;; [0 0 0 0 0 0 0 0 0 0]
(def empty-rows (repeat num-cleared-rows empty-row)) ;; ([0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0])

Here's what the board looks like after complete rows have been removed and new empty replacement rows have been added at the beginning:

(vec (concat empty-rows remaining-rows)) ;; [[0 0 0 0 0 0 0 0 0 0]
                                         ;;  [0 0 0 0 0 0 0 0 0 0]
                                         ;;  [0 0 0 0 0 0 0 0 0 0]
                                         ;;  [0 0 0 0 0 0 0 0 0 0]
                                         ;;  [1 1 1 1 1 1 0 0 1 1]
                                         ;;  [1 0 1 1 1 1 1 1 1 1]]

The concat function combines the two lists and creates a new list with rows, while vec then converts the list to a vector. Note that both vec and concat return immutable data, which is standard for all data structures in Clojure.

Simplify

It occurred to me that we can simplify the code somewhat.

We'll start by making empty-board a bit more readable by adding empty-row:

(ns tetrisanalyzer.board.core)

(defn empty-row [width]
  (vec (repeat width 0)))

(defn empty-board [width height]
  (vec (repeat height (empty-row width))))

Then we can replace:

(def empty-row (vec (repeat board-width 0)))
(def empty-rows (repeat num-cleared-rows empty-row))

With:

(def empty-rows (core/empty-board board-width num-cleared-rows))

Now we can finally use let to combine the different calculation steps into a function:

(ns tetrisanalyzer.board.clear-rows
  (:require [tetrisanalyzer.board.core :as core]))

(defn incomplete-row? [row]
  (some zero? row))

(defn clear-rows [board]
  (let [width (count (first board))
        height (count board)
        remaining-rows (filter incomplete-row? board)
        num-cleared-rows (- height (count remaining-rows))
        empty-rows (core/empty-board width num-cleared-rows)]
    (vec (concat empty-rows remaining-rows))))

Since we've already tested all the subexpressions, there's a good chance that the function will work as expected:

(clear-rows board)  ;; [[0 0 0 0 0 0 0 0 0 0]
                    ;;  [0 0 0 0 0 0 0 0 0 0]
                    ;;  [0 0 0 0 0 0 0 0 0 0]
                    ;;  [0 0 0 0 0 0 0 0 0 0]
                    ;;  [1 1 1 1 1 1 0 0 1 1]
                    ;;  [1 0 1 1 1 1 1 1 1 1]]

And indeed, it looks correct!

We'll finish by creating a test in the new namespace clear-rows-test:

▾ tetris-polylith
  ▸ bases
  ▾ components
    ▸ board
      ▸ src
      ▾ test
        clear-rows-test.clj
        core-test.clj
    ▸ piece
  ▸ development
  ▸ projects
(ns tetrisanalyzer.board.clear-rows-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.board.clear-rows :as sut]))

(deftest clear-two-rows
  (is (= [[0 0 0 0 0 0 0 0 0 0]
          [0 0 0 0 0 0 0 0 0 0]
          [0 0 0 0 0 0 0 0 0 0]
          [0 0 0 0 0 0 0 0 0 0]
          [1 1 1 1 1 1 0 0 1 1]
          [1 0 1 1 1 1 1 1 1 1]]
         (sut/clear-rows [[0 0 0 0 0 0 0 0 0 0]
                          [0 0 0 0 0 0 0 0 0 0]
                          [1 1 1 1 1 1 1 1 1 1]
                          [1 1 1 1 1 1 0 0 1 1]
                          [1 0 1 1 1 1 1 1 1 1]
                          [1 1 1 1 1 1 1 1 1 1]]))))

When we run the test, it shows green and we can thus move on to the Python implementation. But first, a few words about the workflow.

Work faster - in small steps

You might have noticed that we implemented the code before writing the test, and that we didn't write the entire function in one go. Instead, we introduced one small calculation step at a time, which we only put together into a complete function at the end. This allowed us to adjust the solution as our understanding grew, and we didn't need to keep everything in our heads. The brain has its limitations, so it's important that we help it along a bit!

In Clojure, only what has changed is compiled, which usually goes lightning fast. This makes you forget that it's actually a compiled language. You can open any file/namespace in the codebase, and execute a function, perhaps from an existing comment block, and immediately get a response back. Gone is the feeling that something stands between you and the code, in the form of waiting for the compiler to be satisfied.

It's easy to become addicted to this immediate feedback, and the feeling is very similar to working with your hands, for example, when throwing pottery:

Pottery
Me at the pottery wheel

The contact with the clay resembles what you have when working in a REPL, an immediacy that lets you quickly test, adjust, and work toward an intended goal, in real time.

The absence of static typing means the compiler only needs to compile the small change that was just made and nothing else, which is a prerequisite for this fast workflow. Quality is achieved by testing the code often and in small steps, in combination with traditional testing and libraries like malli and spec to validate the data.

In languages that require more extensive compilation, or lack an advanced REPL, it's very common to start by writing a test, both as a way to drive the code forward and to trigger a compilation of the code. In a language like Clojure, you can move forward in even smaller steps, in a fast and controlled way.

Enough about this, and let's switch over to Python instead!

Python

We'll start by trying to get as good a developer experience as possible, similar to what we have in Clojure. There are many good IDEs, but here I'll be using PyCharm.

Much of what's written here comes from this blog post under the heading "Easy setup" (thanks David Vujic!).

Now it's high time to write some Python code, and we'll start by creating the module clear_rows.py:

  ▾ components
    ▾ tetrisanalyzer
      ▾ board
        __init__.py
        clear_rows.py
        copy.py
      ▸ piece
  ▸ test

Then we add the row:

row = [1, 1, 1, 0, 1, 1, 1, 0, 1, 1]

After which we run the shortcut command to send the entire module to the REPL, so it gets loaded (output from the REPL):

In [1]: runfile('/Users/tengstrand/source/tetrisanalyzer/langs/python/tetris-polylith-uv/components/tetrisanalyzer/board/clear_rows.py', wdir='/Users/tengstrand/source/tetrisanalyzer/langs/python/tetris-polylith-uv/components/tetrisanalyzer/board')

Now we can select row in the editor and send it to the REPL:

In [2]: row
Out[2]: [1, 1, 1, 0, 1, 1, 1, 0, 1, 1]

Through the REPL, we now have a convenient way to interact with the compiled code even in Python!

Let's translate the following line from Clojure to Python:

(some zero? row)

By adding the following line to clear_rows.py:

0 in row

Now we can select the line and send it to the REPL, which is an alternative to loading the entire module:

In [3]: 0 in row
Out[3]: True

Then we change row and test again:

row = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

0 in row
In [4]: 0 in row
Out[4]: False

It seems to work! Time to create a function from the code, and test run it:

def is_incomplete(row):
    return 0 in row

row = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

is_incomplete(row)
In [5]: is_incomplete(row)
Out[5]: False

Then I update row and test again:

row = [1, 1, 1, 0, 1, 1, 1, 1, 1, 1]

is_incomplete(row)
In [6]: is_incomplete(row)
Out[6]: True

It looks like it works!

Now we'll add a board to the module, so we have something to test against:

def is_incomplete(row):
    return 0 in row


board = [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
         [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
         [1, 1, 1, 1, 1, 1, 0, 0, 1, 1],
         [1, 0, 1, 1, 1, 1, 1, 1, 1, 1],
         [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]

In Clojure we can filter out incomplete rows like this:

(filter incomplete-row? board)

This is written most simply like this in Python:

[row for row in board if is_incomplete(row)]

The statement is a list comprehension that creates a new list by iterating over board and keeping only rows where is_incomplete returns True.

Let's test run the expression:

In [7]: [row for row in board if is_incomplete(row)]
Out[7]: 
[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
 [1, 1, 1, 1, 1, 1, 0, 0, 1, 1],
 [1, 0, 1, 1, 1, 1, 1, 1, 1, 1]]

It works!

Before for we have row, which is what we iterate over:

[row for row in board if is_incomplete(row)]

Python also allows us to do a calculation for each row, which can be exemplified with:

[row + [9] for row in board if is_incomplete(row)]

Which adds 9 to the end of each row:

[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9],
 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9],
 [1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 9],
 [1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 9]]

Let's return to the original version and assign it to remaining_rows:

def is_incomplete(row):
    return 0 in row

board = [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
         [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
         [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
         [1, 1, 1, 1, 1, 1, 0, 0, 1, 1],
         [1, 0, 1, 1, 1, 1, 1, 1, 1, 1],
         [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]

remaining_rows = [row for row in board if is_incomplete(row)]

Before we continue, let's do the same refactoring of empty_row in core.py as we did in Clojure:

def empty_row(width):
    return [0] * width


def empty_board(width, height):
    return [empty_row(width) for _ in range(height)]

We continue by translating this Clojure code:

(def width (count (first board)))
(def height (count board))
(def remaining-rows (filter incomplete-row? board))
(def num-cleared-rows (- height (count remaining-rows))) ;; 2
(def empty-rows (core/empty-board width num-cleared-rows) ;; ([0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0])
(vec (concat empty-rows remaining-rows)) ;; [[0 0 0 0 0 0 0 0 0 0]
                                         ;;  [0 0 0 0 0 0 0 0 0 0]
                                         ;;  [0 0 0 0 0 0 0 0 0 0]
                                         ;;  [0 0 0 0 0 0 0 0 0 0]
                                         ;;  [1 1 1 1 1 1 1 0 1 1]
                                         ;;  [1 0 1 1 1 1 1 1 1 1]]

Till Python:

width = len(board[0])
height = len(board)
num_cleared_rows = height - len(remaining_rows) # 2
empty_rows = empty_board(width, num_cleared_rows) # [[0,0,0,0,0,0,0,0,0,0], [0,0,0,0,0,0,0,0,0,0]]
empty_rows + remaining_rows # [[0,0,0,0,0,0,0,0,0,0],
                            #  [0,0,0,0,0,0,0,0,0,0],
                            #  [0,0,0,0,0,0,0,0,0,0],
                            #  [0,0,0,0,0,0,0,0,0,0],
                            #  [1,1,1,1,1,1,0,0,1,1],
                            #  [1,0,1,1,1,1,1,1,1,1]]

I've deliberately copied the functional style from Clojure to Python, and as you can see it works excellently in Python too, but with a caveat.

Mutability

At one point, part of the Clojure code looked like this:

(def empty-row (vec (repeat board-width 0)))
(def empty-rows (repeat num-cleared-rows empty-row)])

Which I translated to:

empty_row = [0 for _ in range(board_width)]
empty_rows = [empty_row for _ in range(num_cleared_rows)]

The problem with the Python code is that empty_rows refers to one and the same empty_row, and if the latter is changed, all rows in empty_rows change, which becomes a problem if num_cleared_rows is greater than one.

In the new solution, we instead create completely new rows in Python, while in Clojure we can share the same row since it's immutable. The fact that everything is immutable in Clojure is a big advantage when we let data flow through the system, as it prevents data from spreading uncontrollably to other parts further down in the data flow.

Putting it together

Let's put everything together into a function:

from tetrisanalyzer.board.core import empty_board


def is_incomplete(row):
    return 0 in row


def clear_rows(board):
    width = len(board[0])
    height = len(board)
    remaining_rows = [row for row in board if is_incomplete(row)]
    num_cleared_rows = height - len(remaining_rows)
    empty_rows = empty_board(width, num_cleared_rows)
    return empty_rows + remaining_rows

Now we can test run it. Note that we've removed board from the source file, but the REPL still remembers it from earlier:

clear_rows(board)
In [8]: clear_rows(board)
Out[8]: 
[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
 [1, 1, 1, 1, 1, 1, 0, 0, 1, 1],
 [1, 0, 1, 1, 1, 1, 1, 1, 1, 1]]

It looks correct!

Before we add a test, we need to expose the clear_rows function in the board interface, by updating components/tetrisanalyzer/board/__init__.py (and sending the module to the REPL):

from tetrisanalyzer.board.clear_rows import clear_rows
from tetrisanalyzer.board.core import empty_board, set_cell, set_piece


__all__ = ["empty_board", "set_cell", "set_piece", "clear_rows"]

Finally, we'll add the test test_clear_rows.py to the board component:

  ▾ components
    ▾ tetrisanalyzer
      ▸ board
      ▸ piece
  ▾ test
    ▾ components
      ▾ tetrisanalyzer
        ▾ board
          __init__.py
          test_clear_rows.py
          test_core.py
        ▸ piece
from tetrisanalyzer import board


def test_clear_rows():
    input = [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
             [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
             [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
             [1, 1, 1, 1, 1, 1, 0, 0, 1, 1],
             [1, 0, 1, 1, 1, 1, 1, 1, 1, 1],
             [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]]
    
    expected = [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                [1, 1, 1, 1, 1, 1, 0, 0, 1, 1],
                [1, 0, 1, 1, 1, 1, 1, 1, 1, 1]]
    
    assert expected == board.clear_rows(input)

Now we can run all tests with uv run pytest:

============================================ test session starts ============================================
platform darwin -- Python 3.13.11, pytest-9.0.2, pluggy-1.6.0
rootdir: /Users/tengstrand/source/tetrisanalyzer/langs/python/tetris-polylith-uv
configfile: pyproject.toml
collected 3 items

test/components/tetrisanalyzer/board/test_clear_rows.py .                                             [ 33%]
test/components/tetrisanalyzer/board/test_core.py ..                                                  [100%]

============================================= 3 passed in 0.01s =============================================

It works!

Summary

I've deliberately kept the Python code functional, partly to make it easier to compare with Clojure, but also because I like the simplicity of functional programming. We also learned that we needed to be careful when working with mutable data!

The key takeaway: working in smaller steps helps us move faster!

Happy Coding!

Published: 2026-01-11

Tagged: clojure polylith tetris ai python