You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

#### 8.5 KiB Raw Blame History

title categories draft aliases
Textris - Creating a terminal-based Tetris clone - Part 1: Pieces and playfield [tutorial] true [/post/textris-1/]

This is the first in a series of tutorials on creating a terminal-based Tetris clone with Go.

Game pieces (minos) are generated and added to a playfield (matrix).

## Disclaimer

Tetris is a registered trademark of the Tetris Holding, LLC.

Rocket Nine Labs is in no way affiliated with Tetris Holding, LLC.

# Minos

Game pieces are called "minos" because they are polyominos. This tutorial focuses on the seven one-sided terominos, where each piece has four blocks.

The number of blocks a mino has is also known as its rank.

I O T J L S Z
`████`
`████`
` ████`
`████`
`  ████`
` ████`
`██ ██`

## Data model

Tetris is played on an X-Y grid. We will store Minos as slices of points.

{{< highlight go >}} type Point struct { X, Y int }

func (p Point) Rotate90() Point { return Point{p.Y, -p.X} } func (p Point) Rotate180() Point { return Point{-p.X, -p.Y} } func (p Point) Rotate270() Point { return Point{-p.Y, p.X} } func (p Point) Reflect() Point { return Point{-p.X, p.Y} }

type Mino []Point

var minoT = Mino{{0, 0}, {1, 0}, {2, 0}, {1, 1}} {{< / highlight >}}

## Generation

Instead of hard-coding each piece into our game, let's procedurally generate them. This allows us to play with any size of mino.

### Comparing and sorting

To compare minos efficiently while generating, we will compare their string representation. We will define a String method which sorts the coordinates before printing. This allows us to compare duplicate minos just by checking their string values.

{{< highlight go >}} func (m Mino) Len() int { return len(m) } func (m Mino) Swap(i, j int) { m[i], m[j] = m[j], m[i] } func (m Mino) Less(i, j int) bool { return m[i].Y < m[j].Y || (m[i].Y == m[j].Y && m[i].X < m[j].X) }

func (m Mino) String() string { sort.Sort(m)

``````var b strings.Builder
b.Grow(5*len(m) + (len(m) - 1))

for i := range m {
if i > 0 {
b.WriteRune(',')
}

b.WriteRune('(')
b.WriteString(strconv.Itoa(m[i].X))
b.WriteRune(',')
b.WriteString(strconv.Itoa(m[i].Y))
b.WriteRune(')')
}

return b.String()
``````

} {{< / highlight >}}

Origin returns a translated mino located at 0,0 and with positive coordinates only.

{{< highlight go >}} func (m Mino) minCoords() (int, int) { minx := m[0].X miny := m[0].Y

``````for _, p := range m[1:] {
if p.X < minx {
minx = p.X
}
if p.Y < miny {
miny = p.Y
}
}

return minx, miny
``````

}

func (m Mino) Origin() Mino { minx, miny := m.minCoords()

``````newMino := make(Mino, len(m))
for i, p := range m {
newMino[i].X = p.X - minx
newMino[i].Y = p.Y - miny
}

return newMino
``````

} {{< / highlight >}}

Another transformation is applied not only to help identify duplicate minos, but also to retrieve their initial rotation, as pieces should spawn flat-side down.

The flattest side is calculated and a flattened mino is returned.

{{< highlight go >}} func (m Mino) Flatten() Mino { var ( w, h = m.Size() sides [4]int // Left Top Right Bottom ) for i := 0; i < len(m); i++ { if m[i].Y == 0 { sides[3]++ } else if m[i].Y == (h - 1) { sides[1]++ }

``````	if m[i].X == 0 {
sides[0]++
} else if m[i].X == (w - 1) {
sides[2]++
}
}

var (
largestSide   = 3
largestLength = sides[3]
)
for i, s := range sides[:2] {
if s > largestLength {
largestSide = i
largestLength = s
}
}

var rotateFunc func(Point) Point
switch largestSide {
case 0: // Left
rotateFunc = Point.Rotate270
case 1: // Top
rotateFunc = Point.Rotate180
case 2: // Right
rotateFunc = Point.Rotate90
default: // Bottom
return m
}

newMino := make(Mino, len(m))
copy(newMino, m)
for i := 0; i < len(m); i++ {
newMino[i] = rotateFunc(newMino[i])
}

return newMino
``````

} {{< / highlight >}}

Variations returns the three other rotations of a mino.

