2
\$\begingroup\$

First off is "procedural event driven programming" a thing? My question is about global variables in the context of an event driven program that doesn't use OOP...

I have written a simple Snake game in Python which makes modest use of global variables. I have been been warned about the evils of globals so often that I thought I'd try and refactor the program to pass arguments instead.

It seems to me that any semblance of elegance or simplicity was lost in this endeavor. The version with globals is shown at the bottom of this post. Here's an excerpt from my attempt at refactoring:

def go_up(screen, pen, snake, snake_direction, food):
    if snake_direction != "down":
        snake_direction = "up"
        move_snake(screen, pen, snake, snake_direction, food)



    # Event handlers
    screen.listen()
    screen.onkey(lambda: go_up(screen, pen, snake, snake_direction, food), "Up")
    screen.onkey(lambda: go_right(screen, pen, snake, snake_direction, food), "Right")
    screen.onkey(lambda: go_down(screen, pen, snake, snake_direction, food), "Down")
    screen.onkey(lambda: go_left(screen, pen, snake, snake_direction, food), "Left")

    # Let's go
    snake = []
    snake_direction = ""
    screen, pen, snake, snake_direction, food = reset(screen, pen, snake, snake_direction, food)
    turtle.done()

It got very messy very quickly and I was unable to recreate the same functionality.

I'm pretty sure that even with a simple game like this, using OOP would be preferable to a procedural approach which involves so much argument passing.

But assuming I don't want to use OOP - say I want to teach kids how to write fun games without getting in that deep - is there any merit to using the argument passing approach over what appears to me to be the far more simple approach of having a few global variables?

Is this code evil?

"""
A simple snake game using Turtle Graphics.
Todo:  border collision?, score
"""
import turtle
import random

WIDTH = 500
HEIGHT = 500
FOOD_SIZE = 10
DELAY = 100  # milliseconds

offsets = {
    "up": (0, 20),
    "down": (0, -20),
    "left": (-20, 0),
    "right": (20, 0)
}


def reset():
    global snake, snake_direction
    snake = [[0, 0], [0, 20], [0, 40], [0, 50], [0, 60]]
    snake_direction = "up"
    food_pos = get_random_food_pos()
    food.goto(food_pos)
    # screen.update() Only needed if we are fussed about drawing food before call to `draw_snake()`.
    move_snake()


def move_snake():

    #  Next position for head of snake.
    new_head = snake[-1].copy()
    new_head[0] = snake[-1][0] + offsets[snake_direction][0]
    new_head[1] = snake[-1][1] + offsets[snake_direction][1]

    # Check self-collision
    if new_head in snake[:-1]:  # Or collision with walls?
        reset()
    else:
        # No self-collision so we can continue moving the snake.
        snake.append(new_head)

        # Check food collision
        if not food_collision():
            snake.pop(0)  # Keep the snake the same length unless fed.

        #  Allow screen wrapping
        if snake[-1][0] > WIDTH / 2:
            snake[-1][0] -= WIDTH
        elif snake[-1][0] < - WIDTH / 2:
            snake[-1][0] += WIDTH
        elif snake[-1][1] > HEIGHT / 2:
            snake[-1][1] -= HEIGHT
        elif snake[-1][1] < -HEIGHT / 2:
            snake[-1][1] += HEIGHT

        # Clear previous snake stamps
        pen.clearstamps()

        # Draw snake
        for segment in snake:
            pen.goto(segment[0], segment[1])
            pen.stamp()

        # Refresh screen
        screen.update()

        # Rinse and repeat
        turtle.ontimer(move_snake, DELAY)


def food_collision():
    if get_distance(snake[-1], (food.xcor(), food.ycor())) < 20:
        food_pos = get_random_food_pos()
        food.goto(food_pos)
        return True
    return False


def get_random_food_pos():
    x = random.randint(- WIDTH / 2 + FOOD_SIZE, WIDTH / 2 - FOOD_SIZE)
    y = random.randint(- HEIGHT / 2 + FOOD_SIZE, HEIGHT / 2 - FOOD_SIZE)
    return (x, y)


