Tetris playing AI using Polylith - Part 1

Tetris AI

In this blog series, I will implement a self-playing Tetris program in Clojure and Python using the Polylith architecture and compare the two solutions along the way.

The goal

The goal for this first post is to place a T piece on a Tetris board (represented by a two-dimensional array):

[[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]
 [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]
 [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]
 [0 0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 T 0 0 0]
 [0 0 0 0 0 T T T 0 0]]]

We will put the code in the piece and board components in a Polylith workspace (output from the info command):

Poly info output

This will not be a complete guide to Polylith, Clojure, or Python, but I will explain the most important parts and refer to relevant documentation when needed.

The resulting source code from this blog post (part 1) can be found here:

Workspace

We begin by installing the poly command line tool for Clojure, which we will use when working with the Polylith codebase:

brew install polyfy/polylith/poly

The next step is to create a Polylith workspace:

poly create workspace name:tetris-polylith top-ns:tetrisanalyzer

We now have a standard Polylith workspace for Clojure in place:

▾ tetris-polylith
  ▸ bases
  ▸ components
  ▸ development
  ▸ projects
  deps.edn
  workspace.edn

Python

We will use uv as package manager for Python (see setup for other alternatives). First we install uv:

curl -LsSf https://astral.sh/uv/install.sh | sh

Then we create the tetris-polylith-uv workspace directory, by executing:

uv init tetris-polylith-uv
cd tetris-polylith-uv
uv add polylith-cli --dev
uv sync

which creates:

README.md
main.py
pyproject.toml
uv.lock

Finally we create the standard Polylith workspace structure:

uv run poly create workspace --name tetrisanalyzer --theme loose

which adds:

▾ tetris-polylith-uv
  ▸ bases
  ▸ components
  ▸ development
  ▸ projects
  workspace.toml

The workspace requires some additional manual steps, documented here.

The piece component

Now we are ready to create our first component for the Clojure codebase:

poly create component name:piece

This adds the piece component to the workspace structure:

  ▾ components
    ▾ piece
      ▾ src
        ▾ tetrisanalyzer
          ▾ piece
            interface.clj
            core.clj
      ▾ test
        ▾ tetrisanalyzer
          ▾ piece
            interface-test.clj

If you have used Polylith with Clojure before, you know that you also need to manually add piece to deps.edn, which is described here.

Python

Let's do the same for Python:

uv run poly create component --name piece

This adds the piece component to the structure:

  ▾ components
    ▾ tetrisanalyzer
      ▾ piece
        __init__.py
        core.py
  ▾ test
    ▾ components
      ▾ tetrisanalyzer
        ▾ piece
          __init__.py
          test_core.py

Piece shapes

In Tetris, there are 7 different pieces that can be rotated, summing up to 19 shapes:

Pieces

Here we will store them in a multi-dimensional array where each possible piece shape is made up of four [x,y] cells, with [0,0] representing the upper left corner.

For example the Z piece in its inital position (rotation 0) consists of the cells [0,0] [1,0] [1,1] [2,1]:

Z piece

This is how it looks like in Clojure (commas are treated as white spaces in Clojure and are often omitted):

(ns tetrisanalyzer.piece.piece)

(def pieces [nil

             ;; I (1)
             [[[0 0] [1 0] [2 0] [3 0]]
              [[0 0] [0 1] [0 2] [0 3]]]

             ;; Z (2)
             [[[0 0] [1 0] [1 1] [2 1]]
              [[1 0] [0 1] [1 1] [0 2]]]

             ;; S (3)
             [[[1 0] [2 0] [0 1] [1 1]]
              [[0 0] [0 1] [1 1] [1 2]]]

             ;; J (4)
             [[[0 0] [1 0] [2 0] [2 1]]
              [[0 0] [1 0] [0 1] [0 2]]
              [[0 0] [0 1] [1 1] [2 1]]
              [[1 0] [1 1] [0 2] [1 2]]]

             ;; L (5)
             [[[0 0] [1 0] [2 0] [0 1]]
              [[0 0] [0 1] [0 2] [1 2]]
              [[2 0] [0 1] [1 1] [2 1]]
              [[0 0] [1 0] [1 1] [1 2]]]

             ;; T (6)
             [[[0 0] [1 0] [2 0] [1 1]]
              [[0 0] [0 1] [1 1] [0 2]]
              [[1 0] [0 1] [1 1] [2 1]]
              [[1 0] [0 1] [1 1] [1 2]]]

             ;; O (7)
             [[[0 0] [1 0] [0 1] [1 1]]]])

