- Read the question carefully (twice).
- Break the task into the smallest steps.
- Sketch or write pseudocode before coding.
- Start small — test as you go.
- Check your solution with different cases.
Scenario: The Puzzle Page
You love crosswords. Today you stop solving them and start making them.
A small newspaper wants a program that takes a list of words and clues and prints three things: a blank puzzle for the player, the lists of Across and Down clues, and an answer key. You are going to build it.
Fair warning: building a crossword maker is genuinely hard, even for experienced programmers. The trick is that you will never build the whole thing at once. You will build 13 tiny functions, test each one on its own, and only then snap them together.
The Big Idea: Decompose, Decouple, Slice
Decompose. "Make a crossword" is too big to hold in your head. So we split it into small jobs: make a grid, show a grid, check if a word fits, place a word, find where two words can cross, and so on. Every job becomes one function, and every function gets one test.
Decouple. Keep different jobs in different places. Your program has three layers:
- Data: the words and their clues. Just a dictionary. Nothing in it knows what a grid is.
- Logic: making the grid, checking, and placing words. These functions never print.
- Display: turning the grid into text for a human. These functions never change the grid.
If you keep to those rules, you can change how the puzzle looks (text today, a web page tomorrow) without touching any of the code that builds it.
Slice. Python can cut a piece out of a list with my_list[start:stop]. A crossword is just rows and columns of letters, so every word in it is a slice of a row or a slice of a column. You will use slices to read words, to check whether a word fits, and to double-check your finished puzzle.
Slicing refresher. Run this before you go any further and make sure you understand every line of output:
letters = ["C", "A", "T", "S"]
print(letters[1:3]) # ['A', 'T'] start is included, stop is NOT
print(letters[2:99]) # ['T', 'S'] slicing past the end does not crash
print(letters[:2]) # ['C', 'A'] leaving out the start means "from the beginning"
How to Work Through This Problem Set
- One part at a time. Do not start Part N+1 until the test for Part N prints "passed".
Run only the test you are working on. At the bottom of your file, call just the current test and comment out the rest:
if __name__ == "__main__": test_part1() # test_part2() # test_part3()- Print things. When a test fails, do not guess. Print the grid, print the slice, print the numbers. Look at what is really there.
- Know the "NoneType" message. If a test crashes with something about
NoneType, your function is returning nothing. Either it still sayspass, or you forgot the wordreturn. - Use paper. For Parts 6 and 8, draw a small grid and walk through an example by hand before you write code.
- Write every function yourself. Struggling is how this works. If you are stuck, read the hint, then trace a tiny example, then ask your teacher. Do not paste the problem into an AI tool and ask for the answer.
Vocabulary and Legend
grid: a list of rows. Each row is a list of single-character strings.row,col: whole numbers that count from 0. Row 0 is the top. Col 0 is the left. You read a cell asgrid[row][col], row first.EMPTY: the character".", used for a cell with no letter yet.direction: eitherACROSSorDOWN.placement: a dictionary that describes one word sitting on the grid, for example{"word": "CAT", "row": 2, "col": 1, "direction": "across"}.placements: a list of placements. The grid remembers the letters. The placements remember which letters are words.- crossing: a cell that two words share.
clues: a dictionary from a word to its clue text, such as{"CAT": "Pet that purrs"}.
Here is a tiny grid with its numbers, so you can see what grid[1][1] means:
col 0 col 1 col 2 col 3
row 0 . . . .
row 1 . C A T
row 2 . . . .
grid[1][1] is "C" grid[1][3] is "T" grid[0][0] is "."
What You Will Have at the End
Your finished program prints a player's puzzle (# is a black square, numbers mark where words start, _ is a blank letter), the clues, and an answer key. Yours may look different from this one, and that is fine.
YOUR PUZZLE
1 _ _ 2 # # # # # # #
# # # _ # # # # # # #
3 _ _ _ # # # # # # #
# # # _ # # # # # # #
# # # _ # 4 # # 5 # #
# 6 _ _ _ _ _ _ _ # #
# # # # # _ # # _ # #
# # # 7 _ _ _ # _ # #
# # # # # _ # # _ # #
# # # 8 _ _ # # # # #
ACROSS
1. Repeats code again and again
3. Ordered collection in square brackets
6. Named block you can call
7. Rows and columns
8. Mistake in a program
DOWN
2. Language you are writing in
4. Text value
5. Position number of an item
ANSWER KEY
L O O P . . . . . . .
. . . Y . . . . . . .
L I S T . . . . . . .
. . . H . . . . . . .
. . . O . S . . I . .
. F U N C T I O N . .
. . . . . R . . D . .
. . . G R I D . E . .
. . . . . N . . X . .
. . . B U G . . . . .
Could not place: SLICE, CODE
Notice that two words did not fit. A real crossword maker has the same problem. You will think about why in the reflection questions.
The Plan
Thirteen small parts, in three checkpoints:
- Make an empty grid
- Show the grid
- Slice the grid (read across, read down, read a word)
- Place a word
- Can this word fit?
Checkpoint A: you can put one word on a grid and ask whether another one fits.
- Breathing room (words must not touch)
- Find shared letters
- Where does the crossing word go?
- Add one word to the puzzle
- Build a whole puzzle
Checkpoint B: your program grows a connected puzzle from a list of words.
- Numbers and clues
- The player's view
- Trust, but verify (and put it all together)
Checkpoint C: a finished, checked crossword with a player's puzzle, clues, and answer key.
Setup
Start a new file called crossword.py and put these three constants at the very top. Using named constants instead of typing "." or "across" everywhere means that a typo gives you an error right away instead of a silent bug.
EMPTY = "."
ACROSS = "across"
DOWN = "down"
Part 1: Make an Empty Grid
Your job: write make_grid(rows, cols). It returns a list of rows lists. Each of those lists holds cols copies of EMPTY.
Hints
- Use a loop inside a loop. The inner loop builds one row. The outer loop builds the grid out of rows.
- There is a famous trap:
[[EMPTY] * cols] * rowslooks right, but it makes every row the same list, so changing one cell changes a whole column. Build each row separately.
def make_grid(rows, cols):
# Return a list of rows. Each row is a list of cols EMPTY cells.
pass
def test_part1():
g = make_grid(3, 4)
assert len(g) == 3, "grid should have 3 rows"
assert len(g[0]) == 4, "each row should have 4 cells"
assert g[2][3] == EMPTY
g[0][0] = "A"
assert g[1][0] == EMPTY, "rows are sharing memory! build each row separately"
print("Part 1 passed")
Think about it: the test changes g[0][0] and then checks g[1][0]. What bug is it hunting for?
Part 2: Show the Grid
Your job: write two functions.
grid_to_text(grid)returns one string. Cells in a row are joined by a single space. Rows are joined by"\n".show_grid(grid)prints that string. It only needs a single line of code, becausegrid_to_textalready did the hard part.
Hint: " ".join(["a", "b", "c"]) gives "a b c".
def grid_to_text(grid):
# Return the grid as one string, like ". . .\n. . ."
pass
def show_grid(grid):
# Print the text version of the grid
pass
def test_part2():
g = make_grid(2, 3)
assert grid_to_text(g) == ". . .\n. . ."
g[1][2] = "Z"
assert grid_to_text(g) == ". . .\n. . Z"
print("Part 2 passed")
Think about it: why split this into a function that returns text and another that prints it? Hint: try writing an assert for a function that only prints.
Part 3: Slice the Grid
This is the big slicing idea. Every word in a crossword is a slice of a row or a slice of a column.
Your job: write four functions.
get_across(grid, row, col, length)returns a list of thelengthcells starting at(row, col)and moving right. This is one slice of one row.get_column(grid, col)returns the whole columncolas a list. Your grid is stored as rows, so a column does not exist yet. You have to collect it, one cell from every row.get_down(grid, row, col, length)returns thelengthcells starting at(row, col)and moving down. Build the column first, then slice it.read_word(grid, row, col, length, direction)returns the cells as one string like"CAT", using one of the functions above.
Hints
- Across is one line:
grid[row][col:col + length]. - Remember from the refresher that slicing past the end does not crash. It just gives you a shorter list. You will use that fact in Part 5 to find out that a word ran off the edge.
"".join(["C", "A", "T"])gives"CAT".
def get_across(grid, row, col, length):
pass
def get_column(grid, col):
pass
def get_down(grid, row, col, length):
pass
def read_word(grid, row, col, length, direction):
pass
def test_part3():
g = make_grid(3, 5)
g[1][2] = "C"
g[1][3] = "A"
g[2][3] = "T"
assert get_across(g, 1, 1, 3) == [".", "C", "A"]
assert get_across(g, 1, 3, 5) == ["A", "."], "slice runs off the edge: short list"
assert get_column(g, 3) == [".", "A", "T"]
assert get_down(g, 1, 3, 2) == ["A", "T"]
assert get_down(g, 1, 3, 9) == ["A", "T"]
assert read_word(g, 1, 2, 2, ACROSS) == "CA"
assert read_word(g, 1, 3, 2, DOWN) == "AT"
print("Part 3 passed")
Think about it: getting a column costs more work than getting a row. Why? What does that tell you about how the grid is stored?
Part 4: Place a Word
Your job: write place_word(grid, word, row, col, direction). It writes the letters of word into the grid, starting at (row, col). It does no checking. It trusts the caller, and it will crash if the word runs off the grid. That is okay, because Part 5 will be the one that checks.
Hint: loop i through the positions of the word. For ACROSS, letter i goes in column col + i. For DOWN, it goes in row row + i.
def place_word(grid, word, row, col, direction):
# Write each letter of word into the grid. Returns nothing.
pass
def test_part4():
g = make_grid(4, 4)
place_word(g, "CAT", 0, 1, ACROSS)
assert read_word(g, 0, 1, 3, ACROSS) == "CAT"
place_word(g, "TOP", 0, 3, DOWN)
assert read_word(g, 0, 3, 3, DOWN) == "TOP"
assert g[0][3] == "T"
print("Part 4 passed")
Think about it: the test uses read_word from Part 3 to check the work of place_word. Why is that better than checking cells one by one?
Part 5: Can This Word Fit?
Your job: write can_place(grid, word, row, col, direction). It returns True if the word could be placed there and False if not. It must not change the grid. Asking a question and doing the thing are two different jobs.
A word can be placed when:
- The starting cell is actually on the grid.
- The word does not run off the edge.
- Every cell it would use is either
EMPTYor already holds the same letter (that is a crossing).
Hints
- Careful:
grid[-1]does not crash in Python. It quietly gives you the last row. So you must reject negative starting numbers yourself. - Use your slicing functions from Part 3 to get the cells the word would cover. If you get back fewer cells than the word has letters, it ran off the edge.
zipwalks two lists side by side:for cell, letter in zip(cells, word):
def can_place(grid, word, row, col, direction):
# Step 1: reject a starting cell that is off the grid (negatives too!)
# Step 2: get the cells the word would cover, using a slice
# Step 3: too few cells means it ran off the edge
# Step 4: every cell must be EMPTY or equal to the letter that would go there
return True # replace this with your real answer
def test_part5():
g = make_grid(5, 5)
assert can_place(g, "HELLO", 0, 0, ACROSS) is True
assert can_place(g, "HELLO", 0, 1, ACROSS) is False, "runs off the right edge"
assert can_place(g, "HELLO", 1, 0, DOWN) is False, "runs off the bottom"
assert can_place(g, "HI", -1, 0, DOWN) is False, "negative start must be rejected"
assert can_place(g, "HI", 0, -1, ACROSS) is False
place_word(g, "CAT", 2, 1, ACROSS)
assert can_place(g, "TOP", 2, 3, DOWN) is True, "T matches T"
assert can_place(g, "BOP", 2, 3, DOWN) is False, "B clashes with T"
assert can_place(g, "ACE", 1, 2, DOWN) is False, "C clashes with the A at (2,2)"
print("Part 5 passed")
Checkpoint A
Before you go on, try this by hand. Predict what each line will print, then run it.
g = make_grid(7, 9)
place_word(g, "PYTHON", 3, 1, ACROSS)
show_grid(g)
print(can_place(g, "NAP", 3, 6, DOWN)) # N crosses N
print(can_place(g, "CAT", 3, 6, DOWN)) # C clashes with N
print(can_place(g, "PYTHON", 3, 5, ACROSS))
Part 6: Breathing Room
Your program from Part 5 has a hidden problem. It will happily put two words side by side:
C A T
O D E
Look what that does. Reading down you now have CO, AD and TE, which are not words. Real crosswords never do this. Words may only touch where they cross.
The rules for a word that is about to be placed:
- The cell just before the first letter and the cell just after the last letter must be free. Otherwise two words run together into one long string.
- For every letter that lands in an empty cell, the two cells on either side of it must be free. For an ACROSS word those are the cells above and below. For a DOWN word they are the cells to the left and right.
- Letters that land on an already filled cell are crossings. They are allowed to have neighbours, because the crossing word is supposed to be there.
Your job: write two functions.
cell_is_free(grid, row, col)returnsTrueif the cell isEMPTY. If the position is off the grid, it also returnsTrue, because there is nothing there to bump into.has_breathing_room(grid, word, row, col, direction)returnsTrueif rules 1 to 3 are satisfied.
Hint: do not write one big block for ACROSS and another for DOWN. Decide the direction once, as two pairs of step values, and then write the logic one time:
| Direction | Step along the word (row, col) | Step sideways (row, col) |
|---|---|---|
| ACROSS | (0, 1) | (1, 0) |
| DOWN | (1, 0) | (0, 1) |
Letter i of the word sits at (row + i * along_row, col + i * along_col). The cell before the word is at i = -1. The cell after is at i = len(word). The sideways neighbours are one sideways step in each direction from a letter.
def cell_is_free(grid, row, col):
# True if the position is off the grid, or the cell is EMPTY
pass
def has_breathing_room(grid, word, row, col, direction):
# Assumes the word already fits on the grid (Part 5 checks that first).
# Check the cell before, the cell after, and the neighbours of each new letter.
pass
def test_part6():
g = make_grid(6, 6)
place_word(g, "CAT", 2, 2, ACROSS) # cells (2,2) (2,3) (2,4)
assert has_breathing_room(g, "TOP", 2, 4, DOWN) is True, "a proper crossing is fine"
assert has_breathing_room(g, "DOG", 3, 2, ACROSS) is False, "runs right under CAT"
assert has_breathing_room(g, "DOG", 4, 2, ACROSS) is True, "one row of space is fine"
assert has_breathing_room(g, "NO", 2, 0, ACROSS) is False, "O touches the C"
assert has_breathing_room(g, "UP", 0, 2, DOWN) is False, "P touches the C"
print("Part 6 passed")
Now go back and improve Part 5. At the bottom of can_place, replace return True with a call to your new function:
return has_breathing_room(grid, word, row, col, direction)
Run test_part5() again. It should still pass. That is the whole point of keeping tests: you can change old code and find out right away if you broke something.
Think about it: why does rule 2 only check the neighbours of letters going into empty cells? Draw a crossing and see what happens if you check the crossing letter too.
Part 7: Find Shared Letters
To attach a new word, you need to know which letters it shares with a word that is already on the grid.
Your job: write find_crossings(word_a, word_b). It returns a list of pairs (i, j), one for every place where word_a[i] == word_b[j]. List them with i in order, and for each i with j in order.
Example: find_crossings("CAT", "TAP") gives [(1, 1), (2, 0)], because the A in position 1 of CAT matches the A in position 1 of TAP, and the T in position 2 of CAT matches the T in position 0 of TAP.
Hint: compare every letter of one word with every letter of the other. That is a loop inside a loop.
def find_crossings(word_a, word_b):
# Return a list of (i, j) pairs where word_a[i] == word_b[j]
pass
def test_part7():
assert find_crossings("CAT", "TAP") == [(1, 1), (2, 0)]
assert find_crossings("DOG", "CAT") == []
assert find_crossings("LEVEL", "L") == [(0, 0), (4, 0)]
print("Part 7 passed")
Think about it: why does this function return the positions and not just True or False?
Part 8: Where Does the Crossing Word Go?
This is the hardest idea in the whole problem set, so take your time and use paper.
First, a helper. We need a tidy way to remember a word that has been placed. It is given to you:
def make_placement(word, row, col, direction):
return {"word": word, "row": row, "col": col, "direction": direction}
The problem: CAT is already on the grid, across, starting at (5, 2). We want to add TAP so that it crosses CAT at the letter T.
- The T in CAT is letter number
i = 2. Since CAT runs across, that cell is at row 5, column2 + 2 = 4. The shared cell is(5, 4). - The T in TAP is letter number
j = 0. TAP must run down (it has to cross, not lie along CAT), and its letter 0 sits on the shared cell. So TAP starts at(5, 4).
col 2 3 4
row 5 C A T
row 6 A
row 7 P
Now the same two words, crossing at the letter A instead. The A is letter i = 1 of CAT, so the shared cell is (5, 3). The A is letter j = 1 of TAP. TAP runs down, and its letter 1 must sit on the shared cell, so TAP has to start one row higher, at (4, 3):
col 2 3 4
row 4 T
row 5 C A T
row 6 P
The pattern: for a placed ACROSS word, the new word is DOWN, it sits in the shared column, and it starts j rows above the shared cell.
Your job: write crossing_position(placed, i, j). The first argument is a placement dictionary. It returns three values (row, col, direction) for where the new word should go. Handle the ACROSS case using the pattern above. For a placed DOWN word, work out the mirror image yourself: the new word runs across, and you need to figure out which way it shifts.
def crossing_position(placed, i, j):
# placed: a placement dictionary (see make_placement)
# i: the index of the shared letter in the placed word
# j: the index of the shared letter in the NEW word
# Return (row, col, direction) for the new word.
pass
def test_part8():
cat = make_placement("CAT", 5, 2, ACROSS)
assert crossing_position(cat, 2, 0) == (5, 4, DOWN)
assert crossing_position(cat, 1, 1) == (4, 3, DOWN)
top = make_placement("TOP", 3, 6, DOWN)
assert crossing_position(top, 1, 1) == (4, 5, ACROSS)
print("Part 8 passed")
Think about it: this function does not look at the grid at all. It only does arithmetic. It does not decide whether the position is a good one. Which function from earlier decides that?
Part 9: Add One Word to the Puzzle
Now you have every ingredient. This part glues them together, and it is the only function that needs to know about all of them. Each of the others still knows nothing about the rest.
Your job: write try_to_add(grid, placements, word). In plain words:
- Go through each word that is already placed.
- For that word, go through each shared-letter pair from
find_crossings. - Work out where the new word would go with
crossing_position. - Ask
can_place. If the answer is yes, place the word, add a new placement to the list, and return True right away. - If you run out of options, return
Falseand leave the grid and the list exactly as they were.
Hint: adding to a list while you are looping over it causes strange bugs. Returning immediately after the append avoids the problem completely.
def try_to_add(grid, placements, word):
# Return True if the word was added, False if no crossing worked.
pass
def test_part9():
g = make_grid(7, 7)
placements = []
place_word(g, "CAT", 3, 2, ACROSS)
placements.append(make_placement("CAT", 3, 2, ACROSS))
assert try_to_add(g, placements, "TAP") is True
assert len(placements) == 2
assert placements[1]["direction"] == DOWN
assert read_word(g, placements[1]["row"], placements[1]["col"], 3, DOWN) == "TAP"
assert try_to_add(g, placements, "DOG") is False, "no shared letters"
assert len(placements) == 2, "a failed add must not change placements"
print("Part 9 passed")
Part 10: Build a Whole Puzzle
Your job: write build_puzzle(words, rows, cols). It returns three things: the grid, the list of placements, and a list of words it could not place (call it skipped).
- Sort the words with the longest first. Long words have more letters to cross with, so they make a better skeleton.
- Place the first word ACROSS, in the middle of the grid. The start column is
(cols - len(word)) // 2. The start row isrows // 2. - For every other word, call
try_to_add. If it returns False, add the word toskipped.
Hint: sorted(words, key=len, reverse=True) sorts a list of strings from longest to shortest. And to return three things, write return grid, placements, skipped.
def build_puzzle(words, rows, cols):
# Returns (grid, placements, skipped)
pass
def test_part10():
words = ["PYTHON", "LOOP", "LIST", "INDEX", "SLICE", "BUG"]
grid, placements, skipped = build_puzzle(words, 15, 15)
assert placements[0]["word"] == "PYTHON", "longest word goes first"
assert placements[0]["direction"] == ACROSS
assert len(placements) + len(skipped) == len(words), "every word placed or skipped"
assert len(placements) >= 4, "most words should find a home"
print("Part 10 passed:", len(placements), "placed,", len(skipped), "skipped")
Checkpoint B
Build a puzzle from the words above and look at it with show_grid. Check three things with your own eyes: every word crosses at least one other word, no two words touch except at a crossing, and every skipped word really has no good spot.
grid, placements, skipped = build_puzzle(["PYTHON", "LOOP", "LIST", "INDEX", "SLICE", "BUG"], 15, 15)
show_grid(grid)
print("Skipped:", skipped)
Part 11: Numbers and Clues
Crossword numbering has simple rules. Look at the start cell of every word. Number those cells 1, 2, 3, ... in reading order: top to bottom, and left to right within a row. If an Across word and a Down word start in the same cell, they share one number.
Your job: write three functions.
number_starts(placements)returns a dictionary that maps a start cell(row, col)to its number.clue_lists(placements, numbers, clues)returns two lists,acrossanddown. Each holds(number, clue_text)pairs, sorted by number.clues_to_text(across, down)returns the text shown in the sample output: anACROSSheading, the across clues, a blank line, aDOWNheading, and the down clues. It returns the text and does not print it.
Hints
- Python sorts pairs sensibly:
sorted([(2, 5), (1, 9), (2, 1)])gives[(1, 9), (2, 1), (2, 5)]. First by the first item, then by the second. So a list of(row, col)pairs sorts into reading order with no extra work. - To avoid numbering the same cell twice, only add a cell to your list if it is not already in it.
for n, cell in enumerate(starts, start=1):counts from 1 as it loops. If you would rather use a counter variable, that is fine too.- The clues dictionary is only data. Notice that nothing from Parts 1 to 10 had to learn about clues.
def number_starts(placements):
# Return a dictionary like {(0, 1): 1, (2, 0): 2}
pass
def clue_lists(placements, numbers, clues):
# Return (across, down), each a sorted list of (number, clue_text)
pass
def clues_to_text(across, down):
# Return the clues as one string
pass
def test_part11():
placements = [
make_placement("CAT", 2, 0, ACROSS),
make_placement("TAP", 2, 2, DOWN),
make_placement("ARE", 0, 1, DOWN),
]
numbers = number_starts(placements)
assert numbers == {(0, 1): 1, (2, 0): 2, (2, 2): 3}, numbers
clues = {"CAT": "Pet that purrs", "TAP": "Faucet", "ARE": "Plural of is"}
across, down = clue_lists(placements, numbers, clues)
assert across == [(2, "Pet that purrs")]
assert down == [(1, "Plural of is"), (3, "Faucet")]
text = clues_to_text(across, down)
assert text.startswith("ACROSS\n2. Pet that purrs")
both = [make_placement("CAT", 0, 0, ACROSS), make_placement("COW", 0, 0, DOWN)]
assert number_starts(both) == {(0, 0): 1}
print("Part 11 passed")
Part 12: The Player's View
The grid holds the answers. The player must not see them. So we need a second display function that shows the same grid in a different way, and never changes it.
Your job: write grid_to_player_text(grid, numbers). It returns a string with one line per row. For each cell:
- An
EMPTYcell is shown as#(a black square). - A letter cell that is the start of a word is shown as its number.
- Any other letter cell is shown as
_.
Hints
- Numbers can have one or two digits, so pad every cell to width 3 so the columns line up:
str(5).ljust(3)gives"5 ". - Cut the trailing spaces off the end of each line with
.rstrip(), so your output matches the test. - You need the row and column to look up
numbers[(r, c)], so loop withrange(len(...))instead of looping over the letters directly.
def grid_to_player_text(grid, numbers):
# Return the player's view as one string
pass
def test_part12():
g = make_grid(2, 3)
place_word(g, "CAT", 0, 0, ACROSS)
place_word(g, "AX", 0, 1, DOWN)
numbers = {(0, 0): 1, (0, 1): 2}
expected = "1 2 _\n# _ #"
assert grid_to_player_text(g, numbers) == expected, repr(grid_to_player_text(g, numbers))
assert g[0][0] == "C", "the player view must not change the grid"
print("Part 12 passed")
Think about it: you now have two display functions, grid_to_text (the answer key) and grid_to_player_text. Both read the same grid. What would you have to change in Parts 1 to 10 to add a third one that makes a web page?
Part 13: Trust, but Verify
Your tests prove each function works on small examples. They do not prove that your finished puzzle is valid. Before the program shows a puzzle to anyone, it should check its own work, with fresh eyes, in a completely different way from how it built the puzzle.
A valid crossword has two properties:
- Every placement reads back correctly from the grid. (Use
read_word.) - Every run of two or more letters in any row or column is an actual placed word. A run that is not a placed word is an accident, like the
COandADfrom Part 6.
Your job: write two functions.
runs_in_line(letters)takes a list of cells (a row or a column) and returns a list of the runs of 2 or more letters, each as a string.verify_puzzle(grid, placements)returns a list of problem messages. An empty list means the puzzle is good.
Hints
- Join the line into a string, then split on the empty character:
"..CAT.A.".split(".")gives['', '', 'CAT', 'A', '']. Pieces of length 2 or more are your runs. - Check rows against the words placed ACROSS and columns against the words placed DOWN. You already have
get_columnfor columns.
def runs_in_line(letters):
# letters is a list like ['.', '.', 'C', 'A', 'T', '.', 'A', '.']
# Return a list of runs of 2 or more letters, like ['CAT']
pass
def verify_puzzle(grid, placements):
# Return a list of problem strings. An empty list means no problems found.
pass
def test_part13():
assert runs_in_line(list("..CAT.A.")) == ["CAT"]
g = make_grid(5, 5)
place_word(g, "CAT", 2, 1, ACROSS)
ps = [make_placement("CAT", 2, 1, ACROSS)]
assert verify_puzzle(g, ps) == []
g[2][2] = "X"
assert len(verify_puzzle(g, ps)) >= 1, "a changed letter must be caught"
g = make_grid(5, 5)
place_word(g, "CAT", 2, 1, ACROSS)
g[3][1] = "O" # sneaks in a stray two-letter down run: CO
assert len(verify_puzzle(g, ps)) >= 1, "a stray run must be caught"
print("Part 13 passed")
Put It All Together
Last step. Notice how little new code this takes. Almost all the thinking is already done, and this function is just the glue. It prints, so it belongs to the display side of the program.
WORDS_AND_CLUES = {
"PYTHON": "Language you are writing in",
"LOOP": "Repeats code again and again",
"LIST": "Ordered collection in square brackets",
"INDEX": "Position number of an item",
"SLICE": "Cut out a piece of a list",
"BUG": "Mistake in a program",
"STRING": "Text value",
"GRID": "Rows and columns",
"CODE": "What programmers write",
"FUNCTION": "Named block you can call",
}
def make_crossword(words_and_clues, rows=15, cols=15):
# 1. Build the puzzle. (A dictionary gives you its keys when you ask for list(...))
# 2. Verify it. If there are problems, print them.
# 3. Number the start cells.
# 4. Make the clue lists.
# 5. Print: "YOUR PUZZLE", the player's view, a blank line, the clues,
# a blank line, "ANSWER KEY", and the answer grid.
# 6. If any words were skipped, print "Could not place:" and the words.
pass
def run_all_tests():
test_part1()
test_part2()
test_part3()
test_part4()
test_part5()
test_part6()
test_part7()
test_part8()
test_part9()
test_part10()
test_part11()
test_part12()
test_part13()
if __name__ == "__main__":
run_all_tests()
print()
make_crossword(WORDS_AND_CLUES)
Make it yours: pick a theme you love (a band, a book series, a sport, a school subject) and write at least 10 words with your own clues. Run your program on them. Which words get skipped?
Reflection Questions
Answer these in a few sentences each.
- List every function you wrote that prints. List every function that changes the grid. Is any function on both lists? Why would that be a problem?
- Suppose you wanted the finished puzzle to be an HTML table instead of text. Which functions would you write new? Which would not change at all?
- Where in your program did you use slicing? List each place and say what the slice was.
- Draw what would have happened to the answer key if you had skipped Part 6. Show one example.
- Your program skips some words. Give two different reasons why a word might not be placed. Can you think of a list of words that could never make a connected crossword at all?
- Describe one bug you hit while working. What did the failing test or the printed output tell you, and how did you find the cause?
Stretch Challenges
Pick any of these. They are listed roughly from easier to harder. Each one is a good test of whether you really decoupled your code: if a stretch makes you rewrite lots of old functions, something was tangled.
- Second chances. A word that was skipped might fit once more words have been placed. After the first pass, keep retrying the skipped words until a full pass places nothing new.
- Save it. Write the player's puzzle, the clues, and the answer key to a text file using
open("puzzle.txt", "w"). - Trim the edges. Rows and columns that are completely empty around the puzzle waste space. Use slices to cut them off for display. Be careful: if you trim the grid itself, every position in
placementsshifts too. Can you trim only the display and leave the data alone? - Shuffle and score. Use
import randomandrandom.shuffleto put the words in a random order before building. Build 200 puzzles, and keep the one that places the most words. - A web page. Write
grid_to_html(grid)that returns an HTML table. If your layers are truly separate, you will not touch any logic code. - Play it. Let a player type
3 acrossand a guess. Check the guess against your placements, and reveal the word on their grid if they are right. - Smarter placing. Right now
try_to_addtakes the first spot that works. Score every legal spot (for example, spots with more crossings are better) and pick the best one.
Marking Guide
- Correctness: all 13 tests pass, and
make_crosswordprints a puzzle with no problems reported byverify_puzzle. - Decomposition: each function has one clear job and a short, accurate comment. No function is doing the work of three.
- Decoupling: logic functions do not print. Display functions do not change the grid. The clues are plain data that the grid code never touches. Direction logic is not copy-pasted into two nearly identical blocks.
- Use of slicing: slices are used to read rows, read columns, check fit, and verify, instead of long index-by-index loops where a slice would do.
- Testing and debugging: tests were run after each part. The reflection answer about a bug shows real investigation, not guessing.
- Own puzzle: the program works on a word list the student chose, with original clues.
- Reflection: the reflection questions are answered with specific references to the student's own code.
Submit: your crossword.py, the output of make_crossword on your own word list, and your answers to the reflection questions.