10
\$\begingroup\$

Description

Your task is to imitate a turn-based variation of the popular "Snake" game.

You are given the initial configuration of the board and a list of commands which the snake follows one-by-one. The game ends if one of the following happens:

  • the snake tries to eat its tail
  • the snake tries to move out of the board
  • it executes all the given commands.

Output the board configuration after the game ends. With the snake replaced by 'X' if the game ends abnormally.

Examples

input:
snakeGame([['.', '.', '.', '.'],
           ['.', '.', '<', '*'],
           ['.', '.', '.', '*']],
          "FFFFFRFFRRLLF")

output:
          [['.', '.', '.', '.'],
           ['X', 'X', 'X', '.'],
           ['.', '.', '.', '.']]

input:
snakeGame([['.', '.', '^', '.', '.'],
           ['.', '.', '*', '*', '.'],
           ['.', '.', '.', '*', '*']],
          "RFRF")

output:
          [['.', '.', 'X', 'X', '.'],
           ['.', '.', 'X', 'X', '.'],
           ['.', '.', '.', 'X', '.']]

input:
snakeGame([['.', '.', '*', '>', '.'],
           ['.', '*', '*', '.', '.'],
           ['.', '.', '.', '.', '.']],
          "FRFFRFFRFLFF")

output:
          [['.', '.', '.', '.', '.'],
           ['<', '*', '*', '.', '.'],
           ['.', '.', '*', '.', '.']]

Code

def snakeGame(gameBoard, commands):
    heads = {'v': (1, 0), '^': (-1, 0), '<': (0, -1), '>': (0, 1)}
    new_direc = {'v': {'L': '>', 'R': '<'}, 
                 '^': {'L': '<', 'R': '>'},                 
                 '<': {'L': 'v', 'R': '^'}, 
                 '>': {'L': '^', 'R': 'v'}}
    
    def find_snake():
        def get_next(x, y):
            for key, direc in heads.items():
                new_x, new_y = x + direc[0], y + direc[1]
                if new_x in range(len(gameBoard)) and new_y in range(len(gameBoard[0])):
                    if (new_x, new_y) not in snake:
                        if gameBoard[new_x][new_y] == '*':
                            return (new_x, new_y)

        # Get the head and the next body opposite of snake's direction
        snake = []
        for i, row in enumerate(gameBoard):
            for head in heads:
                if head in row:
                    snake.append((i, row.index(head)))
                    snake.append((snake[0][0] + heads[head][0] * -1, snake[0][1]  + heads[head][1] * -1))
        
        # Append the rest of the body
        while True:
            n = get_next(snake[-1][0], snake[-1][1])            
            if n is None:
                break
            snake.append(n)
        
        return snake
    
    def move_snake(snake):
        head = gameBoard[snake[0][0]][snake[0][1]]
        new_x, new_y = snake[0][0] + heads[head][0], snake[0][1]  + heads[head][1]
        new_snake = []
        if new_x in range(len(gameBoard)) and new_y in range(len(gameBoard[0])) and (new_x, new_y) not in snake:
            new_snake.append((new_x, new_y))
            
            for pos in snake[:-1]:
                new_snake.append(pos)
                
            return new_snake
             
    # Find starting snake
    snake = find_snake()
    
    for command in commands:   
        if command in "LR":
            # Change the head
            gameBoard[snake[0][0]][snake[0][1]] = new_direc[gameBoard[snake[0][0]][snake[0][1]]][command]
        else:
            temp = move_snake(snake)
            
            # if not valid move return dead snake
            if temp is None:
                for pos in snake:
                    x, y = pos
                    gameBoard[x][y] = 'X'
                return gameBoard
            
            # else move snake
            for a, b in zip(snake, temp):
                gameBoard[b[0]][b[1]] = gameBoard[a[0]][a[1]]
                
            gameBoard[snake[-1][0]][snake[-1][1]] = '.'
            snake = temp
    
    return gameBoard
\$\endgroup\$
0
3
\$\begingroup\$

Your code seems to work and is split into small functions. Here are a few things to make it easier to understand.

Try to avoid nested functions

Nested functions can be really useful but in your case, it makes the code hard to understand because it is nested on many levels.

