The Big Book of Small EK9 Programs
Adapted from Al Sweigart's The Big Book of Small Python Projects: 81 Easy Practice Programs (No Starch Press, 2021; copyright © 2021 Al Sweigart), which he makes freely available under a Creative Commons Attribution-NonCommercial-ShareAlike licence at inventwithpython.com/bigbookpython. Changes were made: each program was ported from Python to EK9 and remodelled in EK9's idioms.
Project 79 — Twenty Forty-Eight
This is project 79 of the book, ported to EK9. Its chapter is still being written; until then, this page shows the port itself: a run of it, the whole program, and how it keeps to the original.
A sample run
The program is random, so the run fixes it by setting BIGBOOK_SEED=1. The player's answers are piped in:
A
A
S
D
Q
Run it with:
BIGBOOK_SEED=1 ek9 twenty-forty-eight.ek9 < input.txt
It prints:
Twenty Forty-Eight, by Al Sweigart al@inventwithpython.com
Slide tiles with W/A/S/D; matching tiles combine. Make a 2048 tile to win!
+-----+-----+-----+-----+
| | | | |
+-----+-----+-----+-----+
| 2 | | | |
+-----+-----+-----+-----+
| 2 | | | |
+-----+-----+-----+-----+
| | | | |
+-----+-----+-----+-----+
Score: 4
Enter move: (WASD or Q to quit)
+-----+-----+-----+-----+
| | | | |
+-----+-----+-----+-----+
| 2 | | | |
+-----+-----+-----+-----+
| 2 | 2 | | |
+-----+-----+-----+-----+
| | | | |
+-----+-----+-----+-----+
Score: 6
Enter move: (WASD or Q to quit)
+-----+-----+-----+-----+
| | | | |
+-----+-----+-----+-----+
| 2 | | | 2 |
+-----+-----+-----+-----+
| 4 | | | |
+-----+-----+-----+-----+
| | | | |
+-----+-----+-----+-----+
Score: 8
Enter move: (WASD or Q to quit)
+-----+-----+-----+-----+
| | | | |
+-----+-----+-----+-----+
| | | | |
+-----+-----+-----+-----+
| 2 | | | |
+-----+-----+-----+-----+
| 4 | 2 | | 2 |
+-----+-----+-----+-----+
Score: 10
Enter move: (WASD or Q to quit)
+-----+-----+-----+-----+
| | | | |
+-----+-----+-----+-----+
| | | 2 | |
+-----+-----+-----+-----+
| | | | 2 |
+-----+-----+-----+-----+
| | | 4 | 4 |
+-----+-----+-----+-----+
Score: 12
Enter move: (WASD or Q to quit)
Thanks for playing!
The program
#!ek9 defines module bigbook.twentyfortyeight defines constant Size <- 4 NewTile <- 2 StartingTiles <- 2 //The board's own marks, data like the tiles: the corners and rules between the cells, and //how wide a cell is drawn (this port's own layout). CellWidth <- 5 Corner <- "+" Rule <- '-' Edge <- "|" defines type //The four slides are a closed set, so "is this a move?" is a guard on a parse rather //than a four-way string comparison, and every slide is handled by one switch. Direction Up Left Down Right defines record //A board position used as a Dict KEY, compared by value (lib/Coord.ek9 is the reference //shape). The fields default UNSET so ':=?' assigns, and the constructor snapshots its //arguments so a key never changes under the Dict that holds it. Coord x as Integer: Integer() y as Integer: Integer() default Coord() as pure Coord() as pure -> px as Integer py as Integer x :=? Integer(px) y :=? Integer(py) default operator ? default operator <=> default operator #? defines class //THE input idiom of this book, copied unchanged from lib/LineInput.ek9 (the reasons are //there): read with next(), and every input loop also stops when ended() is true. LineInput stdin as Stdin: Stdin() inputEnded as Boolean: false next() <- rtn as String: stdin.next() if not rtn? inputEnded: true ended() as pure <- rtn as Boolean: Boolean(inputEnded) override operator ? as pure <- rtn as Boolean: true //THE INPUT AND OUTPUT: everything the player sees or types goes through here, worded by Say. Console stdout as Stdout: Stdout() input as LineInput: LineInput() say as Say: Say("en") Console() -> words as Say say: words showIntroduction() stdout.println(say.introduction()) //The board is drawn, not worded: computed from the game's tiles a row at a time, with the //rules between the rows sized to Size and CellWidth. showBoard() -> board as Dict of (Coord, Integer) separator <- separatorLine() for y in 0 ... Size - 1 stdout.println(separator) row <- Edge for x in 0 ... Size - 1 row += `${tileText(tileAt(board, Coord(x, y)))}${Edge}` stdout.println(row) stdout.println(separator) showScore() -> points as Integer stdout.println(say.score(points)) //Asks once, then reads replies until one is a move. Unset when the player quits or the //input ends: either way the game is over. nextMove() <- rtn as Direction: Direction() stdout.println(say.askMove()) quitting <- false while not rtn? and not quitting and not input.ended() if reply <- input.next() command <- reply.upperCase().trim() quitting: command == say.quit() rtn: directionOf(command) if not rtn? and not quitting stdout.println(say.notAMove()) showGameOver() stdout.println(say.gameOver()) showGoodbye() stdout.println(say.goodbye()) ended() as pure <- rtn as Boolean: input.ended() override operator ? as pure <- rtn as Boolean: true defines function //THE GAME: its rules, with no input or output. //Parse a reply into a Direction; anything else yields an UNSET Direction. The keys are the //game's own data. directionOf() as pure -> letter as String <- rtn as Direction: (letter == "W" <- Direction.Up : letter == "A" <- Direction.Left : letter == "S" <- Direction.Down : letter == "D" <- Direction.Right : Direction()) //Every slide works on Size lines of Size cells. Cell `step` of line `line` is counted from //the edge the tiles slide toward, so one combined() handles all four directions. cellOf() as pure -> dir as Direction line as Integer step as Integer <- rtn as Coord: Coord() fromFarEdge <- Size - 1 - step switch dir case Direction.Up rtn: Coord(line, step) case Direction.Down rtn: Coord(line, fromFarEdge) case Direction.Left rtn: Coord(step, line) case Direction.Right rtn: Coord(fromFarEdge, line) default rtn: Coord() //The board holds only tiles: a blank cell is a Coord with no entry, so it reads as unset. tileAt() as pure -> board as Dict of (Coord, Integer) at as Coord <- rtn as Integer: board.getOrDefault(at, Integer()) //The tiles of one line in slide order, blanks left out. tilesOf() -> board as Dict of (Coord, Integer) dir as Direction line as Integer <- rtn as List of Integer: List() of Integer for step in 0 ... Size - 1 if tile <- tileAt(board, cellOf(dir, line, step)) rtn += tile //Merge equal neighbours, front first: [2, 2, 4, 4] -> [4, 8]. Each tile waits as //`pending` until the next one shows whether the two merge; a merged tile does not merge again. combined() -> tiles as List of Integer <- rtn as List of Integer: List() of Integer pending <- Integer() for tile in tiles if pending? and pending == tile rtn += pending * 2 pending: Integer() else if pending? rtn += pending pending: tile if pending? rtn += pending slid() -> board as Dict of (Coord, Integer) dir as Direction <- rtn as Dict of (Coord, Integer): Dict() of (Coord, Integer) for line in 0 ... Size - 1 merged <- combined(tilesOf(board, dir, line)) for step in 0 ... Size - 1 if tile <- merged.getOrDefault(step, Integer()) rtn += DictEntry(cellOf(dir, line, step), tile) //Blank cells in a fixed order (x, then y), so a seeded game always picks the same one. blankCells() -> board as Dict of (Coord, Integer) <- rtn as List of Coord: List() of Coord for x in 0 ... Size - 1 for y in 0 ... Size - 1 at <- Coord(x, y) if not board.contains(at) rtn += at addTile() -> board as Dict of (Coord, Integer) rng as Random blanks <- blankCells(board) if not blanks.empty() board += DictEntry(blanks.getOrDefault(rng.next(0, length blanks - 1), Coord()), NewTile) //A draw that lands on a tile already placed just puts a 2 there again, so the number of //tiles, not a separate "was it blank?" test, is what ends the loop. newBoard() -> rng as Random <- rtn as Dict of (Coord, Integer): Dict() of (Coord, Integer) while length rtn < StartingTiles rtn += DictEntry(Coord(rng.next(0, Size - 1), rng.next(0, Size - 1)), NewTile) score() -> board as Dict of (Coord, Integer) <- rtn as Integer: 0 for tile in board.values() rtn += tile isFull() as pure -> board as Dict of (Coord, Integer) <- rtn as Boolean: length board == Size * Size defines function //The Console's drawing: computed output, with no words in it. //A blank cell is drawn as an empty cell: the board has no "blank" value to show. tileText() as pure -> tile as Integer <- rtn as String: String(tile? <- $tile : "").center(CellWidth) //A rule across the board with a corner between each cell. separatorLine() <- rtn as String: Corner rule <- String("").rightPadded(CellWidth, Rule) for x in 1 ... Size rtn += `${rule}${Corner}` //THE source of randomness, copied unchanged from lib/RandomSource.ek9: SystemRandom(), or //SeededRandom(seed) when BIGBOOK_SEED holds a number (run-and-diff.sh sets it from seed.txt). randomSource() <- rtn as Random: SeededRandom(Integer(EnvVars().get("BIGBOOK_SEED"))) ?: SystemRandom() //THE language of the words, copied unchanged from lib/Language.ek9: BIGBOOK_LANG when it //is set (fr, zh, ...), otherwise en. language() <- rtn as String: EnvVars().get("BIGBOOK_LANG") ?: "en" //THE WORDS: every word the player sees or types. The board and its marks are not here: the //Console draws them from the game's tiles. defines text for "en" Say introduction() `Twenty Forty-Eight, by Al Sweigart al@inventwithpython.com Slide tiles with W/A/S/D; matching tiles combine. Make a 2048 tile to win!` score() -> points as Integer `Score: ${points}` askMove() "Enter move: (WASD or Q to quit)" quit() "Q" notAMove() "Enter one of W, A, S, D, or Q." gameOver() "Game Over - Thanks for playing!" goodbye() "Thanks for playing!" defines program //Twenty Forty-Eight (Big Book project 79). Slide the tiles so that equal neighbours merge and //double, until the board is full. The game decides; the Console asks and tells, in the player's //words. The golden replays a seeded game (seed.txt). TwentyFortyEight() //A language with no words of its own is unset: ?: falls back to English. console <- Console(Say(language()) ?: Say("en")) rng <- randomSource() console.showIntroduction() board <- newBoard(rng) running <- true while running and not console.ended() console.showBoard(board) console.showScore(score(board)) if move <- console.nextMove() board: slid(board, move) addTile(board, rng) if isFull(board) console.showBoard(board) console.showGameOver() running: false else console.showGoodbye() running: false
About this port
- Faithful to the book: 4x4 grid; W/A/S/D slide all tiles; equal neighbours merge and double, and a merged tile does not merge again in the same slide; a new 2 appears on a random blank cell after each move; a full board ends the game ("Game Over - Thanks for playing!"); Q quits. The end of input also ends the game.
- Model:
- The board is a
Dict of (Coord, Integer)holding only tiles. A blank cell is aCoordwith no entry, so it reads as unset: no0standing for "blank", no"x,y"string keys.isFullislength board == Size * Size. Coordis a record compared by value (default operator <=>and#?), the shape oflib/Coord.ek9: its constructor snapshots its arguments.- The moves are a
Directionenumeration.directionOfparses a reply and is unset for anything else, so the Console'snextMovereturns an unsetDirectionfor Q or the end of input and the program guards withif move <- console.nextMove(). cellOf(dir, line, step)names cellstepof a line counted from the edge the tiles slide toward. With it, onecombined(merge a line, front first) serves all four directions.combinedkeeps onependingtile until the next tile shows whether the two merge ([2, 2, 4, 4]gives[4, 8]), which is the book's result without its in-place double-and-shift.
- The board is a