{{< highlight go >}} func (m Mino) Variations() []Mino { v := make([]Mino, 3) for i := 0; i < 3; i++ { v[i] = make(Mino, len(m)) }

``````for j := 0; j < len(m); j++ {
v[0][j] = m[j].Rotate90()
v[1][j] = m[j].Rotate180()
v[2][j] = m[j].Rotate270()
}

return v
``````

} {{< / highlight >}}

Canonical returns a flattened mino translated to 0,0.

{{< highlight go >}} func (m Mino) Canonical() Mino { var ( ms = m.Origin().String() c = -1 v = m.Origin().Variations() vs string )

``````for i := 0; i < 3; i++ {
vs = v[i].Origin().String()
if vs < ms {
c = i
ms = vs
}
}

if c == -1 {
return m.Origin().Flatten().Origin()
}

return v[c].Origin().Flatten().Origin()
``````

} {{< / highlight >}}

### Generating new minos

Neighborhood returns the Von Neumann neighborhood of a point.

{{< highlight go >}} func (p Point) Neighborhood() []Point { return []Point{ {p.X - 1, p.Y}, {p.X, p.Y - 1}, {p.X + 1, p.Y}, {p.X, p.Y + 1}} } {{< / highlight >}}

NewPoints calculates the neighborhood of each point of a mino and returns only the new points.

{{< highlight go >}} func (m Mino) HasPoint(p Point) bool { for _, mp := range m { if mp == p { return true } }

``````return false
``````

}

func (m Mino) NewPoints() []Point { var newPoints []Point

``````for _, p := range m {
for _, np := range p.Neighborhood() {
if m.HasPoint(np) {
continue
}

newPoints = append(newPoints, np)
}
}

return newPoints
``````

} {{< / highlight >}}

NewMinos returns a new mino for every new neighborhood point of a supplied mino.

{{< highlight go >}} func (m Mino) NewMinos() []Mino { points := m.NewPoints()

``````minos := make([]Mino, len(points))
for i, p := range points {
minos[i] = append(m, p).Canonical()
}

return minos
``````

} {{< / highlight >}}

Generate procedurally generates minos of a supplied rank.

{{< highlight go >}} func Generate(rank int) ([]Mino, error) { switch { case rank < 0: return nil, errors.New("invalid rank") case rank == 0: return []Mino{}, nil case rank == 1: return []Mino{monomino()}, nil default: r, err := Generate(rank - 1) if err != nil { return nil, err }

``````	var (
minos []Mino
s     string
found = make(map[string]bool)
)
for _, mino := range r {
for _, newMino := range mino.NewMinos() {
s = newMino.Canonical().String()
if found[s] {
continue
}

minos = append(minos, newMino.Canonical())
found[s] = true
}
}

return minos, nil
}
``````

}

func monomino() Mino { return Mino{{0, 0}} } {{< / highlight >}}

# Matrix

The matrix is typically 10 blocks wide and 20 blocks high.

## Data model

A block is an integer representing the contents of a single X-Y coordinate on the matrix.

{{< highlight go >}} type Block int

const ( BlockNone Block = iota BlockSolidBlue BlockSolidCyan BlockSolidRed BlockSolidYellow BlockSolidMagenta BlockSolidGreen BlockSolidOrange ) {{< / highlight >}}

The matrix will be stored as a slice of blocks. The zero-value of Block is a blank space.

The matrix has a width, height and buffer height. The buffer is additional space above the visible playfield.

{{< highlight go >}} type Matrix struct { W int // Width H in // Height B int // Buffer height

``````M []Block // Contents
``````

}

func NewMatrix(w int, h int, b int) Matrix { m := Matrix{ W: w, H: h, B: b, M: make([]Block, w(h+b)), }

``````return &m
``````

} {{< / highlight >}}

To retrieve the contents of a point, we calculate its index by multiplying the Y coordinate with the matrix width and adding the X coordinate.

{{< highlight go >}} func I(x int, y int, w int) int { if x < 0 || x >= w || y < 0 { log.Panicf("failed to retrieve index for %d,%d width %d: invalid coordinates", x, y, w) }

``````return (y * w) + x
``````

}

func (m *Matrix) Block(x int, y int) Block { if y >= m.H+m.B { log.Panicf("failed to retrieve block at %d,%d: invalid y coordinate", x, y) }

``````index := I(x, y, m.W)
return m.M[index]
``````

} {{< / highlight >}}