The py_maze save format
The header, the seed comment, the markers, the ragged-line rule and what a reader refuses, so another tool can write a file py_maze will load.
A save file is the maze exactly as it is drawn, under a short header
recording the format and the seed it came from. There is no packing and no
encoding: what --save writes is the picture --load reads back, so the
file can be opened in any editor, changed by hand and compared like any
other text.
This page specifies the format so another tool can write a file py_maze
will load, and so a reader written elsewhere agrees with the one in
py_maze/saves.py. The current format is 1, the number carried in the
header.
There are three things --load will read, and this page covers all of
them. The picture under its header is the one --save writes by default
and the one the rest of this page means by “a save file”:
| What | Written by | Read by |
|---|---|---|
| The picture under its header | --save FILE |
--load FILE |
| A JSON document | --save FILE --format json |
--load FILE |
| A picture with no header | Another tool entirely | --load FILE --wall-char C --open-char C |
Every one of them is read from standard input, and the first two written to
standard output, under the file name -.
A Whole File
python -m py_maze -d easy --seed 2024 -c 4 --save maze.txt
maze.txt:
# py_maze save 1
# seed: 2024
* ***********
* * * *
* *** * *** *
* * * *
*** * *******
* * *
* *** ***** *
* *$*$ * *
*** * * *** *
* * $$* *
* ******* * *
* * *
*********** *
Two comment lines and then the maze. Everything below spells out what each part means and what a reader does with it.
The File as a Whole
| Property | Rule |
|---|---|
| Encoding | UTF-8. read_save opens the file with encoding='utf-8', and every character the format uses is ASCII, so plain ASCII is valid UTF-8 here |
| Line endings | LF or CRLF. Lines are split with str.splitlines(), so a file written on either platform reads on both |
| Final newline | Written, not required. write_save ends the file with one |
| Line order | Comments and maze lines in any order, except that the format header must come before the first maze line |
| Blank lines | Skipped wherever they appear, including a line of nothing but spaces or tabs |
| Where it is read from | A file by name, or standard input under the name -. A stream read that way is called <stdin> in the messages |
Comment Lines
A line whose first character is # is a comment. Three kinds matter:
The Format Header
# py_maze save 1
Required of a save file, and required before its first maze line. It is
matched against ^#\s*py_maze save\s+(\d+)\s*$, so the spacing after #
and around the number is free but the wording is not. The number is the
format version: this build reads 1 and refuses anything else rather
than guessing at it, which is what the number is for.
The header is what makes a file a save file, and a file carrying one is read strictly: the markers below are the only characters its maze may be drawn with. A file with no header is not refused, but it is not a save file either. It is read as a picture with no header, on whatever terms the reader is given.
A header below the first maze line is refused rather than believed. A reader that has already taken those lines for a picture cannot go back and read them again as something else.
The Seed Comment
# seed: 2024
Optional. Matched against ^#\s*seed:\s*(.+?)\s*$, with the surrounding
whitespace trimmed off the value. The seed reads as a whole number when it
looks like one and as text otherwise; both regenerate the same maze, and
the number is simply what py_maze reports when it picks a seed itself.
The seed is a record of where the maze came from, not an instruction. A loaded maze is played exactly as the file draws it, and the seed is never used to carve it again. A file with no seed comment loads fine and reports no seed. Where the comment appears more than once, the last one wins.
Any Other Comment
# the long way round is the one on the left
Kept for whoever opens the file and ignored by the reader, so a note about a maze can live alongside it.
The Maze
Every line that is not blank and does not start with # is a row of the
maze, in order from the top.
The Markers
| Character | Meaning | Constant |
|---|---|---|
* |
Wall | py_maze.WALL_MARKER |
| (space) | A cell the player can stand on | py_maze.OPEN_MARKER |
$ |
A collectible, on a cell the player can stand on | py_maze.COLLECTIBLE_MARKER |
Those three and nothing else. py_maze.SAVE_CHARS is the same table in
code, mapping each character to the boolean it becomes.
The markers py_maze draws over a maze on screen are not part of a save
file: the player o, the solution ., the hint, the search frontier and
the cells already visited are all drawn over the grid at the moment of
drawing and are never written to a file. Pasting a solved maze back into
one is refused, which is the point of a fixed character set.
The Ragged-Line Rule
Every maze line is the same length as the first one. A grid whose rows are not all the same length is not a maze, and a file carrying one is refused rather than padded out or trimmed down.
Two things follow from spaces being significant:
- Trailing spaces are open cells. An editor that strips trailing whitespace on save turns a valid file into a ragged one. py_maze’s own files never end a line with a space, because the rightmost column of a maze is always wall.
- A line of nothing but open cells cannot be written. A line with no non-whitespace character in it is skipped as a blank line, so a row of all spaces disappears rather than loading. This never arises in a maze py_maze carves, where the left and right columns are wall.
The Shape of the Maze
A maze of W by H cells is H * 2 + 1 lines of W * 2 + 1 characters,
the extra line in each direction being the wall between one cell and the
next. The example above is a 6 by 6 maze: 13 lines of 13 characters.
The reader does not require those dimensions, and a rectangle of the allowed characters loads. What it does not do is make such a maze playable: the parts of py_maze that walk a maze look for the entrance and the exit where a carved maze puts them.
- The entrance is the first open cell down column 1, which in a carved maze is the gap in the top line.
- The exit is the last open cell up the second-to-last column, which in a carved maze is the gap in the bottom line.
A file that opens neither still loads, and the player starts and finishes at the fallback corners of those two columns.
Collectibles
A $ is an open cell holding a collectible, so a reader records the cell
as both. Cells are counted from the top-left of the maze picture, not
of the file: comment and blank lines are not rows, so (x, y) is column
x of the y-th maze line, both counted from nought.
In the example, the $ on the eighth maze line at column 5 is the cell
(5, 7).
There is no limit on how many a file carries and no requirement that it carry any. py_maze leaves them off the entrance and the exit, so nothing is picked up before the player has taken a step or after the maze is won; a file that puts one on the entrance is loaded all the same, and the game hands it over as play begins.
A Picture With No Header
A maze drawn by something that had never heard of py_maze carries no header
and is unlikely to be drawn with * and the space. Such a file is read as
a plain picture: the reader is told which character is a wall and which
is a cell, and everything else on this page applies unchanged.
python -m py_maze --load drawn.txt --wall-char '#' --open-char '.'
drawn.txt:
#.#####
#.....#
#####.#
The two options default to the characters py_maze itself draws with, so a save file with its header cut off loads as it stands. Three rules follow from a picture having no header to speak for it:
- The characters are the reader’s to name, not the file’s. A file that does carry the header is read with the three markers above whatever the options say, the format having already settled the question.
- A comment is a line the picture could not have drawn.
#opens a comment only when#is not one of the picture’s own characters, so the file above is three maze lines rather than three notes. A plain picture drawn with*and the space still keeps its seed comment. - The first line decides whether this is a maze at all. A character the
picture cannot be drawn with is
not a py_maze save fileon the first maze line, where nothing has read as a maze yet, andunexpected characteron any line after it, where something has.
Nothing about writing changes: a maze is always written with *, the space
and $, so a plain picture loaded this way is a py_maze save file the
moment it is saved again.
The JSON Document
--format json writes the same maze as a document, for a program that
would rather not read a picture. It says outright what the picture leaves
to be worked out, and it is written on a single line so it pipes into a
reader as it stands:
python -m py_maze -w 2 -H 3 --seed 2024 -c 2 --solve --format json --save -
Output (laid out here, written on one line):
{
"py_maze": 1,
"seed": 2024,
"entrance": [1, 0],
"exit": [3, 6],
"collectibles": [[1, 3], [1, 5]],
"solution": [[1, 0], [1, 1], [1, 2], [1, 3], [2, 3], [3, 3], [3, 4], [3, 5], [3, 6]],
"grid": [
[true, false, true, true, true],
[true, false, true, false, true],
[true, false, true, false, true],
[true, false, false, false, true],
[true, true, true, false, true],
[true, false, false, false, true],
[true, true, true, false, true]
]
}
That is the maze python -m py_maze -w 2 -H 3 --seed 2024 -c 2 prints, as a
picture:
* ***
* * *
* * *
*$ *
*** *
*$ *
*** *
| Key | Holds |
|---|---|
py_maze |
The format number, exactly as the header carries it. This build reads 1 |
seed |
The seed the maze was generated from, as a number or a word, and null when there is none |
entrance |
The (x, y) of the entrance, as a two-element list |
exit |
The (x, y) of the exit |
collectibles |
Every open cell holding a pickup, in reading order. [] when there are none |
solution |
The route from the entrance to the exit, cell by cell, when --solve or --animate asked for one. null otherwise, and null when there is no way through |
grid |
The maze itself: a list of rows of true and false, true for a wall |
The grid is the whole of the maze, and the four keys above it are read out of it every time a picture is loaded. A reader may take them as written or work them out again; py_maze itself works them out, which is why a document and a picture of the same maze play identically.
grid is required and every other key is optional. entrance, exit and
solution are written for a reader and are not read back: a loaded
document hands back the same three things a loaded picture does, the grid,
the collectibles and the seed. Rows must be lists of true and false,
all the same length, and each must hold at least one cell. A cell is a list
of two whole numbers, [x, y].
A file is read as a document when it opens with {, which no picture does
unless --wall-char or --open-char says it is drawn with one.
What a Reader Must Refuse
A file that is not a maze this build reads is refused, with a message
naming what was wrong, rather than being guessed at. The reader in
py_maze.saves raises SaveFileError for each of these, and the command
line prints it under a py_maze: prefix and exits with status 3.
| The file | The message |
|---|---|
| Is not a maze the reader can be reading, on its very first line | not a py_maze save file |
| Draws maze lines above its format header | the save header on line 2 comes after the maze |
| Carries a format this build does not read | save format 2 is not supported, this build reads 1 |
| Uses a character the picture is not drawn with | unexpected character '.' on line 2 |
| Has a maze line of a different length than the first | line 3 is 4 characters, expected 5 |
| Is a header and nothing else, or holds no maze at all | the save file has no maze in it |
And for a document:
| The document | The message |
|---|---|
| Is not JSON the parser can read | the JSON could not be read, and what the parser made of it |
Is not an object, or carries no py_maze key |
not a py_maze save file |
| Carries a format this build does not read | save format 2 is not supported, this build reads 1 |
Has no grid, or an empty one |
the save file has no maze in it |
Has a row that is not true and false |
row 1 is not a row of true and false |
| Has rows of different lengths | row 2 is 1 cells, expected 2 |
| Lists something that is not a cell | collectibles holds [1], which is not an (x, y) cell |
| Puts a collectible off the maze | collectibles holds [9, 9], which is outside the maze |
| Puts a collectible on a wall | collectibles holds [0, 0], which is a wall |
| Records a seed that is neither | the seed is not a number or a word |
A picture cannot express either of those collectibles, a $ always being
one of the characters the maze is drawn with and always an open cell, so
the document reader refuses them rather than admitting a maze the picture
reader could not. A cell off the grid is drawn by nothing, a cell on a wall
is drawn over, and neither can be stepped on; both would be counted in the
tally all the same, leaving a summary that reads Collected: 0 of 1
however well the maze is played. A collectible on a wall would not survive
the round trip either: saved as a picture it is drawn as $, and read back
that $ is an open cell, so the wall it stood on would have turned into a
path. Every cell from [0, 0] to the bottom right of the grid is inside
the maze, and a collectible must name one of the false ones.
Line numbers count every line in the file, comments and blank lines
included, and start at 1; a document’s rows are counted from 1 as well.
Where a file is read by name, the messages are prefixed with it:
maze.txt: not a py_maze save file. A maze read from standard input is
prefixed <stdin>: instead.
Two things are deliberately not refused:
- A maze with no way through. The reader checks the file, not the maze.
A grid whose exit cannot be reached loads, plays and can be saved again;
it is the solver that reports there is no route, by returning
None. - A maze of no particular size. Any rectangle of the allowed characters
loads, whether or not its dimensions are the
2n + 1of a carved maze. The command line does draw one line here: the entrance is cut in the second column and the exit in the second from last, and three characters is what it takes for the exit column to stop falling left of the entrance column - at two the exit column falls left of the entrance, both of them on the border, and at one the entrance column is off the row altogether. At three the two meet in the middle column, the one column a maze that narrow has that is not a border.py_maze --loadrefuses a maze narrower than that withthe maze is too narrow for an entrance and an exit, which need 3 charactersand exits with status 3. The reader still hands the maze back,py_maze.has_endsbeing what reports whether a grid has room for the two.
Writing a File py_maze Will Load
The whole checklist, for a tool writing one from scratch:
- Write
# py_maze save 1as the first line. - Write
# seed: <value>next when there is a seed worth recording, and leave the line out when there is not. - Write the maze, one line per row, using only
*, the space and$. - Keep every maze line the same length, trailing spaces included, and make sure no maze line is entirely whitespace.
- Open the entrance in column 1 and the exit in the second-to-last column if the maze is meant to be played.
- End the file with a newline, and write it as UTF-8.
A tool that would rather not draw a picture has two shorter routes. Write
the JSON document instead, which is one object with a py_maze of 1 and
a grid in it and nothing else required. Or draw the maze however the tool
already draws it and let the reader be told: a rectangle of any two
characters loads under --wall-char and --open-char, with no header, no
comments and no fixed markers to honour.
Reading and Writing It in Code
The same format through the public API. read_save and parse_save hand
back the grid described on the library page: a list of
rows of booleans, True for a wall.
>>> import py_maze
>>> grid, collectibles, seed = py_maze.read_save('maze.txt')
>>> len(grid), len(grid[0])
(13, 13)
>>> sorted(collectibles)
[(5, 7), (6, 9), (7, 7), (7, 9)]
>>> seed
2024
| Name | What it does |
|---|---|
py_maze.read_save(path, chars=None, stream=None) |
Read a file, returning (grid, collectibles, seed) |
py_maze.parse_save(text, source=None, chars=None) |
The same, from text already in hand. source names the file in the error messages |
py_maze.parse_json_save(text, source=None) |
The same again, for a document. parse_save calls it for text that opens with { |
py_maze.write_save(path, grid, collectibles=(), seed=None, solution=None, form='text', stream=None) |
Write a maze to a file, in either form |
py_maze.save_lines(grid, collectibles=(), seed=None) |
The lines write_save would write, without writing them |
py_maze.save_json(grid, collectibles=(), seed=None, solution=None) |
The document it would write instead, under form='json' |
py_maze.picture_chars(wall, open_cell) |
The chars map a headerless picture is read with |
py_maze.SaveFileError |
Raised for every refusal above. A ValueError |
py_maze.SAVE_FORMAT |
The format number this build reads |
py_maze.SAVE_HEADER |
The header line for that format |
py_maze.SAVE_CHARS |
The three markers, mapped to the boolean each becomes |
py_maze.JSON_FORMAT_KEY |
The key a document carries the format number under, py_maze |
py_maze.FORMATS |
The two forms, TEXT_FORMAT and JSON_FORMAT, with DEFAULT_FORMAT the one written unasked |
py_maze.STDIO_PATH |
The file name that means standard input or standard output, - |
A maze read from a file is the same type as a maze straight from the generator, so it can be solved, drawn, played and saved again with no conversion between one step and the next:
>>> grid, collectibles, seed = py_maze.read_save('maze.txt')
>>> py_maze.save_lines(grid, collectibles, seed) == open(
... 'maze.txt', encoding='utf-8').read().splitlines()
True
Changing the Format
The header number exists so that an older build refuses a newer file
instead of misreading it. A change that any current reader would get wrong
is a new number, py_maze.SAVE_FORMAT moves with it, and the refusal
message names both. A change an existing reader already handles, such as a
new kind of comment line, keeps the number it has.