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