Embercode
✕
Pattern PrintingLesson 15 of 15

45 min read

Checkerboard and grid art in Python

Why this matters

A grid is a rectangle of cells, with rows going across and columns going down. A chess board, a spreadsheet, a seating plan and a game map are all grids. When you print grid art, you answer one question for every cell: what character goes here? The hollow patterns lesson asked that question with one number at a time, the row or the column. Here the answer depends on two numbers together, and that is the new skill.

Grid art also trains a habit that programmers use all the time. You write one small rule for a single cell, then let two loops apply that rule to every cell. If the rule is right, the whole picture is right. If it is wrong, the mistake shows up in every cell, so you can see it quickly and fix it in one place.

The idea: alternate by adding the row and the column

A checkerboard has two colours that alternate. Each cell touches its neighbours on the left, right, above and below, and each of those neighbours has the other colour. Your job is to find a number for each cell that changes every time you move one step.

Number the rows and columns from 0. The first row is row 0, and the first column is column 0. Add the two numbers for each cell:

row 0:   0 1 2 3
row 1:   1 2 3 4
row 2:   2 3 4 5
row 3:   3 4 5 6

Moving one step right adds 1 to the column, so the sum goes up by 1. Moving one step down adds 1 to the row, so the sum also goes up by 1. Every step changes the sum by exactly 1, which means the sum switches between even and odd on every step. Even and odd are the two values that the word parity describes. Two neighbours always have different parity.

That gives the rule. Give one character to the cells whose sum is even, and another character to the cells whose sum is odd. In Python, the test for an even number is (row + col) % 2 == 0. The symbol % is the remainder operator. It returns what is left over after division, so 7 % 2 is 1 and 8 % 2 is 0. A remainder of 0 when you divide by 2 means the number is even.

Diagonal neighbours are different. A cell and the one down and to the right both change the sum by 2 (1 for the row plus 1 for the column), so they have the same parity and the same colour. That matches a real chess board, where the squares on a diagonal share a colour.

Worked example 1: a four by four board

Start with the smallest board that shows the pattern clearly. You need two nested loops, the same shape as the loops in the earlier pattern lessons. The outer loop moves down the rows. The inner loop moves across the columns. For each cell, the if statement picks # or ..

range(size) produces the numbers 0, 1, 2 and up to size - 1. It stops before size, so for row in range(4) runs with row set to 0, 1, 2 and 3. Stopping before the end is the same rule that the row < size test follows in other languages.

Python · editable

Three things appear in this code. line is a list, a collection that holds items in order. The method append adds one item to the end of it. "".join(line) glues the items together into one string, with nothing between them. Python has no separate character type. "#" is a string that happens to hold one symbol, and it has the same type as "abc". You can write it with single or double quotes, and this lesson uses double quotes.

The inner loop builds one whole line in the list. When the line is complete, print shows it and moves to a new line. Building a full row before printing it keeps the output tidy, and it lets you return the row as a value later, which the next examples do.

Save the program as board4.py and run it with python3 board4.py. The output is:

#.#.
.#.#
#.#.
.#.#

Check it against the sums. Cell (0, 0) has sum 0, which is even, so it is #. Cell (0, 1) has sum 1, so it is .. Cell (1, 0) also has sum 1, so it is ., which is why the second row starts with a dot. The pattern alternates across each row and down each column.

Worked example 2: an eight by eight board with functions

A chess board is eight by eight, and the sample programs so far have put the whole rule inside the main code. For a longer program, it helps to give each job its own function. A function is a named block of code that can be called from other code. It can take inputs, such as row and col, and it can return a value to the caller with return.

This version has three parts. The function cell holds the rule and returns one string. The function board_row builds one row of the board, with a space between cells so the board looks less cramped. The last part calls board_row once for each row.

Python · editable

The words after the colons, such as row: int and -> str, are type hints. They say that row should be a whole number and that cell returns a string. Python does not check them while the program runs, so a wrong hint does not stop the program. Editors and tools that read the hints can warn you before you run it.

The last two lines call main only when you run this file directly. If another file imports this one, main does not run, and the functions can be reused. This is the usual Python way to write a program with a main part.

" ".join(pieces) works like the join in the first example, but it puts one space between the items. board_row makes a list of single cells first, then joins them. The same row can also be written in one line:

def board_row(row: int, size: int) -> str:
    return " ".join("#" if (row + col) % 2 == 0 else "." for col in range(size))

The part "#" if (row + col) % 2 == 0 else "." is a conditional expression. It works like an if and else that produces one value. It is useful inside a larger expression, such as the join above.

The output is:

# . # . # . # .
. # . # . # . #
# . # . # . # .
. # . # . # . #
# . # . # . # .
. # . # . # . #
# . # . # . # .
. # . # . # . #

The split into functions has a practical benefit. The rule now lives in cell, and you can test it without reading any printed output. For example, cell(0, 0) must be "#", and cell(0, 1) must be ".". If you later want to change the characters, you change one line instead of two.

