Search code examples
pythonturtle-graphicspython-turtle

Understanding snake game extension logic


There is a function "extend" which is behaving as expected but I don't understand how. The writer of the code is using -1 as the position of the item in the list "segments". Should this not add an extra element to the already created snake at the position of its last segment? If so, how would that lengthen the snake as the segment created at the end will overlap with the segment that is already there? The complete code of the relevant files is described at the end.

def extend(self):
        self.add_segment(self.segments[-1].position())

The code for main.py is mentioned below:

from turtle import Screen
from snake import Snake
from food import Food
from scoreboard import ScoreBoard
import time

screen = Screen()
screen.setup(width=600, height=600)
screen.bgcolor("black")
screen.title("My Snake Game")
screen.tracer()

scoreboard = ScoreBoard()
snake = Snake()
food = Food()

screen.listen()
screen.onkey(snake.up, "Up")
screen.onkey(snake.down, "Down")
screen.onkey(snake.left, "Left")
screen.onkey(snake.right, "Right")

game_is_on = True

while game_is_on:
    screen.update()

    snake.move()

    if snake.head.distance(food) < 15:
        food.refresh()
        scoreboard.increase_score()
        snake.extend()

    #Detect collision with wall
    if snake.head.xcor() > 280 or snake.head.xcor() < -280 or snake.head.ycor() > 280 or snake.head.ycor() < -280:
        game_is_on = False
        scoreboard.game_over()

    #Detect collision with tail
    for segment in snake.segments:
        if segment == snake.head:
            pass
        elif snake.head.position() == segment.position():
            game_is_on = False
            scoreboard.game_over()

screen.exitonclick()

The code for snake.py is mentioned below:

from turtle import Turtle

STARTING_POSITIONS = [(0, 0), (-20, 0), (-40, 0)]
MOVE_DISTANCE = 20
UP = 90
DOWN = 270
LEFT = 180
RIGHT = 0

class Snake:

    def __init__(self):
        self.segments = []
        self.create_snake()
        self.head = self.segments[0]

    def create_snake(self):
        for position in STARTING_POSITIONS:
            self.add_segment(position)

    def add_segment(self, position):
        new_segment = Turtle("square")
        new_segment.color("white")
        new_segment.penup()
        new_segment.goto(position)
        self.segments.append(new_segment)

    ################
    def extend(self):
        self.add_segment(self.segments[-1].position())
    ################

    def move(self):
        for seg_num in range(len(self.segments) - 1, 0, -1):
            new_x = self.segments[seg_num - 1].xcor()
            new_y = self.segments[seg_num - 1].ycor()
            self.segments[seg_num].goto(new_x, new_y)
        self.segments[0].forward(MOVE_DISTANCE)

    def up(self):
        if self.head.heading() != DOWN:
            self.head.setheading(UP)

    def down(self):
        if self.head.heading() != UP:
            self.head.setheading(DOWN)

    def left(self):
        if self.head.heading() != RIGHT:
            self.head.setheading(LEFT)

    def right(self):
        if self.head.heading() != LEFT:
            self.head.setheading(RIGHT)

Solution

  • Should this not add an extra element to the already created snake at the position of its last segment? If so, how would that lengthen the snake as the segment created at the end will overlap with the segment that is already there?

    A good question: intuitively, it seems like it should. But examine the movement code:

    def move(self):
        for seg_num in range(len(self.segments) - 1, 0, -1):
            new_x = self.segments[seg_num - 1].xcor()
            new_y = self.segments[seg_num - 1].ycor()
            self.segments[seg_num].goto(new_x, new_y)
        self.segments[0].forward(MOVE_DISTANCE)
    

    This iterates the snake segments from tail to head, moving each segment to the position of the segment ahead of it, then finally moving the head forward a step in whatever direction the snake is heading.

    It's clear that this works fine under normal movement. The snake's tail will vacate its previous location and nothing will fill it in, leaving an empty space, while the head will occupy a new, previously empty space. Here's an example of a normal move call, with the snake of length 5 moving one step to the right:

    4 3 2 1 H
    -------->
    
      4 3 2 1 H
    ^
    |
    empty
    

    Now, after a call to extend we get this seemingly invalid situation (imagine the two 4s share the exact same square/position on a 1-d axis, rather than positioned one square vertically above it):

    4
    4 3 2 1 H
    -------->
    

    But the next move call resolves this scenario just fine. Even though there are two 4s sharing the same position, the snake will move as follows after one tick to the right:

    4 4 3 2 1 H
    ^
    |
    filled in by new segment
    

    Although I'm still using 4, it's really a 5th tail segment with its own unique position:

    5 4 3 2 1 H
    ^
    |
    filled in by new segment
    

    The snake has moved to the right, but the last element fell into place naturally because it was assigned to the coordinate space occupied by the segment ahead of it, self.segments[seg_num - 1], which would have been left empty under normal movement.

    This vacant space is exactly the length - 2-th element's previous position, and that's precisely what the new (seemingly) duplicate tail element was set to by move (and extend).

    The position of the new tail is never "passed back" to any other element, so it doesn't really matter what its initial value is as long as it's not the head position; it will be assigned to whatever the old tail's space was in the next move call.


    To concisely summarize this:

    • Under normal movement, there's no segment that is assigned to the tail's old position, leaving an empty space.
    • After a call to extend when the snake grows, the duplicate tail is assigned to the empty space that the old tail would have vacated, filling it in.