Also, because some parameters are not explicitly provided but somehow used as part of the functions context, it is hard to tell what is used/not used, input/output, updated/unchanged.

This is fairly easy to change to make the code more linear with the different functions defined one after the other (in order to do so, you need to add parameters in the function signatures and in the function calls). Also, you could take this chance to extract a few constant (and name them in UPPERCASE):

EMPTY = '.'
BODY = '*'
DEAD = 'X'
HEADS = {'v': (1, 0), '^': (-1, 0), '<': (0, -1), '>': (0, 1)}

def get_next(x, y, snake, gameBoard):
    for key, direc in HEADS.items():
        new_x, new_y = x + direc[0], y + direc[1]
        if new_x in range(len(gameBoard)) and new_y in range(len(gameBoard[0])):
            if (new_x, new_y) not in snake:
                if gameBoard[new_x][new_y] == BODY:
                    return (new_x, new_y)

def find_snake(gameBoard):
    # Get the head and the next body opposite of snake's direction
    snake = []
    for i, row in enumerate(gameBoard):
        for head in HEADS:
            if head in row:
                snake.append((i, row.index(head)))
                snake.append((snake[0][0] + HEADS[head][0] * -1, snake[0][1]  + HEADS[head][1] * -1))

    # Append the rest of the body
    while True:
        n = get_next(snake[-1][0], snake[-1][1], snake, gameBoard)
        if n is None:
            break
        snake.append(n)

    return snake

def move_snake(snake, gameBoard):
    head = gameBoard[snake[0][0]][snake[0][1]]
    new_x, new_y = snake[0][0] + HEADS[head][0], snake[0][1]  + HEADS[head][1]
    new_snake = []
    if new_x in range(len(gameBoard)) and new_y in range(len(gameBoard[0])) and (new_x, new_y) not in snake:
        new_snake.append((new_x, new_y))

        for pos in snake[:-1]:
            new_snake.append(pos)

        return new_snake


def snakeGame(gameBoard, commands):
    new_direc = {'v': {'L': '>', 'R': '<'}, 
                 '^': {'L': '<', 'R': '>'},                 
                 '<': {'L': 'v', 'R': '^'}, 
                 '>': {'L': '^', 'R': 'v'}}

    # Find starting snake
    snake = find_snake(gameBoard)

    for command in commands:   
        if command in "LR":
            # Change the head
            gameBoard[snake[0][0]][snake[0][1]] = new_direc[gameBoard[snake[0][0]][snake[0][1]]][command]
        else:
            temp = move_snake(snake, gameBoard)

            # if not valid move return dead snake
            if temp is None:
                for pos in snake:
                    x, y = pos
                    gameBoard[x][y] = DEAD 
                return gameBoard

            # else move snake
            for a, b in zip(snake, temp):
                gameBoard[b[0]][b[1]] = gameBoard[a[0]][a[1]]

            gameBoard[snake[-1][0]][snake[-1][1]] = EMPTY 
            snake = temp

    return gameBoard


def test_snake(gameBoard, commands, expected):
    out = snakeGame(gameBoard, commands)
    if out != expected:
        print(out, commands, expected)

test_snake([['.', '.', '.', '.'],
            ['.', '.', '<', '*'],
            ['.', '.', '.', '*']],
           "FFFFFRFFRRLLF",
           [['.', '.', '.', '.'],
            ['X', 'X', 'X', '.'],
            ['.', '.', '.', '.']])
test_snake([['.', '.', '^', '.', '.'],
            ['.', '.', '*', '*', '.'],
            ['.', '.', '.', '*', '*']],
           "RFRF",
           [['.', '.', 'X', 'X', '.'],
            ['.', '.', 'X', 'X', '.'],
            ['.', '.', '.', 'X', '.']])
test_snake([['.', '.', '*', '>', '.'],
            ['.', '*', '*', '.', '.'],
            ['.', '.', '.', '.', '.']],
           "FRFFRFFRFLFF",
           [['.', '.', '.', '.', '.'],
            ['<', '*', '*', '.', '.'],
            ['.', '.', '*', '.', '.']])

Getting rid of indices (almost) everywhere

In your code, element access (foo[i]) is used everywhere:

  • to get/set a particular cell of the grid
  • to get the x (or y) part of coordinates
  • the get the direction from a particular "head" characters

