Skip to content
FutureBlitz

    Install

    Projects, 3 of 3

    A terminal game (Snake, on std.term)

    Build Snake for the terminal on std.term, from drawing a board to a game you play with the arrow keys, tested with a FakeTerminal.

    What you’ll build

    Snake, played in the terminal: the snake moves a step at a time, the arrow keys turn it, it grows each time it eats, and the game ends when it hits a wall or itself. It’s an ordinary program on std.term: a Canvas to draw on, a Screen that shows it and reads keys, and a FakeTerminal that plays the game in tests. Each step adds one part, and the last is the whole game.

    The board

    A Canvas is a grid of cells to draw on, columns by rows. DrawBox draws a border around an area, with a title, and gives the area inside it. Print(column, row, text) writes text at a cell. A Canvas is Printable, so Print(canvas) shows its text, which is how this step and the next show the board.

    Import std.core
    Import std.term
    
    Local canvas = Canvas(22, 8)
    Local inside = canvas.DrawBox(canvas.Area, BorderStyle.ASCII, Style(), "Score 0")
    Local snake = [(6, 3), (5, 3), (4, 3)]
    For part In snake
        Local x, y = part
        canvas.Print(inside.Column + x, inside.Row + y, part == snake[0] ? "@" : "o")
    End For
    canvas.Print(inside.Column + 14, inside.Row + 1, "*")
    Print(canvas)
    Output
    +- Score 0 ----------+
    |                    |
    |              *     |
    |                    |
    |    oo@             |
    |                    |
    |                    |
    +--------------------+

    The snake is a list of cells, its head first. @ is the head, o the rest of the body, and * the food. Positions inside the box start from 0, so the code adds the inside area’s column and row to them.

    Moving and eating

    The game’s state goes in a Class. A Point is a cell, and Body lists the snake’s, head first. Each Step moves the head one cell in the Heading direction. If the new head is outside the board or on the body, the game is over. Otherwise it’s added at the front, and the last cell is dropped, unless the head is on the food: then the snake keeps its tail, grows by one, and new food is placed. A seeded Random places the food, so a game with the same seed goes the same way each time.

    Import std.core
    Import std.math
    Import std.term
    
    Enum Direction
        UP
        DOWN
        LEFT
        RIGHT
    End Enum
    
    Struct Point
        Field X: Int
        Field Y: Int
    End Struct
    
    Class Game
        Field Width: Int
        Field Height: Int
        Field Body: Point[]
        Field Heading: Direction
        Field Moved: Direction
        Field Food: Point
        Field Score: Int
        Field Over: Bool
        Field Dice: Random
    
        New(width: Int, height: Int, seed: Int)
            Width = width
            Height = height
            Local y = height / 2
            Body = [Point(3, y), Point(2, y), Point(1, y)]
            Heading = Direction.RIGHT
            Moved = Direction.RIGHT
            Dice = Random(seed)
            PlaceFood()
        End New
    
        Method Turn(to: Direction)
            Select to
                Case Direction.UP, Direction.DOWN
                    If Moved == Direction.LEFT || Moved == Direction.RIGHT Then Heading = to
                Default
                    If Moved == Direction.UP || Moved == Direction.DOWN Then Heading = to
            End Select
        End Method
    
        Method Step()
            If Over Then Return
            Local head = Body[0]
            Select Heading
                Case Direction.UP
                    head.Y -= 1
                Case Direction.DOWN
                    head.Y += 1
                Case Direction.LEFT
                    head.X -= 1
                Case Direction.RIGHT
                    head.X += 1
            End Select
    
            Local outside = head.X < 0 || head.Y < 0 || head.X >= Width || head.Y >= Height
            If outside || Body.Contains(head)
                Over = True
                Return
            End If
    
            Body.Insert(0, head)
            Moved = Heading
            If head == Food
                Score += 1
                PlaceFood()
            Else
                Body.RemoveAt(Body.Count - 1)
            End If
        End Method
    
        Method PlaceFood()
            Loop
                Food = Point(Dice.Int(0, Width - 1), Dice.Int(0, Height - 1))
                If !Body.Contains(Food) Then Return
            End Loop
        End Method
    
        Method Draw(canvas: Canvas)
            Local title = Over ? "Game over: {Score}" : "Score {Score}"
            Local area = Region(0, 0, Width + 2, Height + 2)
            Local box = canvas.DrawBox(area, BorderStyle.ASCII, Style(), title)
            canvas.Print(box.Column + Food.X, box.Row + Food.Y, "*")
            For i = 0 Until Body.Count
                canvas.Print(box.Column + Body[i].X, box.Row + Body[i].Y, i == 0 ? "@" : "o")
            End For
        End Method
    End Class
    
    Local game = Game(20, 6, 7)
    For move In [Direction.UP, Direction.UP, Direction.UP, Direction.RIGHT, Direction.RIGHT,
                 Direction.RIGHT, Direction.RIGHT]
        game.Turn(move)
        game.Step()
    End For
    
    Local canvas = Canvas(22, 8)
    game.Draw(canvas)
    Print(canvas)
    
    game.Turn(Direction.UP)
    game.Step()
    canvas.Clear()
    game.Draw(canvas)
    Print(canvas)
    Output
    +- Score 1 ----------+
    |  * ooo@            |
    |                    |
    |                    |
    |                    |
    |                    |
    |                    |
    +--------------------+
    +- Game over: 1 -----+
    |  * ooo@            |
    |                    |
    |                    |
    |                    |
    |                    |
    |                    |
    +--------------------+

    The scripted moves go up three cells and right four, onto the food, so the score is 1 and the snake is four cells long. One more step up leaves the board, and the game is over. Turn ignores a turn back onto the snake’s own neck, from left to right or up to down. It checks against Moved, the way the last step went, not Heading, so two turns before a step, like Up and then Left while it heads right, can’t fold the snake back onto itself.

    Keys

    A terminal sends keys as bytes: a letter as itself, and an arrow key as an escape sequence, like ESC [ A for Up. std.term’s InputParser turns the bytes into InputEvents. IsKey tests for a key that types no character, and IsChar for one that does. Feeding the parser bytes, as a terminal would send them, shows what each key becomes:

    Import std.core
    Import std.term
    
    Enum Direction
        UP
        DOWN
        LEFT
        RIGHT
    End Enum
    
    Function DirectionOf(event: InputEvent): Direction?
        If event.IsKey(KeyCode.UP) || event.IsChar('w') Then Return Direction.UP
        If event.IsKey(KeyCode.DOWN) || event.IsChar('s') Then Return Direction.DOWN
        If event.IsKey(KeyCode.LEFT) || event.IsChar('a') Then Return Direction.LEFT
        If event.IsKey(KeyCode.RIGHT) || event.IsChar('d') Then Return Direction.RIGHT
        Return Null
    End Function
    
    Local parser = InputParser()
    parser.Feed("\u{1b}[A\u{1b}[Dwxq")
    Loop
        Local event = parser.Next()
        If event == Null Then Break
        Local turn = DirectionOf(event)
        Print("{event}: {turn?.ToString() ?? "no turn"}")
    End Loop
    Output
    key Up: UP
    key Left: LEFT
    key w: UP
    key x: no turn
    key q: no turn

    DirectionOf gives Null for a key that doesn’t turn the snake, so its result is a Direction?. A Screen reads keys from the terminal with its own parser, so the game never feeds one itself.

    The game on the screen

    Now the game goes into a package, so a test can play it. game.fb holds the game, with what the other files use marked Public, and a Play function that runs it on a Screen. A Screen takes over the whole terminal: it switches to the terminal’s alternate screen, hides the cursor, and reads keys as they’re pressed, and Close() gives the terminal back as it was.

    Play draws a frame on the Screen’s Frame, a Canvas, and Present() writes the cells that changed to the terminal. PollEvent(timeout) waits for a key, at most until the next step is due, so the snake moves at a steady pace however fast keys come. A Deadline from std.time keeps that time: Remaining is how long is left until it, and HasPassed says when it’s time to step.

    project.toml
    [project]
    name = "snake"
    version = "1.0.0"
    game.fb
    Import std.core
    Import std.math
    Import std.term
    Import std.time
    
    /// Where the snake is heading.
    Public Enum Direction
        UP
        DOWN
        LEFT
        RIGHT
    End Enum
    
    /// A cell of the board, from 0, 0 at its top left.
    Public Struct Point
        Public Field X: Int
        Public Field Y: Int
    End Struct
    
    /// A game of Snake on a board of `Width` by `Height` cells.
    Public Class Game
        Public Field Width: Int
        Public Field Height: Int
        /// The snake's cells, its head first.
        Public Field Body: Point[]
        Public Field Heading: Direction
        /// The way the last step went, which a turn can't reverse.
        Field Moved: Direction
        Public Field Food: Point
        Public Field Score: Int
        Public Field Over: Bool
        Field Dice: Random
    
        /// A new game, its snake three cells long, heading right, with the food placed by `seed`.
        Public New(width: Int, height: Int, seed: Int)
            Width = width
            Height = height
            Local y = height / 2
            Body = [Point(3, y), Point(2, y), Point(1, y)]
            Heading = Direction.RIGHT
            Moved = Direction.RIGHT
            Dice = Random(seed)
            PlaceFood()
        End New
    
        /// Heads `to`, unless that's straight back onto the snake's neck.
        Public Method Turn(to: Direction)
            Select to
                Case Direction.UP, Direction.DOWN
                    If Moved == Direction.LEFT || Moved == Direction.RIGHT Then Heading = to
                Default
                    If Moved == Direction.UP || Moved == Direction.DOWN Then Heading = to
            End Select
        End Method
    
        /// Moves the snake a cell, eating the food there, or ends the game at a wall or its body.
        Public Method Step()
            If Over Then Return
            Local head = Body[0]
            Select Heading
                Case Direction.UP
                    head.Y -= 1
                Case Direction.DOWN
                    head.Y += 1
                Case Direction.LEFT
                    head.X -= 1
                Case Direction.RIGHT
                    head.X += 1
            End Select
    
            Local outside = head.X < 0 || head.Y < 0 || head.X >= Width || head.Y >= Height
            If outside || Body.Contains(head)
                Over = True
                Return
            End If
    
            Body.Insert(0, head)
            Moved = Heading
            If head == Food
                Score += 1
                PlaceFood()
            Else
                Body.RemoveAt(Body.Count - 1)
            End If
        End Method
    
        /// Puts the food on a cell the snake isn't on.
        Method PlaceFood()
            Loop
                Food = Point(Dice.Int(0, Width - 1), Dice.Int(0, Height - 1))
                If !Body.Contains(Food) Then Return
            End Loop
        End Method
    
        /// Draws the board, its border, the food, and the snake on `canvas`.
        Public Method Draw(canvas: Canvas)
            Local title = Over ? "Game over: {Score}" : "Score {Score}"
            Local area = Region(0, 0, Width + 2, Height + 2)
            Local box = canvas.DrawBox(area, BorderStyle.ASCII, Style(), title)
            canvas.Print(box.Column + Food.X, box.Row + Food.Y, "*", Style(TermColor.Red))
            Local look = Style(TermColor.Green).Bold()
            For i = 0 Until Body.Count
                canvas.Print(box.Column + Body[i].X, box.Row + Body[i].Y, i == 0 ? "@" : "o", look)
            End For
        End Method
    End Class
    
    /// The direction a key turns the snake: an arrow key, or W, A, S, or D.
    Public Function DirectionOf(event: InputEvent): Direction?
        If event.IsKey(KeyCode.UP) || event.IsChar('w') Then Return Direction.UP
        If event.IsKey(KeyCode.DOWN) || event.IsChar('s') Then Return Direction.DOWN
        If event.IsKey(KeyCode.LEFT) || event.IsChar('a') Then Return Direction.LEFT
        If event.IsKey(KeyCode.RIGHT) || event.IsChar('d') Then Return Direction.RIGHT
        Return Null
    End Function
    
    /// Plays `game` on `screen`, a step each `tick`, until Q or Escape, or the game is over; then it
    /// shows the last frame until a key.
    Public Function Play(screen: Screen, game: Game, tick: Duration)
        Local next = Deadline.After(tick)
        While !game.Over
            screen.Clear()
            game.Draw(screen.Frame)
            screen.Present()
    
            Local event = screen.PollEvent(next.Remaining)
            If event != Null
                If event.IsChar('q') || event.IsKey(KeyCode.ESCAPE) Then Return
                Local turn = DirectionOf(event)
                If turn != Null Then game.Turn(turn)
            End If
    
            If next.HasPassed
                game.Step()
                next = Deadline.After(tick)
            End If
        End While
    
        screen.Clear()
        game.Draw(screen.Frame)
        screen.Present()
        screen.ReadEvent()
    End Function
    main.fb
    Import std.core
    Import std.term
    Import std.time
    Import snake.game
    
    Local screen = Screen()
    Local game = Game(30, 12, 42)
    Play(screen, game, 150.Milliseconds)
    screen.Close()
    Print("Score: {game.Score}")

    A FakeTerminal is a terminal for tests: it keeps what’s written to it, and gives what it’s fed as keys. Pause() is a moment when no key is pressed, so a wait for a key gives nothing. With a tick of 0, each pass of Play’s loop takes one fed key or pause and makes one step, so a test plays the same game every time.

    tests/play_test.fb
    Import std.core
    Import std.test
    Import std.term
    Import std.time
    Import snake.game
    
    @Test
    Function EatsTheFood()
        Local terminal = FakeTerminal(22, 8)
        For i = 1 To 3
            terminal.Pause()
        End For
        terminal.Feed("\u{1b}[C")
        For i = 1 To 3
            terminal.Pause()
        End For
        terminal.Feed("q")
    
        Local game = Game(20, 6, 7)
        game.Turn(Direction.UP)
        Play(Screen(terminal, mouse: False), game, 0.Milliseconds)
        Expect(game.Score == 1)
        Expect(game.Body.Count == 4)
        Expect(!game.Over)
    End Function
    
    @Test
    Function IgnoresAQuickReverse()
        Local game = Game(20, 6, 7)
        game.Turn(Direction.UP)
        game.Turn(Direction.LEFT)
        game.Step()
        Expect(!game.Over)
        Expect(game.Heading == Direction.UP)
        Expect(game.Body[0].Y == 2)
    End Function
    
    @Test
    Function EndsAtAWall()
        Local terminal = FakeTerminal(22, 8)
        Local game = Game(20, 6, 7)
        Play(Screen(terminal, mouse: False), game, 0.Milliseconds)
        Expect(game.Over)
        Expect(game.Body[0].X == 19)
    End Function
    Output
         Running 3 tests
    test snake.tests.play_test.EatsTheFood ... ok
    test snake.tests.play_test.EndsAtAWall ... ok
    test snake.tests.play_test.IgnoresAQuickReverse ... ok
    
    test result: ok. 3 passed; 0 failed; 0 skipped; 0 filtered out; finished in 0.02s

    The first test goes up three steps and right four, onto the food, as the scripted moves did, and then presses Q. The second turns Up and then Left before one step, as two quick keys within a tick would, while the snake heads right: Left would fold it back onto its neck, so only Up counts. In the third, nothing is pressed, so the snake runs right into the wall.

    Playing it

    fb run

    The arrow keys or W, A, S, and D steer, and Q or Escape quits. When the game ends, the last frame stays until a key is pressed, and then the program prints the score. Screen gives the terminal back however the program ends, a panic included, so a bug never leaves the terminal in raw mode. fb build --release makes the game one executable.

    What you learned

    • A Canvas holds cells to draw on, with DrawBox and Print, and shows as text.
    • A Screen takes over the terminal, shows its Frame with Present, and reads keys.
    • PollEvent with a Deadline’s Remaining reads keys while keeping a steady pace.
    • InputEvent’s IsKey and IsChar tell keys apart.
    • A FakeTerminal plays a terminal program in tests, with fed keys and pauses.

    Report an issue with this page or with FutureBlitz on GitHub.