def get_distance(pos1, pos2):
    x1, y1 = pos1
    x2, y2 = pos2
    distance = ((y2 - y1) ** 2 + (x2 - x1) ** 2) ** 0.5
    return distance


def go_up():
    global snake_direction
    if snake_direction != "down":
        snake_direction = "up"


def go_right():
    global snake_direction
    if snake_direction != "left":
        snake_direction = "right"


def go_down():
    global snake_direction
    if snake_direction != "up":
        snake_direction = "down"


def go_left():
    global snake_direction
    if snake_direction != "right":
        snake_direction = "left"


def main():
    global screen, pen, food

    # Screen
    screen = turtle.Screen()
    screen.setup(WIDTH, HEIGHT)
    screen.title("Snake")
    screen.bgcolor("green")
    screen.setup(500, 500)
    screen.tracer(0)

    # Pen
    pen = turtle.Turtle("square")
    pen.penup()

    # Food
    food = turtle.Turtle()
    food.shape("circle")
    food.color("red")
    food.shapesize(FOOD_SIZE / 20)  # Default size of turtle "square" shape is 20.
    food.penup()

    # Event handlers
    screen.listen()
    screen.onkey(go_up, "Up")
    screen.onkey(go_right, "Right")
    screen.onkey(go_down, "Down")
    screen.onkey(go_left, "Left")

    # Let's go
    reset()
    turtle.done()


if __name__ == "__main__":
    main()
\$\endgroup\$
3
  • 3
    \$\begingroup\$ If you have working code that meets your needs, and you're looking for feedback on coding style or reducing "evil," you might want to consider taking this to the Code Review StackExchange instead, where they specialize in that. \$\endgroup\$ Commented Mar 20, 2020 at 8:39
  • \$\begingroup\$ Related reading alternatives to globals & using dependency injection in place of singletons. \$\endgroup\$ Commented Mar 20, 2020 at 14:45
  • \$\begingroup\$ As a side note - globals themselves aren't necessarily evil, thought they're easy to misuse & the odds of running into trouble tends to increase with size of the project. For something very small in scope, a few well used, easily identified globals maybe preferable to adding a heavier weight management system. \$\endgroup\$ Commented Mar 20, 2020 at 14:48

1 Answer 1

0
\$\begingroup\$

First off is "procedural event driven programming" a thing?

Sure, since you just did it! And it does seem appropriate for a program like this.

I have written a simple Snake game in Python which makes modest use of global variables.

Yes, this is a common (and likely optimal) strategy for small turtle applications like this.

I have been been warned about the evils of globals so often that I thought I'd try and refactor the program to pass arguments instead.

In a program this small, global variables are not that evil, but I can understand wanting to steer students away from bad habits like the global keyword.

It seems to me that any semblance of elegance or simplicity was lost in this endeavor.

I agree, parameters aren't appropriate here, particularly fully unpacked like snake, snake_direction is. One major benefit of a class is that you can reference self. from within class methods without parameter lists everywhere.

However, you might think of a module as a singleton class with properties and methods, except the properties are global in the module, and the methods are loose functions in the module scope.

But assuming I don't want to use OOP - say I want to teach kids how to write fun games without getting in that deep

I agree, OOP is a big commitment. Using turtle programs to teach OOP principles strikes me as counter-productive, since students will find it far more cumbersome than doing it procedurally and won't have an "aha!" moment, as you have.

But using a simple class as a singleton data container is an appropriate compromise. You can avoid the global keyword and de-evilize the program without compromising the simple procedural style.

Sure, it's a global within the module, and you're still mutating state outside of the function, but the module is small and more or less the same as a singleton class, but without the extra level of nesting and new, surprising concepts.

Here's a rewrite that provides the benefits you want from OOP, but without introducing any challenging OOP concepts (you can describe class as a way to group a few related variables):

"""
A simple snake game using Turtle Graphics.
"""

from collections import namedtuple
from random import randint
from turtle import Screen, Turtle

WIDTH = 500
HEIGHT = 500
CELL_SIZE = 20
DELAY = 100  # milliseconds

