Please checkout the repository at https://github.com/rational-kunal/SSSnake
Basic terminal configuration
To create a smooth terminal-based game, we need to control the terminal behavior by: 1) Hiding the cursor to prevent flickering and 2) Enabling raw mode to capture key presses without requiring the user to hit Enter.
static func hideCursor() { print("\u{001B}[?25l", terminator: "") } static func enableRawMode() { var raw = termios() tcgetattr(STDIN_FILENO, &raw) raw.c_lflag &= ~tcflag_t(ECHO | ICANON) // Disable echo & line buffering raw.c_cc.0 = 1 // Min character read raw.c_cc.1 = 0 // No timeout tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) }
Rendering the Game
The game is displayed using a 2D character array (canvas). To render a frame, we print this array.
Also, instead of printing each frame anew (which causes flickering), we move the cursor to the top and update the existing buffer.
struct Terminal { private(set) var canvas: [[Character]] = TerminalHelper.makeCanvas() // Renders the canvas / frame on the terminal mutating func render() { TerminalHelper.moveCursor(x: 0, y: 0) for row in canvas { print(String(row)) } fflush(stdout) } // Draws the symbol at the given position mutating func draw(x: Int, y: Int, symbol: Character) { canvas[y][x] = symbol } }
To compute the size of the canvas, i.e. terminal size, we can use the ioctl
function.
private struct TerminalHelper { static func windowSize() -> (width: Int, height: Int) { var ws = winsize() _ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) return (Int(ws.ws_col), Int(ws.ws_row) - 1) } // Create blank 2d buffer of blank characters static func makeCanvas() -> [[Character]] { let (width, height) = windowSize() return Array(repeating: Array(repeating: " ", count: width), count: height) } }
So now we have a way to render frames on the terminal.
Get the user input
We need a non-blocking way to listen for user input. Running a background thread ensures that key presses are read continuously while the game logic runs separately.
DispatchQueue.global(qos: .userInteractive).async { [weak self] in while let self, self.isRunning, let key = Terminal.getKeyPress() { self.processInput(key) } } extension Terminal { static func getKeyPress() -> String? { var buffer = [UInt8](repeating: 0, count: 3) return read(STDIN_FILENO, &buffer, 3) == 1 ? String(UnicodeScalar(buffer[0])) : nil } }
Game loop
There should be a loop where we will ask the game to update its state and then update the canvas and then render it.
class Game { lazy var terminal = Terminal() public func start() { // Start the game loop while isRunning { terminal.update() // Refresh canvas loop() // Update game state draw() // Draw updated elements terminal.render() // Render the frame usleep(100_000) // Control game speed (10 FPS) } } func processInput(_ key: String) {} func loop() {} func draw() {} }
Cool now we have a basic skeleton to build the game
Snake game
Now, we can override the Game class to create our own Snake game.
class SnakeGame: Game { override func draw() { // Draw border, snake and its food // Like: terminal.at(x: 12, y: 33, symbol: "#") } override func loop() { // Update game state like move the snake spawn food if needed etc } override func processInput(_ key: String) { // Process inputs turn the direction of snke as needed } }
Check out its implementation at SnakeGame.swift
Thanks for reading.
Top comments (0)