Image description

In this post, we’ll walk through the implementation of a lightweight terminal-based tile game inspired by 2048. We'll explore the logic, algorithms, color display in the terminal, and movement mechanics—all crafted in pure Python.

🧩 Overview of What We're Building

  • A grid-based game where tiles with the same number can be merged.
  • Navigation using WASD keys.
  • Auto-generated new tiles after each move.
  • A colorful terminal UI for better visibility.
  • Game-ending logic upon reaching 2048 or running out of moves.

🚀 Features at a Glance

  • Pure Python, no external libraries required.
  • Uses ANSI color codes for terminal coloring.
  • Compatible with both Windows and Unix-based systems.
  • Modular code design using classes, enums, and functions.
  • Supports extensibility and customizations.

🧠 2048 Summary

  • Each round, the player can move tiles in one of four directions.
  • If two adjacent tiles with the same value meet, they merge.
  • A new tile is randomly generated in an empty space after each move.
  • The game ends when there are no valid moves or a tile reaches 2048.

Code Structure and Explanations

📦 1. Imports

import enum
import random

We use enum for cleaner state and action management, and random to add new tiles.

🔡 2. Cross-platform Character Input

def getch() -> str:
    """Gets a single character"""
    try:
        import msvcrt

        return str(msvcrt.getch().decode("utf-8"))  # type: ignore
    except ImportError:
        import sys
        import termios
        import tty

        fd = sys.stdin.fileno()
        oldsettings = termios.tcgetattr(fd)
        try:
            tty.setraw(fd)
            ch = sys.stdin.read(1)
        finally:
            termios.tcsetattr(fd, termios.TCSADRAIN, oldsettings)
        return ch
  • On Windows, it uses msvcrt.
  • On Unix-based systems, it uses termios and tty.

This allows us to read single-character inputs like w, a, etc., in real-time.

🚦 3. Game State and Actions

class State(enum.Enum):
    BLOCK = enum.auto()
    WALL = enum.auto()
    CONTINUE = enum.auto()
    END = enum.auto()


class Action(enum.Enum):
    MOVE_DOWN = "down"
    MOVE_LEFT = "left"
    MOVE_RIGHT = "right"
    MOVE_UP = "up"
    EXIT = "exit"
    NOTHING = "nothing"

We define possible cell states (like wall or end) and player actions (directional movement).

🎨 4. Terminal Coloring

def rgb_bg(r: int, g: int, b: int) -> str:
    return f"\033[48;2;{r};{g};{b}m"


def rgb(r: int, g: int, b: int) -> str:
    return f"\033[38;2;{r};{g};{b}m"


def reset() -> str:
    return "\033[0m"

These helper functions generate ANSI escape codes for colored text and backgrounds.

🌈 5. Tile Colors by Value

value_color = {
    2: f"{rgb(0, 0, 0)}{rgb_bg(255, 255, 255)}",
    4: f"{rgb(0, 0, 0)}{rgb_bg(150, 150, 255)}",
    8: f"{rgb(0, 0, 0)}{rgb_bg(100, 255, 100)}",
    16: f"{rgb(0, 0, 0)}{rgb_bg(255, 100, 100)}",
    32: f"{rgb(0, 0, 0)}{rgb_bg(150, 150, 150)}",
    64: f"{rgb(0, 0, 0)}{rgb_bg(200, 150, 100)}",
    128: f"{rgb(0, 0, 0)}{rgb_bg(255,182,193)}",
    256: f"{rgb(0, 0, 0)}{rgb_bg(255, 255, 0)}",
    512: f"{rgb(0, 0, 0)}{rgb_bg(100, 250, 250)}",
    1024: f"{rgb(0, 0, 0)}{rgb_bg(255, 0, 255)}",
    2048: f"{rgb(0, 0, 0)}{rgb_bg(255, 99, 71)}",
}

Each tile value has a color combo, improving visual feedback in the terminal. This maps values (like 2, 4, 8...) to specific foreground/background color combinations using ANSI escape sequences.

🧱 6. The Cell Class

class Cell:
    def __init__(self, state: State = State.BLOCK, value: int = 0) -> None:
        self.state: State = state
        self.value: int = value
        self.down: "Cell"
        self.up: "Cell"
        self.right: "Cell"
        self.left: "Cell"

    def __str__(self) -> str:
        match self:
            case self if self.state == State.WALL:
                return "🔹"
            case self if self.value:
                value = str(self.value).center(6)[2:4]
                return f"{value_color[self.value]}{value}{reset()}"
            case self if self.right.value:
                value = str(self.right.value).center(6)[:2]
                return f"{value_color[self.right.value]}{value}{reset()}"
            case self if self.left.value:
                value = str(self.left.value).center(6)[4:]
                return f"{value_color[self.left.value]}{value}{reset()}"
        margin_value = (
            self.down.value
            or self.up.value
            or (getattr(self.down.left, "value") if hasattr(self.down, "left") else 0)
            or (getattr(self.down.right, "value") if hasattr(self.down, "right") else 0)
            or (getattr(self.up.right, "value") if hasattr(self.up, "right") else 0)
            or int(getattr(self.up.left, "value") if hasattr(self.up, "left") else 0)
        )
        if margin_value:
            return f"{value_color[int(margin_value)]}  {reset()}"
        return f"{rgb_bg(0, 0, 0)}  {reset()}"

