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)+- 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)+- 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 Loopkey 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]
name = "snake"
version = "1.0.0"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 FunctionImport 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.
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 FunctionRunning 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 runThe 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
Canvasholds cells to draw on, withDrawBoxandPrint, and shows as text. - A
Screentakes over the terminal, shows itsFramewithPresent, and reads keys. PollEventwith aDeadline’sRemainingreads keys while keeping a steady pace.InputEvent’sIsKeyandIsChartell keys apart.- A
FakeTerminalplays a terminal program in tests, with fed keys and pauses.
Report an issue with this page or with FutureBlitz on GitHub.