Segment = namedtuple("Segment", ["x", "y"])


class Direction:
    UP = 0, CELL_SIZE
    DOWN = 0, -CELL_SIZE
    LEFT = -CELL_SIZE, 0
    RIGHT = CELL_SIZE, 0


class Snake:
    segments = []
    direction = Direction.UP


def is_opposite(dir1, dir2):
    return (
        (dir1 == Direction.UP and dir2 == Direction.DOWN)
        or (dir1 == Direction.DOWN and dir2 == Direction.UP)
        or (dir1 == Direction.LEFT and dir2 == Direction.RIGHT)
        or (dir1 == Direction.RIGHT and dir2 == Direction.LEFT)
    )


def restart_game():
    Snake.segments = [Segment(0, i * CELL_SIZE) for i in range(5)]
    Snake.direction = Direction.UP
    randomize_food()
    move_snake()


def move_snake():
    dx, dy = Snake.direction
    head = Snake.segments[-1]
    new_head = Segment(head.x + dx, head.y + dy)

    # Self collision
    if new_head in Snake.segments[:-1]:
        restart_game()
        return

    new_segments = Snake.segments + [new_head]

    # Food collision
    distance = get_distance(
        (new_head.x, new_head.y),
        (food.xcor(), food.ycor())
    )
    if distance < CELL_SIZE:
        randomize_food()
    else:
        new_segments.pop(0)

    # Screen wrapping
    wrapped_x = (new_head.x + WIDTH // 2) % WIDTH - WIDTH // 2
    wrapped_y = (new_head.y + HEIGHT // 2) % HEIGHT - HEIGHT // 2
    new_segments[-1] = Segment(wrapped_x, wrapped_y)

    Snake.segments = new_segments

    draw_snake()
    Screen().update()
    Screen().ontimer(move_snake, DELAY)


def draw_snake():
    pen.clearstamps()
    for segment in Snake.segments:
        pen.goto(segment.x, segment.y)
        pen.stamp()


def randomize_food():
    x = randint(-WIDTH // 2, WIDTH // 2)
    y = randint(-HEIGHT // 2, HEIGHT // 2)
    food.goto(x, y)


def get_distance(pos1, pos2):
    x1, y1 = pos1
    x2, y2 = pos2
    return ((y2 - y1) ** 2 + (x2 - x1) ** 2) ** 0.5


def set_direction(new_direction):
    if not is_opposite(Snake.direction, new_direction):
        Snake.direction = new_direction


def main():
    global pen, food

    # Setup screen
    screen = Screen()
    screen.setup(WIDTH, HEIGHT)
    screen.title("Snake")
    screen.bgcolor("green")
    screen.tracer(0)

    # Setup pen
    pen = Turtle("square")
    pen.penup()

    # Setup food
    food = Turtle()
    food.shape("circle")
    food.color("red")
    food.penup()
    randomize_food()

    # Bind keys
    screen.listen()
    screen.onkey(lambda: set_direction(Direction.UP), "Up")
    screen.onkey(lambda: set_direction(Direction.RIGHT), "Right")
    screen.onkey(lambda: set_direction(Direction.DOWN), "Down")
    screen.onkey(lambda: set_direction(Direction.LEFT), "Left")

    restart_game()
    screen.exitonclick()


if __name__ == "__main__":
    main()

I've made a number of other improvements, most notably, using an enum-like singleton data class for directions and a named tuple for snake segments. The latter lets you do .x and .y rather than [0] and [1]. If these feel too advanced, you can skip them. Most of the benefits are from the class Snake data class.

You can get rid of global entirely by initializing turtle objects top-level. The if __name__ == "__main__": and def main() aren't really necessary here since nobody will ever import and use this module as a third party consumer, but I left them in since I get the sense you want to keep things away from the global scope as much as possible.

As an aside, JavaScript (and other languages like Lua) does the "lightweight object" thing we're doing here much better than Python. We could use a dict instead of class, but then we'd have to do snake["direction"] which is ugly. In JS we'd simply do:

const snake = {segments: [], direction: Direction.UP};

// access with:
snake.segments;
\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.