Worked example 3: a box with a border and a fill

Now the grid art changes from a rule about parity to a rule about position. A box is a rectangle with a border of one character and a fill of another character inside. A cell is on the border when it is in the first row, or the last row, or the first column, or the last column. Every other cell is inside, so it gets the fill.

This is the same border test as the hollow square, with the width and height kept separate. A square has one size, but a box can be wider than it is tall. The test uses height - 1 for the last row and width - 1 for the last column, because the rows and columns are numbered from 0.

Python · editable

The condition is split over two lines. Python allows a line break inside parentheses, so the condition stays readable without a backslash at the end of the line. The function adds "\n" at the end of each row. That is the newline character, so the returned string already contains the line breaks.

print adds a newline after its text by default. The box already ends each row with a newline, so the calls use end="" to stop print from adding one more. Without it, every box would be followed by an empty line. The line print("---") uses the default, because it has no newline of its own.

The output is:

######
#....#
#....#
######
---
#
#
#
---
*****
*****
---

Each of the three calls tests a different edge case. The first is a normal box with a visible fill. The second has width 1, so every cell is on the left edge and the right edge at the same time. The output is a single column of #, with no fill at all. The third has height 2, so there is no row in the middle. The output has two rows of border and nothing inside. Those edge cases are where box code usually breaks, so test them first.

Worked example 4: a framed checkerboard

This example combines the two ideas. The frame is a border of + characters, and the inside is a checkerboard. The interior has inner cells in each direction, and the whole frame is inner + 2 cells wide, because the border takes one cell on each side.

The interior cells need board coordinates that start at 0. The frame coordinates start at 0 too, but the first interior cell is at frame position (1, 1). So the code passes row - 1 and col - 1 to cell, which moves the origin of the board to the first interior cell.

Python · editable

The output is:

++++++
+#.#.+
+.#.#+
+#.#.+
+.#.#+
++++++

Here is a point that surprises many learners. Subtracting 1 from both coordinates does not change the colours in this picture. Shifting the row and the column by the same amount adds the same number to the sum twice, so the parity stays the same. Only shifting one of them changes the colours. That is the reason the mixed-index mistake below flips the board. The subtraction is still worth writing, because it makes the board's own first cell the origin. If the board later needs a different starting colour, the rule for that change sits in cell, and the frame code does not need to change.

Notice that framed tests the border first and sends every other cell to cell. The border is decided by the position in the frame, and the board is decided by the position inside it. Each part of the picture has its own test, and the two tests do not interfere with each other. This is a useful way to design grid art of any size: decide each region, then give each region its own rule.

Common mistakes

Mixing a one-based row with a zero-based column

Many learners start the row at 1 because a person would count the first row as row 1. Start the row at 1 and the column at 0, and the colours flip. Here is the program, with only the row loop changed. range(1, size + 1) produces 1, 2, 3 and 4.

Python · editable

The output is:

.#.#
#.#.
.#.#
#.#.

Both loops have to use the same starting number. If both start at 1, the board is correct, because adding 2 to the sum does not change its parity. Pick one convention, either 0 for both or 1 for both, and use it everywhere in the same program.

Using and instead of or in the border test

The border test needs or, because a cell is on the border if it meets any one of the four edge tests. Replacing the or with and means a cell must be on all four edges at once. Only a one by one box can do that, so in any bigger box the output is blank. Here is the program with the wrong operator on a three by three box:

Python · editable

The output is:

...
...
...

No cell is printed as #, so the picture looks blank. The fix is to change each and to or. Read the condition aloud as a check. "Is it on the top row, or the bottom row, or the left column, or the right column?" That sentence needs "or".

Python reads and and or as plain words, so the code looks almost like the sentence. The conditional expression "#" if border else "." produces one string for each cell, and append puts it in the list.

Treating a string as a single character

Python has no separate character type, so "#" and "##" are both strings. Python does not check how long a string is when you pass it to a function. This makes the mistake quiet. The box function from worked example 3 accepts a two-character border without any error:

Python · editable

The output is:

########
##..##
########

The border cells are two characters wide, but the fill cells are one character wide, so the rows have different lengths. The program ran without an error, so the fix is to check the input yourself. If a function needs exactly one character, test len(border) == 1 at the start and raise an error when it is false.

Mixing a string with a number gives an error that Python does report:

mark = "#"
print(mark + 1)

Python stops and prints a traceback, an error report that names the failing line:

Traceback (most recent call last):
  File "concat_error.py", line 2, in <module>
    print(mark + 1)
          ~~~~~^~~
TypeError: can only concatenate str (not "int") to str

The directory in front of the file name is left out here, as in the other messages in this lesson. The message names both types. A str is text and an int is a whole number. To combine them, convert the number with str(1) first, which turns it into text, or print the two values separately.

Using = in place of ==