Python

Here is how it looks in Python:

pieces = [None,

          # I (1)
          [[[0, 0], [1, 0], [2, 0], [3, 0]],
           [[0, 0], [0, 1], [0, 2], [0, 3]]],

          # Z (2)
          [[[0, 0], [1, 0], [1, 1], [2, 1]],
           [[1, 0], [0, 1], [1, 1], [0, 2]]],

          # S (3)
          [[[1, 0], [2, 0], [0, 1], [1, 1]],
           [[0, 0], [0, 1], [1, 1], [1, 2]]],

          # J (4)
          [[[0, 0], [1, 0], [2, 0], [2, 1]],
           [[0, 0], [1, 0], [0, 1], [0, 2]],
           [[0, 0], [0, 1], [1, 1], [2, 1]],
           [[1, 0], [1, 1], [0, 2], [1, 2]]],

          # L (5)
          [[[0, 0], [1, 0], [2, 0], [0, 1]],
           [[0, 0], [0, 1], [0, 2], [1, 2]],
           [[2, 0], [0, 1], [1, 1], [2, 1]],
           [[0, 0], [1, 0], [1, 1], [1, 2]]],

          # T (6)
          [[[0, 0], [1, 0], [2, 0], [1, 1]],
           [[0, 0], [0, 1], [1, 1], [0, 2]],
           [[1, 0], [0, 1], [1, 1], [2, 1]],
           [[1, 0], [0, 1], [1, 1], [1, 2]]],

          # O (7)
          [[[0, 0], [1, 0], [0, 1], [1, 1]]]]

In Clojure we had to specify the namespace at the top of the file, but in Python, the namespace is implicitly given based on the directory hierarchy.

Here we put the above code in shape.py, and it will therefore automatically belong to the tetrisanalyzer.piece.shape module:

▾ tetris-polylith-uv
  ▾ components
    ▾ tetrisanalyzer
      ▾ piece
        __init__.py
        shape.py

Interface

In Polylith, only what's in the component's interface is exposed to the rest of the codebase.

In Python, we can optionally control what gets exposed in wildcard imports (from module import *) by defining the __all__ variable in the __init__.py module. However, even without __all__, all public names (those not starting with _) are still accessible through explicit imports.

This is how the piece interface in __init__.py looks like:

from tetrisanalyzer.piece.core import I, Z, S, J, L, T, O, piece

__all__ = ["I", "Z", "S", "J", "L", "T", "O", "piece"]

We could have put all the code directly in __init__.py, but it's a common pattern in Python to keep this module clean by delegating to implementation modules like core.py:

from tetrisanalyzer.piece import shape

I = 1
Z = 2
S = 3
J = 4
L = 5
T = 6
O = 7

def piece(p, rotation):
    return shape.pieces[p][rotation]

The piece component now has these files:

▾ tetris-polylith-uv
  ▾ components
    ▾ tetrisanalyzer
      ▾ piece
        __init__.py
        core.py
        shape.py

Clojure

In Clojure, the interface is often just a single namespace with the name interface:

  ▾ components
    ▾ piece
      ▾ src
        ▾ tetrisanalyzer
          ▾ piece
            interface.clj

which is implemented like this:

(ns tetrisanalyzer.piece.interface
  (:require [tetrisanalyzer.piece.shape :as shape]))

(def I 1)
(def Z 2)
(def S 3)
(def J 4)
(def L 5)
(def T 6)
(def O 7)

(defn piece [p rotation]
  (get-in shape/pieces [p rotation]))

A language comparision

Let's see what differences there are in the two languages:

;; Clojure
(defn piece [p rotation]
  (get-in shape/pieces [p rotation]))
# Python
def piece(p, rotation):
    return shape.pieces[p][rotation]

An obvious difference here is that Clojure is a Lisp dialect, while Python uses a more traditional syntax. This means that if you want anything to happen in Clojure, you put it first in a list:

