By this point in the Blockr project we have two small pieces of vocabulary. A point is a tuple, {x, y}, and a group is a list of points. We can move a point left, right, and down, then apply the same movement to a whole group.
That works until the thing we’re moving stops being only a group.
A Tetris piece has a shape. It sits somewhere on the board. It has a rotation. It has a color. If those stay as loose values, every movement function has to accept unrelated arguments and hand them all back again:
def left(name, location, rotation, color) do
{name, Point.move_left(location), rotation, color}
end
That code is honest, but it is already tiring. The function only wants to move the location, yet it has to carry the name, rotation, and color through by hand.
That is the problem a struct solves.
Bruce sets it up with an analogy that has nothing to do with Tetris:
“Do you say, ‘Hey everybody, figure out how to get to the restaurant’? No. You collect your family together and you all take a car… that way you get to manage one problem and not three or four little ones. That’s what we’re going to do with our family of points.” — Bruce Tate
The tetromino is the car. The points are the family. You move the container, and you only unpack the points when something actually needs them.
The Container
The data we need to remember is fixed: name, location, rotation, and color. That fixed shape is the signal. When the keys are always the same, you have outgrown a plain map.
Bruce says it directly as he types:
“But since these atoms are always going to be the same, there’s a better data structure for us to use, and that’s called a struct.” — Bruce Tate
defmodule Tetromino do
defstruct name: :i, location: {3, 1}, rotation: 0, color: :red
end
The names come from the pieces themselves: I, L, J, O, T, S, and Z. A :t is easier to talk about than a list of four coordinates, and it gives the rest of the module one field to branch on later.
The important part is not that structs are fancy. They are not. A struct is a map with a known shape and a module name attached. That extra structure buys you pressure in the right places. If you update a key that does not exist, Elixir tells you. If you build a tetromino, the defaults are already there.
The car now has doors, seats, and a name on the registration.
🎯 Join Groxio's Newsletter
Weekly lessons on Elixir, system design, and AI-assisted development -- plus stories from our training and mentoring sessions.
We respect your privacy. No spam, unsubscribe anytime.
Construct, Then Reduce
The first function is the constructor. Its job is small: give us a valid tetromino to start with.
def new do
%__MODULE__{}
end
%__MODULE__{} means %Tetromino{} from inside the module. It keeps the constructor tied to the module it lives in, instead of hardcoding the name twice.
Then come the reducers. Bruce gives the pattern in one sentence:
“Every single one of these is going to take a tetromino and update one little piece of it.” — Bruce Tate
That is the rule to keep. A reducer takes a tetromino, updates one field, and returns a tetromino.
Now the earlier function becomes this:
def left(tetro) do
%{tetro | location: Point.move_left(tetro.location)}
end
The difference is the whole lesson. We are no longer passing four loose values through a function that only cares about one of them. We pass one container, update the field that changed, and let the rest come along untouched.
The same shape handles the other moves:
def right(tetro) do
%{tetro | location: Point.move_right(tetro.location)}
end
def fall(tetro) do
%{tetro | location: Point.move_down(tetro.location)}
end
Map-update syntax is what makes the intent readable: %{tetro | location: ...} means “this same tetromino, with a new location.” The name, rotation, and color are preserved without being mentioned.
Because each reducer receives and returns the same kind of value, the functions pipe naturally:
Tetromino.new()
|> Tetromino.left()
|> Tetromino.left()
#=> %Tetromino{name: :i, location: {1, 1}, rotation: 0, color: :red}
Bruce’s first version had a small bug. He wrote tetro.point, but the field was called location. The error was useful because the struct knew its shape. A loose map might have let a bad key drift into nil; the struct makes the mistake show up at the boundary.
One small syntax trap shows up when moving groups too. If you pass an existing function to Enum.map, capture it:
def move_left(points), do: Enum.map(points, &Point.move_left/1)
Without the &, Elixir reads something else. The compiler is not being mysterious there; it is telling you it needed a function.
Two Ways Around the Circle
Rotation is still a reducer, but it has one extra problem. Turning right adds 90 degrees: 0, 90, 180, 270. The next turn should go back to 0, not 360.
Bruce shows both ways to think about that:
“There’s a way we can solve that with math, and a way we can solve that with a new function head. I want to show you each way.” — Bruce Tate
The pattern-matched version catches the wrap before it happens:
def rotate(%{rotation: 270} = tetro), do: %{tetro | rotation: 0}
def rotate(tetro), do: %{tetro | rotation: tetro.rotation + 90}
That reads like a set of cases. If the rotation is already 270, reset it. Otherwise, add 90.
The arithmetic version removes the special case:
def rotate(tetro), do: %{tetro | rotation: rem(tetro.rotation + 90, 360)}
rem/2 gives the remainder after division, so 360 becomes 0 again. Bruce keeps this version, but both are valid Elixir. The choice is not about proving one style superior. It is about choosing the version that makes the state change easiest to read in this reducer.
What Changes
Once the tetromino becomes a struct, movement stops being a coordination problem. You are not keeping four separate values synchronized anymore. You are taking one known shape, changing one field, and returning the same known shape.
That is the Construct and Reduce half of the pattern: build valid data, then move it forward in small, predictable steps.
We can build a tetromino and push it around. Now we have to turn it back into something we can actually draw: the Convert step. That is the moment the car pulls up and unloads the passengers.
Want to Learn Elixir's Data Shapes by Mental Model?
This post comes from Bruce's structured Elixir course, where structs, reducers, and domain containers are taught as production architecture decisions instead of syntax trivia. Build the mental models that help you keep related state together in real-world Elixir code.
— Paulo & Bruce