A single equals sign stores a value in a variable. Two equals signs compare two values. Writing if row = 0: tries to store 0 in row inside an if line, which Python does not allow. Python stops before the program runs and reports a syntax error, which means the line is not valid Python:

  File "equals_error.py", line 2
    if row = 0:
       ^^^^^^^
SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='?

The directory in front of the file name depends on where you saved the file, so it is left out here. The caret points at the part that Python could not read. The fix is to write row == 0.

Missing colons

Every if, for, def and else line ends with a colon. The colon tells Python that an indented block follows. Here the colon is missing:

row = 0
if row == 0
    print("top")

Python reports:

  File "colon_error.py", line 2
    if row == 0
               ^
SyntaxError: expected ':'

The caret sits right after the condition, which is where the colon belongs. Add the colon, and the line is accepted.

Indentation errors

Python does not use braces to mark blocks. It uses indentation, which means the spaces at the start of a line, to decide which lines belong to a block. Every line in a loop or a function must be indented under its header. If the indentation is missing, Python stops:

size = 3
for col in range(size):
line = "#"

Python reports:

  File "indent_error.py", line 3
    line = "#"
    ^^^^
IndentationError: expected an indented block after 'for' statement on line 2

The error names the header that needs a block. Indent the lines under for by four spaces, and the error goes away. Keep one indentation style in a file. Mixing tabs and spaces causes its own errors.

Off-by-one errors in the last row and column

The last row is height - 1, not height. If you write row == height in the border test, the bottom edge never matches, because no row has that number. The box then has a top border, two side borders and no bottom border. The same mistake applies to the last column. Test the box with a height of 2 or 3 and look at the bottom row. If it is not a full border, check the - 1.

Practice

Exercise 1. Change the checkerboard from worked example 1 so that the top left cell is . and the top row starts with a dot. Print a three by three board. Write your answer as a change to the if condition.

Show the answer

Change the condition to (row + col) % 2 == 1, so the # goes on the odd sums. The three by three board is:

.#.
#.#
.#.

Exercise 2. Print a box five cells wide and three cells high. The border is = and the fill is ~. Use the box function from worked example 3 or write the loops yourself. Predict the output before you run the program.

Show the answer

Call box(5, 3, "=", "~") and print the result with end="". The output is:

=====
=~~~=
=====

The top and bottom rows are all border, because they are row 0 and row 2. The middle row has a border at each end and three fill cells in between.

Exercise 3. Write a function count_hashes(n) that returns the number of # cells in an n by n checkerboard that starts with # in the top left. Test it for n from 1 to 6.

Show the answer

Count the even sums with two nested loops, the same way as worked example 1, and return the total. The counts are:

n=1 hashes=1 cells=1
n=2 hashes=2 cells=4
n=3 hashes=5 cells=9
n=4 hashes=8 cells=16
n=5 hashes=13 cells=25
n=6 hashes=18 cells=36
def count_hashes(n: int) -> int:
    total = 0
    for row in range(n):
        for col in range(n):
            if (row + col) % 2 == 0:
                total += 1
    return total

For an even n, the answer is n * n / 2. For an odd n, the answer is (n * n + 1) / 2. In Python, write these with //, which is floor division. It divides and drops the fraction, so 7 // 2 is 3. The single formula (n * n + 1) // 2 gives the right answer for both kinds of n. Writing the loop first and the formula second is a good way to check your formula. The loop can also be written as one line with sum and a generator expression, which is a short way to produce values one at a time: sum(1 for row in range(n) for col in range(n) if (row + col) % 2 == 0).

Exercise 4. Print a box that is six cells wide and four cells high. The border is @. The inside is a checkerboard of # and ., starting with # in the first inside cell. Use the framed example as a model, but with the border drawn by the box rule.

Show the answer

Use the box loops from worked example 3. In the else branch, choose the character from the parity of row + col, with the box's own coordinates. The first inside cell is at (1, 1), with sum 2, so it is #.

Python · editable

The output for width 6 and height 4 is:

@@@@@@
@#.#.@
@.#.#@
@@@@@@

Exercise 5. A student wrote this box test and got a blank picture:

border = (row == 0 and row == size - 1
          and col == 0 and col == size - 1)

Explain why the output is blank, and fix the test.

Show the answer

Each cell must be on all four edges at once, and only a one by one box can do that. The fix is to use or between the four tests:

Python · editable

For a three by three box, the output is:

###
#.#
###

Summary

  • A checkerboard alternates colour with each step, so (row + col) % 2 decides the character for each cell.
  • Moving one step right or down changes the sum by 1, which flips its parity.
  • Keep the rule for one cell in its own function, so you can test it without reading the printed output.
  • A box border is a cell on the first or last row, or on the first or last column. Join those four tests with or.
  • Row and column numbers must start from the same value. Mixing a 1 start and a 0 start flips the colours.
  • Test a box with the smallest sizes first, such as width 1 and height 2, because those expose the edge cases.
Check yourself: Grids in Python (6 questions) →Practice this topic: coding problems for Pattern Printing →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Aligned tables with f-strings