Another significant difference is that data is immutable in Clojure, while in Python it's mutable (like the pieces data structure).

However, a similarity is that both languages are dynamically typed, but uses concrete types in the compiled code:

;; Clojure
(class \Z) ;; Returns java.lang.Character
(class 2)  ;; Returns java.lang.Long
(class Z)  ;; Returns java.lang.Long (since Z is bound to 2)
# Python
type('Z')  # Returns <class 'str'> (characters are strings in Python)
type(2)    # Returns <class 'int'>
type(Z)    # Returns <class 'int'> (since Z is bound to 2)

The languages also share another feature: type information can be added optionally. In Clojure, this is done using type hints for Java interop and performance optimization. In Python, type hints (introduced in Python 3.5) can be added using the typing module, though they are not enforced at runtime and are primarily used for static type checking with tools like mypy.

The board component

Now let's continue by creating a board component:

poly create component name:board

Which adds the board component to the workspace:

▾ tetris-polylith
  ▸ bases
  ▾ components
    ▸ board
    ▸ piece
  ▸ development
  ▸ projects

The Clojure code that places a piece on the board is implemented like this:

(ns tetrisanalyzer.board.core)

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

(defn set-cell [board p x y [cx cy]]
  (assoc-in board [(+ y cy) (+ x cx)] p))

(defn set-piece [board p x y piece]
  (reduce (fn [board cell]
            (set-cell board p x y cell))
          board
          piece))

For Clojure newcomers: the last parameter of set-cell uses destructuring to extract the first two elements from the vector argument [cx cy] into cx and cy.

Python

Now let's create the board component for Python:

uv run poly create component --name board

This adds the board component to the workspace:

  ▾ components
    ▾ tetrisanalyzer
      ▸ board
      ▸ piece
  ▾ test
    ▾ components
      ▾ tetrisanalyzer
        ▸ board
        ▸ piece

The implementation looks like this in Python:

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

def set_cell(board, p, x, y, cell):
    cx, cy = cell
    board[y + cy][x + cx] = p

def set_piece(board, p, x, y, piece):
    for cell in piece:
        set_cell(board, p, x, y, cell)
    return board

The test looks like this:

(ns tetrisanalyzer.board.core-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.piece.interface :as piece]
            [tetrisanalyzer.board.core :as board]))

(def empty-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]
                  [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]
                  [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]
                  [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]])

(deftest empty-board-test
  (is (= empty-board
         (board/empty-board 10 15))))

(deftest set-piece-test
  (let [T piece/T
        piece-t (piece/piece T 2)
        x 5
        y 13]
    (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]
            [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]
            [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]
            [0 0 0 0 0 0 0 0 0 0]
            [0 0 0 0 0 0 T 0 0 0]
            [0 0 0 0 0 T T T 0 0]]
           (board/set-piece empty-board T x y piece-t)))))

Let's execute the tests to check that everything works as expected:

poly test :dev
Poly test output

The tests passed!

Python

Now, let's add a Python test for the board:

import unittest
from tetrisanalyzer import piece
from tetrisanalyzer.board import board

empty_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],
               [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],
               [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],
               [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]]

class BoardTest(unittest.TestCase):
    def test_empty_board(self):
        self.assertEqual(empty_board, board.empty_board(10, 15))

    def test_set_piece(self):
        T = piece.T
        piece_t = piece.piece(T, 2)
        x = 5
        y = 13
        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],
                    [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],
                    [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],
                    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    [0, 0, 0, 0, 0, 0, T, 0, 0, 0],
                    [0, 0, 0, 0, 0, T, T, T, 0, 0]]
        self.assertEqual(expected, board.set_piece(empty_board, T, x, y, piece_t))

We could run the tests using Python's built-in unittest framework like this:

uv run python -m unittest discover -s test -p "test_*.py" -v
Python test output

However, pytest is a popular alternative, so let's install it:

uv add pytest --dev

And run the tests:

uv run pytest
Pytest output

With that, we have finished the first post in this blog series.

If you're eager to see a self-playing Tetris program, I happen to have made a couple in other languages that you can watch here.

Tetris Analyzer Scala
Tetris Analyzer C++
Tetris Analyzer Tool

Happy Coding!

Published: 2025-12-17

Tagged: clojure polylith tetris ai python