Snaaake!

So, I was at FOSDEM this last weekend, and had quite a lot of fun. One of the cooler talks was actually Yuri Numerov's talk about making simple console-based games in Python.

I thought hey, I can do that too! and started hacking on a Snake implementation right away. You know, that game you used to waste all your time back in the Nokia 3210 days? Yep, that one.

Turns out, you can do that in less than 100 lines of python. Or in about 250ish lines if you add some more features (like boosters, and increasing difficulty!) and tidying up the code a bit.

So - if you ever thought that making games is hard, think again. And just try to do it in the simplest way possible...

Here's what I came up (hacked in about 1h total):

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
# vim: autoindent expandtab tabstop=4 sw=4 sts=4 filetype=python

import os
import sys
import time
import termios
import select
import fcntl
import struct
import random

from tty import IFLAG, OFLAG, CFLAG, LFLAG, VTIME
from tty import BRKINT, ICRNL, INPCK, ISTRIP
from tty import IXON, OPOST, CSIZE, PARENB, VMIN
from tty import CS8, ECHO, ICANON, IEXTEN, ISIG, CC


class Game:

    snake = {}

    TURN_TIME = 0.3

    SPEEDUP_FACTOR = 0.95

    last_move = time.time()

    boost_val = 0

    WIDTH  = None
    HEIGHT = None

    direction = 'up'
    head      = None
    length    = 5

    @classmethod
    def init(cls):
        term_width, term_height = cls.term_size()
        cls.WIDTH  = term_width // 2
        cls.HEIGHT = term_height

        cls.head      = (cls.HEIGHT // 2, cls.WIDTH // 2)
        cls.direction = 'up'
        cls.last_move = time.time()

    @staticmethod
    def term_size():
        # Stolen from
        # http://stackoverflow.com/questions/566746/how-to-get-console-window-width-in-python
        env = os.environ

        def ioctl_GWINSZ(fd):
            try:
                cr = struct.unpack(
                    'hh',
                    fcntl.ioctl(fd, termios.TIOCGWINSZ, '1234'))
            except:
                return
            return cr
        cr = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2)
        if not cr:
            try:
                fd = os.open(os.ctermid(), os.O_RDONLY)
                cr = ioctl_GWINSZ(fd)
                os.close(fd)
            except:
                pass
        if not cr:
            cr = (env.get('LINES', 25), env.get('COLUMNS', 80))

        return int(cr[1]), int(cr[0])

    @classmethod
    def place_star(cls):
        cls.star = (
            random.randint(1, cls.HEIGHT - 2),
            random.randint(1, cls.WIDTH - 2)
        )
        if cls.star in cls.snake:
            # collision!
            cls.place_star()

    @staticmethod
    def clearscreen():
        # obtained via "clear | hexdump"
        print('\x5b\x1b\x4a\x33\x5b\x1b\x1b\x48\x32\x5b\x00\x4a')

    @classmethod
    def draw(cls):
        cls.clearscreen()

        for x in range(0, cls.HEIGHT):
            line_parts = []
            for y in range(0, cls.WIDTH):
                if (x, y) == cls.head:
                    line_parts.append('OO')
                elif (x, y) in cls.snake:
                    line_parts.append('##')
                elif (x, y) == cls.star:
                    line_parts.append('* ')
                else:
                    line_parts.append('  ')
            print(''.join(line_parts))

    @classmethod
    def dead(cls, action):
        print("You %s and DIED (Score: %d)" % (action, cls.length))
        sys.exit()

    @classmethod
    def evolve(cls):

        cls.boost_val -= 1

        for cell in list(cls.snake.keys()):
            cls.snake[cell] -= 1
            if cls.snake[cell] <= 0:
                del cls.snake[cell]

        # Note this is calculating all directions at every move. This is a very
        # low-performance game, so we're choosing readability over performance
        # here.
        cls.head = {
            'up':    (cls.head[0] - 1, cls.head[1]),
            'down':  (cls.head[0] + 1, cls.head[1]),
            'left':  (cls.head[0], cls.head[1] - 1),
            'right': (cls.head[0], cls.head[1] + 1),
        }[cls.direction]

        if cls.head in cls.snake:
            # Tail bite!
            cls.dead("bit yourself")

        if (cls.head[0] < 0 or cls.head[1] < 0 or
                cls.head[0] >= cls.HEIGHT or cls.head[1] >= cls.WIDTH):
            cls.dead("hit a wall")

        if cls.head == cls.star:
            cls.length += 1
            cls.TURN_TIME = cls.TURN_TIME * cls.SPEEDUP_FACTOR
            cls.place_star()

        cls.snake[cls.head] = cls.length

    @classmethod
    def update_direction(cls, direction):
        # don't do 180° turns
        if any([
                direction == 'up' and cls.direction == 'down',
                direction == 'down' and cls.direction == 'up',
                direction == 'left' and cls.direction == 'right',
                direction == 'right' and cls.direction == 'left']):
            return
        cls.direction = direction

    @classmethod
    def boost(cls):
        if cls.boost_val < cls.length / 2:
            cls.boost_val = cls.length * 2

    @classmethod
    def infoscreen(cls):
        lines = [
            "Welcome to this simple snakes game...",
            "",
            "             h    - go left",
            "             j    - go down",
            "             k    - go up",
            "             l    - go right",
            "        <space>   - booster",
            "",
            "        <esc> / q - exit",
        ]

        textwidth = max([len(l) for l in lines])

        # note: cls.WIDTH is GAME width (double-chars), not terminal width
        indent = " " * (cls.WIDTH - textwidth // 2)

        cls.clearscreen()

        print("\n" * (cls.HEIGHT // 2 - len(lines) // 2))

        print("\n".join(['%s%s' % (indent, l) for l in lines]))

        print("\n" * (cls.HEIGHT // 2 - len(lines) // 2))
        time.sleep(4)

    @classmethod
    def turn_time_delta(cls):
        if cls.boost_val < 0:
            return cls.TURN_TIME
        return cls.TURN_TIME / 3

    @staticmethod
    def debug_key(key):
        print("------- unknown key: %s (ord %d)" % (key, ord(key)))
        sys.exit()

    @classmethod
    def wait_and_get_input(cls):
        fd = sys.stdin.fileno()
        old_settings = termios.tcgetattr(fd)
        new_settings = termios.tcgetattr(fd)

        new_settings[IFLAG] = new_settings[IFLAG] & ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON)  # noqa
        new_settings[OFLAG] = new_settings[OFLAG] & ~(OPOST)
        new_settings[CFLAG] = new_settings[CFLAG] & ~(CSIZE | PARENB)
        new_settings[CFLAG] = new_settings[CFLAG] | CS8
        new_settings[LFLAG] = new_settings[LFLAG] & ~(ECHO | ICANON | IEXTEN | ISIG)  # noqa
        new_settings[CC][VMIN] = 1
        new_settings[CC][VTIME] = 0

        try:
            termios.tcsetattr(fd, termios.TCSADRAIN, new_settings)

            while time.time() < cls.last_move + cls.turn_time_delta():
                i, o, e = select.select(
                    [sys.stdin], [], [], cls.TURN_TIME / 10)

                for s in i:
                    if s == sys.stdin:
                        char = sys.stdin.read(1)
                        action = {
                            'h': lambda: cls.update_direction('left'),
                            'j': lambda: cls.update_direction('down'),
                            'k': lambda: cls.update_direction('up'),
                            'l': lambda: cls.update_direction('right'),
                            ' ': lambda: cls.boost(),

                            '\x03': lambda: cls.dead('quit'),  # ^C apparently
                            '\x1b': lambda: cls.dead('quit'),  # ESC
                            'q':    lambda: cls.dead('quit'),
                        }.get(char, lambda: cls.debug_key(char))

                        action()

            cls.last_move = time.time()
        finally:
            termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)


if __name__ == '__main__':
    Game.init()
    Game.infoscreen()
    Game.place_star()
    while True:
        Game.draw()
        Game.wait_and_get_input()
        Game.evolve()