It may make things confusing but you can use a few techniques to get rid of it:

  • When you iterate over a dictionnary, you can choose if you are interested in keys, values or both. Then you don't need to get my_dict[my_key].
  • You can use iterator unpacking when you know the number of elements in it. This is very convenient when you handle coordinates: x, y = my_coor. Also, this can be used in the for syntax: for head, (dx, dy) in HEADS.items().
  • Instead of using list access to get the element you've just added to the (empty) list, you can use temporary variables:

                snake.append((i, row.index(head)))
                snake.append((snake[0][0] + heads[head][0] * -1, snake[0][1]
    

becomes

                beginx, beginy = i, row.index(head)
                snake.append((beginx, beginy))
                snake.append((beginx - dx, beginy - dy))

It "costs" an additional line but it makes things clearer to me. (Also I took this chance to replace a + b * -1 by a - b).

Combining all these techniques, you'd get something like:

EMPTY = '.'
BODY = '*'
DEAD = 'X'
HEADS = {'v': (1, 0), '^': (-1, 0), '<': (0, -1), '>': (0, 1)}

def get_next(x, y, snake, gameBoard):
    for (dx, dy)  in HEADS.values():
        new_x, new_y = x + dx, y + dy
        if new_x in range(len(gameBoard)) and \
           new_y in range(len(gameBoard[0])) and \
           (new_x, new_y) not in snake and \
           gameBoard[new_x][new_y] == BODY:
            return (new_x, new_y)

def find_snake(gameBoard):
    # Get the head and the next body opposite of snake's direction
    snake = []
    for i, row in enumerate(gameBoard):
        for head, (dx, dy) in HEADS.items():
            if head in row:
                beginx, beginy = i, row.index(head)
                snake.append((beginx, beginy))
                snake.append((beginx - dx, beginy - dy))

    # Append the rest of the body
    while True:
        n = get_next(snake[-1][0], snake[-1][1], snake, gameBoard)
        if n is None:
            break
        snake.append(n)

    return snake

def move_snake(snake, gameBoard):
    headx, heady = snake[0]
    head = gameBoard[headx][heady]
    dx, dy = HEADS[head]
    new_x, new_y = headx + dx, heady + dy
    new_coord = new_x, new_y
    new_snake = []
    if new_x in range(len(gameBoard)) and \
       new_y in range(len(gameBoard[0])) and \
       new_coord not in snake:
        new_snake.append(new_coord)
        for pos in snake[:-1]:
            new_snake.append(pos)
        return new_snake


def snakeGame(gameBoard, commands):
    new_direc = {'v': {'L': '>', 'R': '<'}, 
                 '^': {'L': '<', 'R': '>'},                 
                 '<': {'L': 'v', 'R': '^'}, 
                 '>': {'L': '^', 'R': 'v'}}
    # Find starting snake
    snake = find_snake(gameBoard)

    for command in commands:   
        if command in "LR":
            # Change the head
            headx, heady = snake[0]
            gameBoard[headx][heady] = new_direc[gameBoard[headx][heady]][command]
        else:
            temp = move_snake(snake, gameBoard)

            # if not valid move return dead snake
            if temp is None:
                for (x, y) in snake:
                    gameBoard[x][y] = DEAD 
                return gameBoard

            # else move snake
            for a, b in zip(snake, temp):
                gameBoard[b[0]][b[1]] = gameBoard[a[0]][a[1]]

            tailx, taily = snake[-1]
            gameBoard[tailx][taily] = EMPTY 
            snake = temp

    return gameBoard

Other code simplifications

Now that we provide the full snake to the get_next function, it doesn't really need to x and y values.

def get_next(snake, gameBoard):
    x, y = snake[-1]
    ....

In the move_snake function, you write a loop to add elements from snake[:-1]. You could use a simple + operation on lists.

Also, it is clearer to add return None at the end of a function whose return value is used. This is one of the latest addition to PEP 8:

Be consistent in return statements. Either all return statements in a function should return an expression, or none of them should. If any return statement returns an expression, any return statements where no value is returned should explicitly state this as return None, and an explicit return statement should be present at the end of the function (if reachable).

Then, the whole function becomes:

def move_snake(snake, gameBoard):
    headx, heady = snake[0]
    head = gameBoard[headx][heady]
    dx, dy = HEADS[head]
    new_x, new_y = headx + dx, heady + dy
    new_coord = new_x, new_y
    if new_x in range(len(gameBoard)) and \
       new_y in range(len(gameBoard[0])) and \
       new_coord not in snake:
        return [new_coord] + snake[:-1]
    return None

Other ideas

At the moment, we have the snakes represented in 2 ways : as a list of body parts (which makes sense given how convenient it is to make the snake go forward) and as a drawing on a grid (which makes sense because it is our input and our output).

Also, for every step forward, we need to update both representations. Maybe, it would make sense to keep the listy-snake (and other data you might need) during the computations of the different steps and only generate the output grid at the very end.

As an exercice, I've tried to do this. I went for an object approach to store the different data needed and interact easily with them. I've tried to keep as much as possible from your code:

EMPTY = '.'
BODY = '*'
DEAD = 'X'
HEADS = {'v': (1, 0), '^': (-1, 0), '<': (0, -1), '>': (0, 1)}

class Snake(object):
    # Heads and body parts are (x, y, char)
    def __init__(self, head, dimx, dimy):
        self.dimx = dimx
        self.dimy = dimy
        self.body = [head]
        self.alive = True

    def add_queue(self, body_part):
        self.body.append(body_part)

    def turn(self, direc):
        new_direc = {'v': {'L': '>', 'R': '<'}, 
                     '^': {'L': '<', 'R': '>'},                 
                     '<': {'L': 'v', 'R': '^'}, 
                     '>': {'L': '^', 'R': 'v'}}
        x, y, head = self.body[0]
        new_head = new_direc[head][direc]
        self.body[0] = (x, y, new_head)

    def move_forward(self):
        x, y, char = self.body[0]
        dx, dy = HEADS[char]
        new_x, new_y = x + dx, y + dy
        if self.position_is_free(new_x, new_y): 
            self.body = [(new_x, new_y, char)] + [(x, y, BODY)] + self.body[1:-1]
        else:
            self.die()

    def position_is_free(self, x, y):
        return x in range(self.dimx) and \
               y in range(self.dimy) and \
               not any(x == x2 and y == y2 for (x2, y2, _) in self.body)

    def die(self):
       self.alive = False
       self.body = [(x, y, DEAD) for (x, y, _) in self.body] 

    def get_as_grid(self):
        g = [[EMPTY for i in range(self.dimy)] for j in range(self.dimx)]
        for x, y, c in self.body:
            g[x][y] = c
        return g

def find_head(gameBoard):
    for i, row in enumerate(gameBoard):
        for head, (dx, dy) in HEADS.items():
            if head in row:
                return Snake((i, row.index(head), head), len(gameBoard), len(gameBoard[0]))


def get_next(snake, gameBoard):
    x, y, _ = snake.body[-1]
    for (dx, dy) in HEADS.values():
        new_x, new_y = x + dx, y + dy
        if snake.position_is_free(new_x, new_y) and \
           gameBoard[new_x][new_y] == BODY:
            return (new_x, new_y, BODY)


def find_snake(gameBoard):
    # Get the head
    s = find_head(gameBoard)

    # Append the rest of the body
    while True:
        n = get_next(s, gameBoard)
        if n is None:
            break
        s.add_queue(n)
    return s


def snakeGame(gameBoard, commands):
    # Find snake
    s = find_snake(gameBoard)

    for command in commands:
        if command in "LR":
            # Change the head
            s.turn(command)
        else:
            s.move_forward()
            if not s.alive:
                break  
    return s.get_as_grid()

Something I had forgotten

if new_x in range(len(gameBoard)) can be rewritten if 0 <= new_x < len(gameBoard).

On Python2, this is much faster (because a list is generated and then we perform a linear search on it). On Python3, the performance impact may not be so big.

\$\endgroup\$
1
  • \$\begingroup\$ I think your last point is off. Since Python 3.x, range is a special object that is lazily evaluated, that makes x in range(y) faster \$\endgroup\$
    – Ludisposed
    Dec 20 '17 at 18:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.