Each cell knows:

  • Its state (block, wall, etc.)
  • Its numeric value (used in merging)
  • References to its four neighbors (left, right, up, down)
  • __str__ is overridden to print the cell with colors and context-aware visuals.

🗺️ 7. The Board Class

class Board:
    def __init__(self, size: int) -> None:
        self.size = size * 3 + 2
        self.player_state: State = State.CONTINUE
        self.cells: list[list[Cell]]
        self.valuable_cells: list[Cell]
        self.set_initial()

    def set_initial(self) -> None:
        self.set_cells()
        self.set_walls()
        self.set_cells_neighboring()
        self.set_valuable_cells()
        self.set_init_value_cell()

    def set_cells(self) -> None:
        self.cells = [[Cell() for _ in range(self.size)] for _ in range(self.size)]

    def set_walls(self) -> None:
        for i in range(self.size):
            for j in [0, self.size - 1]:
                self.cells[i][j].state = State.WALL
                self.cells[j][i].state = State.WALL

    def set_cells_neighboring(self) -> None:
        for i in range(1, self.size - 1):
            for j in range(1, self.size - 1):
                self.cells[i][j].left = self.cells[i][j - 1]
                self.cells[i][j].right = self.cells[i][j + 1]
                self.cells[i][j].up = self.cells[i - 1][j]
                self.cells[i][j].down = self.cells[i + 1][j]

    def set_valuable_cells(self) -> None:
        self.valuable_cells = [
            self.cells[i][j]
            for i in range(2, self.size, 3)
            for j in range(2, self.size, 3)
        ]

    def set_init_value_cell(self) -> None:
        self.valuable_cells[0].value = 2
        self.valuable_cells[-3].value = 2

    def take(self, ch: str) -> None:
        for _ in range(int(pow(len(self.valuable_cells), 0.5))):
            match ch:
                case " ":
                    self.player_state = State.END
                case "w":
                    self.move(Action.MOVE_UP.value)
                case "s":
                    self.move(Action.MOVE_DOWN.value)
                case "a":
                    self.move(Action.MOVE_LEFT.value)
                case "d":
                    self.move(Action.MOVE_RIGHT.value)
        new_valuable_cell = list(filter(lambda c: not c.value, self.valuable_cells))
        if new_valuable_cell:
            random.choice(new_valuable_cell).value = 2
        else:
            self.player_state = State.END

    def move(self, direction: str) -> None:
        for c in self.valuable_cells:
            if getattr(getattr(c, direction), direction).state == State.BLOCK:
                d_cell = getattr(getattr(getattr(c, direction), direction), direction)
                if d_cell.value == c.value:
                    d_cell.value += c.value
                    c.value = 0
                elif c.value and not d_cell.value:
                    d_cell.value = c.value
                    c.value = 0
                if d_cell.value == 2048:
                    self.player_state = State.END
                    break

    def __str__(self) -> str:
        return "\n".join(["".join(map(str, rows)) for rows in self.cells])

This handles the entire game board logic. The board is constructed as a 2D grid with padded walls.

Methods:

  • set_initial: Sets up the board by calling the other setup methods.
  • set_cells: Creates all cells.
  • set_walls: Adds walls to edges.
  • set_cells_neighboring: Links each cell to its 4 neighbors.
  • set_valuable_cells: Marks specific cells on the grid as "valuable" cells — meaning the player can place or merge tile values (like 2, 4, 8, etc.) only in these cells.
  • set_init_value_cell: Adds the first two cells with value 2.
  • take(ch):
    1. Takes user input (w, a, s, d, or space).
    2. Applies movement based on input.
    3. Spawns a new random 2 tile if there's space.
    4. Ends the game if no space.
  • move:
    1. Moves all cells in the given direction by checking and merging cells. Merging is allowed only if the destination cell is the same value.
    2. If 2048 is achieved, the game ends.

🎮 8. The Game Loop

def run() -> None:
    board = Board(4)
    print(f"\033[H\033[J{board}")
    while board.player_state != State.END:
        board.take(getch())
        print(f"\033[H\033[J{board}")

The run() function drives the game:

  • Initializes the board.
  • Clears and redraws the screen on each move.
  • Takes input using getch().

🧪 Try it Yourself!
This program is built for the Python terminal environment using only Python’s standard libraries. It does not depend on any external libraries and is compatible with Python 3.10 and above.

For the complete code, please visit the “oyna” project and then go to the “twenty_forty_eight_2048” section to view the code.

You can run the script by saving it as grid_base.py or download grid_base.py file and executing:

python grid_base.py

Use w, a, s, d to move the tiles. Press SPACE to exit.

🔧 Want to Contribute?
The Oyna project is a fun starting point! You can improve or expand it by:

  • Adding undo functionality.
  • Implementing scoring.
  • Building a GUI version (Tkinter, Pygame, etc.).
  • Creating an AI to play it.
  • Supporting custom board sizes or patterns.

Feel free to fork it and submit a pull request. Let’s make it awesome together! 🚀