Sunday, October 31, 2021

The Python Coding Blog: Practise Using Lists, Tuples, Dictionaries, and Sets in Python With the Chaotic Balls Animation

One of the early topics covered when learning to code deals with the built-in data structures in Python. Lists are usually learned early on, followed by dictionaries and tuples. Sets are not normally one of the earliest topics covered. However, that’s not because they’re complex but because they’re used less often in Python. Understanding the similarities and differences between these data structures is important. But there’s more than just the ‘rules’ when using lists, tuples, dictionaries, and sets in Python.

In this article, you’ll write a simulation using lists, tuples, dictionaries, and sets in Python. The code will produce this animation:

The article’s principal aim is to practise using lists, tuples, dictionaries, and sets in Python and understand how each one is suited for a different purpose. The main purpose of the post is not to give a detailed explanation of the data structures. However, I’ll briefly review the basics of these built-in data structures in Python throughout the article.

You can find a lot more detail about lists in the Chapter about loops and lists in The Python Coding Book, and dictionaries and tuples are dealt with in the Chapter about data types.

Introducing the Chaotic Balls Animation

Have a look again at the video above showing the simulation in action. Can you guess the rules the simulation is following?

Here they are:

  • The screen includes several tiles. There are three types of tiles that are identified by the colour of the outline: green, orange, or red.
  • A ball appears randomly on the screen once every two seconds. Its direction of travel is also random.
  • When a ball hits a green tile, it speeds up.
  • When a ball hits a red tile, it slows down. When a ball slows down to zero, it disappears from the screen.
  • When a ball hits an orange tile, it changes its direction of travel randomly.
  • The colour of each ball indicates the ball’s speed.

You’ll use the turtle module for this animation. This module is part of the standard library, so you don’t need to install it separately. You don’t need to have any previous experience with the turtle module to get the most out of this article. It’s quite simple to use, and I’ll explain how it works throughout the article.

You’ll also need two other modules from the standard library: random and time.

A Quick Review of Lists and Tuples

Lists and tuples have many similarities. They’re both sequences in which the items are stored in order and can be referenced using an index showing the item’s position in the sequence:

>>> some_list = [4, 6, 7, 3, 2, 10, 4]
>>> some_tuple = (4, 6, 7, 3, 2, 10, 4)

>>> some_list[3]
3
>>> some_tuple[3]
3

>>> some_list[2:5]
[7, 3, 2]
>>> some_tuple[2:5]
(7, 3, 2)

>>> for number in some_list:
...    print(number)
...    
4
6
7
3
2
10
4

>>> for number in some_tuple:
...    print(number)
...    
4
6
7
3
2
10
4

Note that when creating a tuple, the parentheses () are optional. The following line creates the same tuple as the one in the example above:

>>> some_tuple = 4, 6, 7, 3, 2, 10, 4
>>> some_tuple
(4, 6, 7, 3, 2, 10, 4)

The key difference between lists and tuples is that lists are mutable while tuples are immutable:

>>> some_list[2] = 100
>>> some_list
[4, 6, 100, 3, 2, 10, 4]

>>> some_tuple[2] = 100
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

You can change, add, and remove items in a list, but you cannot do the same with tuples. Tuples are useful when you want to create a group of items that will not change in your code. You use a list for a container that’s meant to be flexible.

Getting Started: Setting Up the Animation

Let’s start setting up the animation and see where you need to use lists and where tuples would be more suitable. You can start by creating a window using the turtle module and selecting its size and colour:

import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

# Create window
window = turtle.Screen()
window.tracer(0)
window.colormode(255)
window.setup(*screen_size)
window.bgcolor(background_colour)

# Temporary line to keep window open. We'll remove later
turtle.done()

The name background_colour stores the red, green, and blue (RGB) values representing the background colour. You can use a tuple to store RGB colour triplets. You also use a tuple for the width and height of the window, which you store in screen_size.

The Create window section uses Screen() from the turtle module to create the window. The tracer() method is used to control when things are drawn on the screen. Setting this to 0 means that you’ll be able to control when to refresh the screen by using the update() method later on. The colormode() method allows you to choose to represent colours as triplets of numbers between 0 and 255 to represent the RGB values.

setup() is the method you can use to set the size of the window. This method needs two arguments to represent the width and height of the window in pixels. Therefore, you use the unpacking operator * to unpack the tuple screen_size into the two numbers that it contains. window.setup(*screen_size) is the same as window.setup(screen_size[0], screen_size[1]) in this case since there are two items in screen_size.

Finally, you change the window’s background colour using bgcolor() which accepts a tuple with RGB values as an argument. When you run this code, you should see a square window with a grey background.

Creating the Balls

You can now set things up to create the balls that appear randomly on the screen at regular intervals. You’ll use a Turtle object from the turtle module for each ball. However, you want to store all the ball objects in the same place in your program. The data structure should be iterable so that you can go through it using a loop to deal with all the balls.

You also need the container to be flexible since you’ll add a new ball every two seconds, and you need to remove balls that have come to a standstill. This is an ideal scenario for creating a list. You can initialise an empty list, ready to store the balls as they are created. You can then define a function create_new_ball() to create a ball at a random position and orientation:

import random
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

# Create window
window = turtle.Screen()
window.tracer(0)
window.colormode(255)
window.setup(*screen_size)
window.bgcolor(background_colour)

# Create balls
balls = []

def create_new_ball():
    ball = turtle.Turtle()
    ball.penup()
    ball.shape("circle")
    ball.pencolor("white")
    ball.setposition(
        random.randint(-screen_size[0] // 2, screen_size[0] // 2),
        random.randint(-screen_size[1] // 2, screen_size[1] // 2)
    )
    ball.setheading(random.randint(0, 359))
    ball.ball_speed = 0.5

    balls.append(ball)

create_new_ball()  # Start animation with one ball

# Temporary lines. We'll remove later
window.update()
turtle.done()

Once you create an instance of the turtle.Turtle class, you call several of its methods:

  • penup() ensures that no lines are drawn when a Turtle object moves
  • shape() changes the shape of the object displayed. Here, you’re changing the shape to a circle.
  • pencolor() selects the colour of any lines drawn by the Turtle. As you called penup() earlier, no lines will be drawn. However, the outline of the circle displayed will now be white.
  • setposition() moves the Turtle object to the x– and y-coordinates given as arguments. The centre of the screen is represented by the coordinates (0, 0). Therefore, the first argument is a random number between -400 and 400 since the width is 800. The second argument follows the same principle but uses the height of the window, which is the second item in screen_size. You use floor division // to ensure the result is an integer as random.randint() needs integer arguments.
  • setheading() changes the orientation of the Turtle object. You will use the forward() method later, which will move the Turtle object in the direction the object is facing.

ball_speed is not an attribute of the Turtle class. You’re creating an instance variable with the line ball.ball_speed = 0.5. If you want to brush up on this topic, you can read more about Python instance variables. Each ball will have its own speed as balls will speed up or slow down at different rates.

Each time you call create_new_ball(), the program will create a new Turtle representing a ball and add it to the list balls. You call the function once right away so that there’s one ball at the start of the animation. You’ll call the function again later to create more balls. For the time being, when you run this code, you see a single, stationary ball placed in a random position on the screen. The ball has a white outline since you set this to white when you called pencolor(). The rest of the ball is black, which is the default colour. You’ll change this colour later:

It’s now time to add movement to the animation.

Creating the Main Loop to Move the Balls

All animations will need a main loop to run through each frame of the animation. You can use a while True loop in this case. Although you only have one ball in the animation, you know that all the balls will be represented by Turtle objects stored in the list balls. Therefore, you can iterate through this list in the while loop to move the balls. You can also take care of what happens when the ball leaves the window from either of the four edges: left, right, top, or bottom. Here are the additions you’ll need to your code:

import random
import time
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

# Create window
window = turtle.Screen()
window.tracer(0)
window.colormode(255)
window.setup(*screen_size)
window.bgcolor(background_colour)

# Create balls
balls = []

def create_new_ball():
    ball = turtle.Turtle()
    ball.penup()
    ball.shape("circle")
    ball.pencolor("white")
    ball.setposition(
        random.randint(-screen_size[0] // 2, screen_size[0] // 2),
        random.randint(-screen_size[1] // 2, screen_size[1] // 2)
    )
    ball.setheading(random.randint(0, 359))
    ball.ball_speed = 0.5

    balls.append(ball)

create_new_ball()  # Start animation with one ball

# Main animation loop
while True:
    for ball in balls:
        # Move ball
        ball.forward(ball.ball_speed)
        # If ball goes out of bounds, move to other side
        if abs(ball.xcor()) > screen_size[0] / 2:
            ball.setx(-ball.xcor())
        if abs(ball.ycor()) > screen_size[1] / 2:
            ball.sety(-ball.ycor())

    window.update()
    time.sleep(0.001)

You’ve now added a while loop. Each iteration of this loop represents one frame of the animation. The while loop so far consists of the following:

  • A for loop that iterates through the list containing all the Turtle objects representing the balls
  • The call to the forward() method of the Turtle class. This method moves the Turtle forward by the number of pixels given as an argument. The argument is ball.ball_speed. The instance variable ball_speed is one you have created in create_new_ball() and each Turtle will have its own value. The Turtle will move in the direction it is facing, which you’ve set to a random value in create_new_ball().
  • Two if statements. These statements are needed to check whether the ball has left the screen through any of the four sides.
    • The methods setx() and sety() are similar to setposition() which you used earlier. However, they only change one of the Turtle object’s coordinates at a time instead of both.
    • The methods xcor() and ycor() return the Turtle object’s x– and y-coordinates.
    • The abs() built-in function returns the absolute value of its argument. In this case, as the value will be a float, the function will always return the positive value of the difference between the ball’s coordinate and the screen’s half-width or half-height. This allows you to test for the left and right edges in the same statement and for the top and bottom edges in another.
  • The call to update() refreshes the display on the screen. This method is used with tracer(0) to control when things are drawn in the animation. By placing window.update() in the while loop, you refresh the image once per frame.
  • time.sleep() introduces a small delay in the loop. You’ll need to import the time built-in module, too. In this animation, you’re not controlling the speed of each frame strictly. Instead, your while loop will run at whatever speed your computer allows it to! This means that the speed of the animation will vary from computer to computer. Putting in a small delay in the while loop allows you to control the overall speed of the animation. Change the value used as an argument for time.sleep() to suit your computer speed. If your animation is too fast, use a larger number.

You’ve also removed the temporary lines you had at the bottom of your code earlier. You no longer need these lines now that the while loop is in place.

This code gives output similar to the following video:

The ball will appear in a random position and move in a random direction. It should reappear at the opposite end of the screen whenever it leaves the window through any of the four edges.

Creating a Grid

You can now turn your attention to creating the tiles. You can create a virtual grid and work out how the grid maps to the whole screen. In the code below, you’ll create a 16x16 grid. Since the screen is 800x800 pixels, each cell of the grid will be 50x50 pixels, since 800÷16=50.

However, you don’t want every one of the 16x16 cells of the grid to include a tile. In the animation, there are gaps where there are no tiles. You can now define some parameters at the top of your code to set up the grid:

import random
import time
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

grid_size = 16, 16
grid_scale = (
    screen_size[0] / grid_size[0],
    screen_size[1] / grid_size[1]
)
fraction_of_grid_points_used = 0.35
n_tiles = int(
    fraction_of_grid_points_used * grid_size[0] * grid_size[1]
)

# ...

grid_size and grid_scale are both tuples containing two values representing the x– and y-values. grid_scale contains the size in pixels of each cell in the grid. In this example, this is 50x50.

You’ve then set the value for fraction_of_grid_points to 0.35. This means that 35% of all the 16x16 grid cells will be filled with tiles. The result of this calculation is stored in n_tiles.

A Quick Review of Dictionaries and Sets

In this tutorial, you’re practising using lists, tuples, dictionaries, and sets in Python. You’ve already used lists and tuples. Now, it’s time for a quick review of the other two data structures you’re using in this example.

A dictionary is a mapping linking a key to a value. Each item in a dictionary consists of a key-value pair:

>>> some_dictionary = {"James": 10, "Mary": 20, "Kate": 15}
>>> some_dictionary["James"]
10

The values of a dictionary can be of any data type, including other data structures. The values can also be function names:

>>> another_dict = {"first": print, "second": str.upper}
>>> another_dict["first"]
<built-in function print>

>>> another_dict["first"]("hello")
hello

>>> another_dict["second"]("hello")
'HELLO'

The value of another_dict["first"] is the function print. Therefore, another_dict["first"]("hello") is the same as the function call print("hello").

The key of a dictionary cannot be any data type, though. Have a look at the following examples:

>>> one_more_dictionary = {[1, 2]: "hello"}
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: unhashable type: 'list'

>>> one_more_dictionary = {(1, 2): "hello"}
>>> one_more_dictionary
{(1, 2): 'hello'}

Keys need to be hashable. You can see that when you tried to use a list as a key in the dictionary, you got an ‘unhashable type’ error. However, tuples can be used.

Sets share the same type of bracket with dictionaries, the curly brackets {}, but items within a set are individual items and not pairs:

>>> some_set = {4, 6, 7, 6, 3, 4, 5, 4}
>>> type(some_set)
<class 'set'>
>>> some_set
{3, 4, 5, 6, 7}

Each value in a set must be distinct and therefore can only appear once. In the example above, you can see that the repeated values have been excluded from the set.

Note that when you want to create an empty set, you cannot use the same method as with lists, tuples, and dictionaries since the curly brackets default to an empty dictionary:

>>> a = []
>>> type(a)
<class 'list'>

>>> b = ()
>>> type(b)
<class 'tuple'>

>>> c = {}
>>> type(c)
<class 'dict'>

>>> d = set()
>>> type(d)
<class 'set'>

Before going back to the animation code, we should have a quick word about comprehensions for lists, tuples, dictionaries, and sets.

Comprehensions

When using lists, tuples, dictionaries, and sets in Python, you’ll often need to initialise the empty data structure and then populate it with values. Often, you can use comprehensions to do this:

>>> some_list = [4, 6, 7, 3, 2, 10, 4]
>>> some_list
[4, 6, 100, 3, 2, 10, 4]

>>> new_list = [item * 2 for item in some_list]
>>> new_list
[8, 12, 200, 6, 4, 20, 8]

>>> new_set = {item * 2 for item in some_list}
>>> new_set
{4, 6, 8, 200, 12, 20}

You can use the same method for dictionaries by defining both the key and the value in the comprehension:

>>> names = ["James", "Mary", "Kate"]
>>> numbers = [10, 20, 15]

>>> some_dictionary = {key: value for key, value in zip(names, numbers)}
>>> some_dictionary
{'James': 10, 'Mary': 20, 'Kate': 15}

When using comprehensions to populate tuples, you need to beware of a common error:

>>> some_numbers = (item * 2 for item in some_list)
>>> some_numbers
<generator object <genexpr> at 0x7fe68991b3c0>

>>> some_numbers = tuple(item * 2 for item in some_list)
>>> some_numbers
(8, 12, 14, 6, 4, 20, 8)

The expression in the parentheses () alone returns a generator and not a tuple. You can use the tuple() function with a comprehension expression to create a tuple.

Adding Tiles to the Screen

You’ve created the parameters grid_size and grid_scale earlier that allow you to create a grid and map it to the screen size in pixels. You’ll read more about this mapping between grid and screen later on. You also defined fraction_of_grid_points_used as 0.35 earlier, or 35% of all grid cells. This leads to 89 tiles in this animation. Therefore, you need to select 89 random pairs of grid coordinates which will host the tiles.

Choosing the Tile Coordinates

However, you need to ensure that the program selects 89 unique pairs of grid coordinates. One way of achieving this is by using a set:

import random
import time
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

grid_size = 16, 16
grid_scale = (
    screen_size[0] / grid_size[0],
    screen_size[1] / grid_size[1]
)
fraction_of_grid_points_used = 0.35
n_tiles = int(
    fraction_of_grid_points_used * grid_size[0] * grid_size[1]
)

# Create window
window = turtle.Screen()
window.tracer(0)
window.colormode(255)
window.setup(*screen_size)
window.bgcolor(background_colour)

# Choose grid coordinates that will contain tiles
tile_grid_coords = set()
while len(tile_grid_coords) < n_tiles:
    tile_grid_coords.add(
        (
            random.randint(0, grid_size[0] - 1),
            random.randint(0, grid_size[1] - 1)
        )
    )

# Create balls
balls = []

def create_new_ball():
    ball = turtle.Turtle()
    ball.penup()
    ball.shape("circle")
    ball.pencolor("white")
    ball.setposition(
        random.randint(-screen_size[0] // 2, screen_size[0] // 2),
        random.randint(-screen_size[1] // 2, screen_size[1] // 2)
    )
    ball.setheading(random.randint(0, 359))
    ball.ball_speed = 0.5

    balls.append(ball)

create_new_ball()  # Start animation with one ball

# Main animation loop
while True:
    for ball in balls:
        # Move ball
        ball.forward(ball.ball_speed)
        # If ball goes out of bounds, move to other side
        if abs(ball.xcor()) > screen_size[0] / 2:
            ball.setx(-ball.xcor())
        if abs(ball.ycor()) > screen_size[1] / 2:
            ball.sety(-ball.ycor())

    window.update()
    time.sleep(0.001)

You initialised an empty set and used the uniqueness property of sets to run a while loop until the required number of coordinates are reached. The grid coordinates that the program chooses range from (0, 0) to (15, 15). You can add a call to print(tile_grid_coords) after the loop to display the grid coordinates chosen if you wish.

Tile Colours and Actions

Before you’re ready to draw the tiles, you’ll need to link each tile colour with an action. When a ball hits a tile, it will perform a specific action depending on that tile’s colour.

The three actions that a ball can perform are:

  • Increase speed if the ball hits a green tile
  • Decrease speed if the ball hits a red tile
  • Change direction randomly if the ball hits an orange tile

You can start by defining these three functions, each taking a Turtle object’s name as an input argument. You also define two new parameters to set the maximum speed that a ball can reach, to avoid balls going too fast, and the step size you’d like to use to increase and decrease the ball speed each time it hits a green or red tile:

import random
import time
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

grid_size = 16, 16
grid_scale = (
    screen_size[0] / grid_size[0],
    screen_size[1] / grid_size[1]
)
fraction_of_grid_points_used = 0.35
n_tiles = int(
    fraction_of_grid_points_used * grid_size[0] * grid_size[1]
)

max_ball_speed = 2
ball_speed_step = 0.2

# Create window
window = turtle.Screen()
window.tracer(0)
window.colormode(255)
window.setup(*screen_size)
window.bgcolor(background_colour)

# Choose grid coordinates that will contain tiles
tile_grid_coords = set()
while len(tile_grid_coords) < n_tiles:
    tile_grid_coords.add(
        (
            random.randint(0, grid_size[0] - 1),
            random.randint(0, grid_size[1] - 1)
        )
    )

# Define actions based on grid point colour
def speed_up(ball: turtle.Turtle):
    """Increase ball speed until it reaches max_ball_speed"""
    ball.ball_speed += ball_speed_step
    if ball.ball_speed > max_ball_speed:
        ball.ball_speed = max_ball_speed

def slow_down(ball: turtle.Turtle):
    """Decrease ball speed. Hide and remove from list when stationary"""
    ball.ball_speed -= ball_speed_step
    if ball.ball_speed < ball_speed_step:
        ball.hideturtle()
        balls.remove(ball)

def change_direction(ball: turtle.Turtle):
    """Rotate Turtle object by a random angle in [-90, 90] range"""
    ball.left(random.randint(-90, 90))

# Create balls
balls = []

def create_new_ball():
    ball = turtle.Turtle()
    ball.penup()
    ball.shape("circle")
    ball.pencolor("white")
    ball.setposition(
        random.randint(-screen_size[0] // 2, screen_size[0] // 2),
        random.randint(-screen_size[1] // 2, screen_size[1] // 2)
    )
    ball.setheading(random.randint(0, 359))
    ball.ball_speed = 0.5

    balls.append(ball)

create_new_ball()  # Start animation with one ball

# Main animation loop
while True:
    for ball in balls:
        # Move ball
        ball.forward(ball.ball_speed)
        # If ball goes out of bounds, move to other side
        if abs(ball.xcor()) > screen_size[0] / 2:
            ball.setx(-ball.xcor())
        if abs(ball.ycor()) > screen_size[1] / 2:
            ball.sety(-ball.ycor())

    window.update()
    time.sleep(0.001)

The functions are described in the docstrings for each one. Type hinting is used to improve readability, showing that the input argument should be a Turtle object.

The balls are removed from the list balls when they become stationary, and they cannot exceed the maximum ball speed you set in the parameters at the top of your code.

Mapping Tile Colours to Ball Actions

Your next step is to map the tile colours to each of these actions. Dictionaries are an ideal data structure to create these mappings. As you’ve seen earlier, you can use tuples as keys in a dictionary, and the value can be a function name. You can create a dictionary called actions which maps RGB colour triplets to the function names representing actions:

# ...

# Define actions based on grid point colour
def speed_up(ball: turtle.Turtle):
    """Increase ball speed until it reaches max_ball_speed"""
    ball.ball_speed += ball_speed_step
    if ball.ball_speed > max_ball_speed:
        ball.ball_speed = max_ball_speed

def slow_down(ball: turtle.Turtle):
    """Decrease ball speed. Hide and remove from list when stationary"""
    ball.ball_speed -= ball_speed_step
    if ball.ball_speed < ball_speed_step:
        ball.hideturtle()
        balls.remove(ball)

def change_direction(ball: turtle.Turtle):
    """Rotate Turtle object by a random angle in [-90, 90] range"""
    ball.left(random.randint(-90, 90))

# Map colours to ball actions
actions = {
    (144, 238, 144): speed_up,
    (220, 20, 60): slow_down,
    (255, 127, 80): change_direction,
}

# ...

The tuples used as keys in the dictionary actions represent the light green, red, and orange colours used in this animation. Of course, you can choose your own favourite colours if you wish!

You’re now ready to assign a colour to each tile. You can create another dictionary named tiles which uses the tuples containing the tile coordinates as keys and a colour as a value. This dictionary will contain items in the following format:

{(2, 3): (144, 238, 144), (7, 2): (255, 127, 80), ...}

Each pair of tile coordinates is mapped onto a colour from the three colours available. You can create the dictionary tiles using a dictionary comprehension:

# ...

# Choose grid coordinates that will contain tiles
tile_grid_coords = set()
while len(tile_grid_coords) < n_tiles:
    tile_grid_coords.add(
        (
            random.randint(0, grid_size[0] - 1),
            random.randint(0, grid_size[1] - 1)
        )
    )

# Define actions based on grid point colour
def speed_up(ball: turtle.Turtle):
    """Increase ball speed until it reaches max_ball_speed"""
    ball.ball_speed += ball_speed_step
    if ball.ball_speed > max_ball_speed:
        ball.ball_speed = max_ball_speed

def slow_down(ball: turtle.Turtle):
    """Decrease ball speed. Hide and remove from list when stationary"""
    ball.ball_speed -= ball_speed_step
    if ball.ball_speed < ball_speed_step:
        ball.hideturtle()
        balls.remove(ball)

def change_direction(ball: turtle.Turtle):
    """Rotate Turtle object by a random angle in [-90, 90] range"""
    ball.left(random.randint(-90, 90))

# Map colours to ball actions
actions = {
    (144, 238, 144): speed_up,
    (220, 20, 60): slow_down,
    (255, 127, 80): change_direction,
}

# Create tiles
tiles = {
    coord: random.choice(tuple(actions.keys()))
    for coord in tile_grid_coords
}

# ...

You loop through tile_grid_coords in the dictionary comprehension and place each item as a key in the dictionary. For each key, you choose a random colour as a value. Since the colours available are the keys of the dictionary named actions, you can use actions.keys() as an argument for random.choice() once you convert to a sequence such as a tuple. You can print(tiles) if you wish to display the set of tiles and their colours.

Converting Between Grid Coordinates and Screen Coordinates

You have to deal with two sets of coordinates in this program:

  • The grid coordinates represent the cells in the 16x16 grid. The bottom-left cell is (0, 0), and the top-right cell is (15, 15).
  • The screen coordinates correspond to each pixel on the screen. In the Turtle module, the centre of the screen has the coordinates (0, 0). Therefore, the screen coordinates include negative and positive values to represent all four quadrants of the screen.

The illustration below shows the relationship between grid coordinates and screen coordinates for a 4x4 grid. The grid coordinates are shown using square brackets and the screen coordinates using round brackets in this illustration:

In the code, the grid is 16x16 instead of 4x4. The smaller grid was only used in the drawing to make it easier to illustrate.

You can now write a couple of functions to convert between the two coordinate systems. You can add these helper functions immediately after defining the parameters at the top of the code:

import random
import time
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

grid_size = 16, 16
grid_scale = (
    screen_size[0] / grid_size[0],
    screen_size[1] / grid_size[1]
)
fraction_of_grid_points_used = 0.35
n_tiles = int(
    fraction_of_grid_points_used * grid_size[0] * grid_size[1]
)

max_ball_speed = 2
ball_speed_step = 0.2

# Functions to convert between grid and screen coordinates
def convert_grid_to_screen_coords(grid_coords):
    return (
        grid_coords[0] * grid_scale[0] - screen_size[0]/2 + grid_scale[0]/2,
        grid_coords[1] * grid_scale[1] - screen_size[1]/2 + grid_scale[1]/2,
    )

def convert_screen_to_grid_coords(screen_coords):
    return (
        round(
            (screen_coords[0] - grid_scale[0]/2 + screen_size[0]/2) / grid_scale[0]
        ),
        round(
            (screen_coords[1] - grid_scale[1]/2 + screen_size[1]/2) / grid_scale[1]
        ),
    )

# ...

In the function convert_grid_to_screen_coords(), a pair of grid coordinates such as (3, 1) is converted to the screen coordinates at the centre of the grid cell. The steps in the function are as follows:

  • The input argument is a tuple containing the grid coordinates.
  • The return value is another tuple containing the screen coordinates at the centre of the cell.
  • The grid coordinates are multiplied by the grid_scale first. This is the size of each cell in the grid in pixels. This gives the left-most pixel when index 0 is used in the tuple indexing or the bottom-most pixel when 1 is used.
  • Since the grid coordinates start at the bottom left while the screen coordinates are centred at the middle of the screen, you need to subtract half the width or height of the screen.
  • You now need to add half the grid_scale value to move from the bottom-left pixel of the grid cell to the centre pixel of the cell.

In the function convert_screen_to_grid_coords(), the screen coordinates of any pixel are converted to the grid coordinates of the cell that contains that pixel:

  • The input argument is a tuple containing the screen coordinates of a pixel.
  • The return value is another tuple containing the grid coordinates for the grid which contains the pixel.
  • The calculation is the reverse of the one described for convert_grid_to_screen(). The result is rounded to give the integers needed for the grid coordinate system.

There’s a bit more detail about the transformations occurring in these functions in an appendix to this article.

Drawing the Tiles

It’s time to draw the tiles on the screen. You can create a new Turtle object to draw the tiles and then loop through the dictionary tiles to draw each one.

The keys in tiles are the grid coordinates of the cell, and the values are the colours. The steps needed to draw the tiles are the following:

  • Loop through tiles.items() and assign the keys to the name coord and the values to the name colour.
  • Convert grid coordinates to screen coordinates.
  • Move the Turtle object to the bottom-left region of the cell, allowing for a margin so that tiles are not in contact with each other. The factor of 0.9 is used for this.
  • Change the Turtle object’s colour to the colour associated with the tile, which is stored in the tiles dictionary.
  • Draw a square with the Turtle object. The factor of 0.8 ensures that a margin is left between the tile drawn and the edge of the cell.

You can add this loop to your code:

import random
import time
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

grid_size = 16, 16
grid_scale = (
    screen_size[0] / grid_size[0],
    screen_size[1] / grid_size[1]
)
fraction_of_grid_points_used = 0.35
n_tiles = int(
    fraction_of_grid_points_used * grid_size[0] * grid_size[1]
)

max_ball_speed = 2
ball_speed_step = 0.2

# Functions to convert between grid and screen coordinates
def convert_grid_to_screen_coords(grid_coords):
    return (
        grid_coords[0] * grid_scale[0] - screen_size[0]/2 + grid_scale[0]/2,
        grid_coords[1] * grid_scale[1] - screen_size[1]/2 + grid_scale[1]/2,
    )

def convert_screen_to_grid_coords(screen_coords):
    return (
        round(
            (screen_coords[0] - grid_scale[0]/2 + screen_size[0]/2) / grid_scale[0]
        ),
        round(
            (screen_coords[1] - grid_scale[1]/2 + screen_size[1]/2) / grid_scale[1]
        ),
    )

# Create window
window = turtle.Screen()
window.tracer(0)
window.colormode(255)
window.setup(*screen_size)
window.bgcolor(background_colour)

# Choose grid coordinates that will contain tiles
tile_grid_coords = set()
while len(tile_grid_coords) < n_tiles:
    tile_grid_coords.add(
        (
            random.randint(0, grid_size[0] - 1),
            random.randint(0, grid_size[1] - 1)
        )
    )

# Define actions based on grid point colour
def speed_up(ball: turtle.Turtle):
    """Increase ball speed until it reaches max_ball_speed"""
    ball.ball_speed += ball_speed_step
    if ball.ball_speed > max_ball_speed:
        ball.ball_speed = max_ball_speed

def slow_down(ball: turtle.Turtle):
    """Decrease ball speed. Hide and remove from list when stationary"""
    ball.ball_speed -= ball_speed_step
    if ball.ball_speed < ball_speed_step:
        ball.hideturtle()
        balls.remove(ball)

def change_direction(ball: turtle.Turtle):
    """Rotate Turtle object by a random angle in [-90, 90] range"""
    ball.left(random.randint(-90, 90))

# Map colours to ball actions
actions = {
    (144, 238, 144): speed_up,
    (220, 20, 60): slow_down,
    (255, 127, 80): change_direction,
}

# Create tiles
tiles = {
    coord: random.choice(tuple(actions.keys()))
    for coord in tile_grid_coords
}

# Create balls
balls = []

def create_new_ball():
    ball = turtle.Turtle()
    ball.penup()
    ball.shape("circle")
    ball.pencolor("white")
    ball.setposition(
        random.randint(-screen_size[0] // 2, screen_size[0] // 2),
        random.randint(-screen_size[1] // 2, screen_size[1] // 2)
    )
    ball.setheading(random.randint(0, 359))
    ball.ball_speed = 0.5

    balls.append(ball)

create_new_ball()  # Start animation with one ball

# Draw tiles on screen
grid_draw = turtle.Turtle()
grid_draw.penup()
grid_draw.hideturtle()

for coord, colour in tiles.items():
    coords = convert_grid_to_screen_coords(coord)
    grid_draw.setposition(
        coords[0] - grid_scale[0] / 2 * 0.9,
        coords[1] - grid_scale[1] / 2 * 0.9
    )
    grid_draw.color(colour)
    grid_draw.pendown()
    for _ in range(2):
        grid_draw.forward(grid_scale[0] * 0.8)
        grid_draw.left(90)
        grid_draw.forward(grid_scale[1] * 0.8)
        grid_draw.left(90)
    grid_draw.penup()

# Main animation loop
while True:
    for ball in balls:
        # Move ball
        ball.forward(ball.ball_speed)
        # If ball goes out of bounds, move to other side
        if abs(ball.xcor()) > screen_size[0] / 2:
            ball.setx(-ball.xcor())
        if abs(ball.ycor()) > screen_size[1] / 2:
            ball.sety(-ball.ycor())

    window.update()
    time.sleep(0.001)

When you run this code, you’ll see the single ball moving across the screen over the drawings of the tiles:

Before adding more balls to the animation, you can deal with the interactions between the ball and the tiles it hits.

Creating Interactions Between Balls and Tiles

You only have one ball in the animation so far. However, any steps you take in the main animation loop will apply to all balls in the animation since you’re looping through the list balls.

The steps required to detect when a ball hits a tile and to perform the required actions on the ball are the following:

  • Find which cell in the grid the ball is currently in.
  • Check whether that cell has a tile on it.
  • If the ball is on a tile, find the colour of the tile and what action is associated with that colour.
  • Implement the required action on the ball.

There is another pitfall you’ll need to be careful about. The ball is moving in small steps, and therefore, it will overlap on a single tile for several iterations of the main animation loop. However, you only want the action to be performed when the ball first hits a tile. You can add another instance variable to each ball to store the last tile the ball has hit and then add this as an additional check to determine whether a ball has just hit a tile.

You can add the new instance variable and make additions to the main animation loop:

import random
import time
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

grid_size = 16, 16
grid_scale = (
    screen_size[0] / grid_size[0],
    screen_size[1] / grid_size[1]
)
fraction_of_grid_points_used = 0.35
n_tiles = int(
    fraction_of_grid_points_used * grid_size[0] * grid_size[1]
)

max_ball_speed = 2
ball_speed_step = 0.2

# Functions to convert between grid and screen coordinates
def convert_grid_to_screen_coords(grid_coords):
    return (
        grid_coords[0] * grid_scale[0] - screen_size[0]/2 + grid_scale[0]/2,
        grid_coords[1] * grid_scale[1] - screen_size[1]/2 + grid_scale[1]/2,
    )

def convert_screen_to_grid_coords(screen_coords):
    return (
        round(
            (screen_coords[0] - grid_scale[0]/2 + screen_size[0]/2) / grid_scale[0]
        ),
        round(
            (screen_coords[1] - grid_scale[1]/2 + screen_size[1]/2) / grid_scale[1]
        ),
    )

# Create window
window = turtle.Screen()
window.tracer(0)
window.colormode(255)
window.setup(*screen_size)
window.bgcolor(background_colour)

# Choose grid coordinates that will contain tiles
tile_grid_coords = set()
while len(tile_grid_coords) < n_tiles:
    tile_grid_coords.add(
        (
            random.randint(0, grid_size[0] - 1),
            random.randint(0, grid_size[1] - 1)
        )
    )

# Define actions based on grid point colour
def speed_up(ball: turtle.Turtle):
    """Increase ball speed until it reaches max_ball_speed"""
    ball.ball_speed += ball_speed_step
    if ball.ball_speed > max_ball_speed:
        ball.ball_speed = max_ball_speed

def slow_down(ball: turtle.Turtle):
    """Decrease ball speed. Hide and remove from list when stationary"""
    ball.ball_speed -= ball_speed_step
    if ball.ball_speed < ball_speed_step:
        ball.hideturtle()
        balls.remove(ball)

def change_direction(ball: turtle.Turtle):
    """Rotate Turtle object by a random angle in [-90, 90] range"""
    ball.left(random.randint(-90, 90))

# Map colours to ball actions
actions = {
    (144, 238, 144): speed_up,
    (220, 20, 60): slow_down,
    (255, 127, 80): change_direction,
}

# Create tiles
tiles = {
    coord: random.choice(tuple(actions.keys()))
    for coord in tile_grid_coords
}

# Create balls
balls = []

def create_new_ball():
    ball = turtle.Turtle()
    ball.penup()
    ball.shape("circle")
    ball.pencolor("white")
    ball.setposition(
        random.randint(-screen_size[0] // 2, screen_size[0] // 2),
        random.randint(-screen_size[1] // 2, screen_size[1] // 2)
    )
    ball.setheading(random.randint(0, 359))
    ball.ball_speed = 0.5
    ball.current_grid = None

    balls.append(ball)

create_new_ball()  # Start animation with one ball

# Draw tiles on screen
grid_draw = turtle.Turtle()
grid_draw.penup()
grid_draw.hideturtle()

for coord, colour in tiles.items():
    coords = convert_grid_to_screen_coords(coord)
    grid_draw.setposition(
        coords[0] - grid_scale[0] / 2 * 0.9,
        coords[1] - grid_scale[1] / 2 * 0.9
    )
    grid_draw.color(colour)
    grid_draw.pendown()
    for _ in range(2):
        grid_draw.forward(grid_scale[0] * 0.8)
        grid_draw.left(90)
        grid_draw.forward(grid_scale[1] * 0.8)
        grid_draw.left(90)
    grid_draw.penup()

# Main animation loop
while True:
    for ball in balls:
        # Move ball
        ball.forward(ball.ball_speed)
        # If ball goes out of bounds, move to other side
        if abs(ball.xcor()) > screen_size[0] / 2:
            ball.setx(-ball.xcor())
        if abs(ball.ycor()) > screen_size[1] / 2:
            ball.sety(-ball.ycor())

        # Check whether ball hit tile and perform required action
        ball_grid_coords = convert_screen_to_grid_coords(ball.position())
        if (
                ball_grid_coords in tiles.keys()
                and ball_grid_coords != ball.current_grid
        ):
            colour = tiles[ball_grid_coords]
            actions[colour](ball)
            ball.current_grid = ball_grid_coords

    window.update()
    time.sleep(0.001)

The if statement you just added in the while loop contains two conditions:

  • The ball must be on a tile. You verify this by checking whether the tuple containing the grid coordinates of the ball’s current position is one of the keys in the dictionary tiles.
  • The tile the ball is currently on must not be the same one as in the previous iteration.

When both conditions are met, you perform the following steps:

  • You get the tile’s colour from the dictionary tiles and store it in the variable colour.
  • You get the name of the function mapped to the colour and call the function with ball as its argument. This is the same technique summarised in the section reviewing dictionaries above. actions is a dictionary, and therefore, actions[colour] gives the value associated with the tuple colour. This value is a function name (speed_up, slow_down, or change_direction).
  • You assign the current grid coordinates to the instance variable ball.current_grid so that these actions are not performed in the next iterations if the ball is still on this tile.

The output from the code so far gives the following output:

Note that as the tiles’ positions and colours and the ball’s position and orientation are all random, the outcome of each run will be different. When there’s only one ball, it is possible that this ball will be short-lived if it hits too many red tiles early on!

Using Colour to Show Ball Speed

You’ll indicate the speed of the ball by changing the ball’s colour. To achieve this, you’ll first need to select a colour for the balls. You can add this to the parameters at the top of your code.

Then, you can add a function that works out the right shade of that colour based on the speed of the ball. This function works out what fraction of the maximum speed the ball’s current speed is and scales the red, green, and blue values of the ball’s colour accordingly. You can use fillcolor(), which is another Turtle method, to fill the shape of the ball:

import random
import time
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

grid_size = 16, 16
grid_scale = (
    screen_size[0] / grid_size[0],
    screen_size[1] / grid_size[1]
)
fraction_of_grid_points_used = 0.35
n_tiles = int(
    fraction_of_grid_points_used * grid_size[0] * grid_size[1]
)

ball_colour = 0, 191, 255
max_ball_speed = 2
ball_speed_step = 0.2

# Functions to convert between grid and screen coordinates
def convert_grid_to_screen_coords(grid_coords):
    return (
        grid_coords[0] * grid_scale[0] - screen_size[0]/2 + grid_scale[0]/2,
        grid_coords[1] * grid_scale[1] - screen_size[1]/2 + grid_scale[1]/2,
    )

def convert_screen_to_grid_coords(screen_coords):
    return (
        round(
            (screen_coords[0] - grid_scale[0]/2 + screen_size[0]/2) / grid_scale[0]
        ),
        round(
            (screen_coords[1] - grid_scale[1]/2 + screen_size[1]/2) / grid_scale[1]
        ),
    )

# Create window
window = turtle.Screen()
window.tracer(0)
window.colormode(255)
window.setup(*screen_size)
window.bgcolor(background_colour)

# Choose grid coordinates that will contain tiles
tile_grid_coords = set()
while len(tile_grid_coords) < n_tiles:
    tile_grid_coords.add(
        (
            random.randint(0, grid_size[0] - 1),
            random.randint(0, grid_size[1] - 1)
        )
    )

# Define actions based on grid point colour
def speed_up(ball: turtle.Turtle):
    """Increase ball speed until it reaches max_ball_speed"""
    ball.ball_speed += ball_speed_step
    if ball.ball_speed > max_ball_speed:
        ball.ball_speed = max_ball_speed

def slow_down(ball: turtle.Turtle):
    """Decrease ball speed. Hide and remove from list when stationary"""
    ball.ball_speed -= ball_speed_step
    if ball.ball_speed < ball_speed_step:
        ball.hideturtle()
        balls.remove(ball)

def change_direction(ball: turtle.Turtle):
    """Rotate Turtle object by a random angle in [-90, 90] range"""
    ball.left(random.randint(-90, 90))

# Map colours to ball actions
actions = {
    (144, 238, 144): speed_up,
    (220, 20, 60): slow_down,
    (255, 127, 80): change_direction,
}

# Create tiles
tiles = {
    coord: random.choice(tuple(actions.keys()))
    for coord in tile_grid_coords
}

# Create balls
balls = []

def change_ball_colour(ball):
    fraction_of_max_speed = ball.ball_speed / max_ball_speed
    ball.fillcolor(
        int(ball_colour[0] * fraction_of_max_speed),
        int(ball_colour[1] * fraction_of_max_speed),
        int(ball_colour[2] * fraction_of_max_speed),
    )

def create_new_ball():
    ball = turtle.Turtle()
    ball.penup()
    ball.shape("circle")
    ball.pencolor("white")
    ball.setposition(
        random.randint(-screen_size[0] // 2, screen_size[0] // 2),
        random.randint(-screen_size[1] // 2, screen_size[1] // 2)
    )
    ball.setheading(random.randint(0, 359))
    ball.ball_speed = 0.5
    ball.current_grid = None
    change_ball_colour(ball)

    balls.append(ball)

create_new_ball()  # Start animation with one ball

# Draw tiles on screen
grid_draw = turtle.Turtle()
grid_draw.penup()
grid_draw.hideturtle()

for coord, colour in tiles.items():
    coords = convert_grid_to_screen_coords(coord)
    grid_draw.setposition(
        coords[0] - grid_scale[0] / 2 * 0.9,
        coords[1] - grid_scale[1] / 2 * 0.9
    )
    grid_draw.color(colour)
    grid_draw.pendown()
    for _ in range(2):
        grid_draw.forward(grid_scale[0] * 0.8)
        grid_draw.left(90)
        grid_draw.forward(grid_scale[1] * 0.8)
        grid_draw.left(90)
    grid_draw.penup()

# Main animation loop
while True:
    for ball in balls:
        # Move ball
        ball.forward(ball.ball_speed)
        # If ball goes out of bounds, move to other side
        if abs(ball.xcor()) > screen_size[0] / 2:
            ball.setx(-ball.xcor())
        if abs(ball.ycor()) > screen_size[1] / 2:
            ball.sety(-ball.ycor())

        # Check whether ball hit tile and perform required action
        ball_grid_coords = convert_screen_to_grid_coords(ball.position())
        if (
                ball_grid_coords in tiles.keys()
                and ball_grid_coords != ball.current_grid
        ):
            colour = tiles[ball_grid_coords]
            actions[colour](ball)
            ball.current_grid = ball_grid_coords
            change_ball_colour(ball)

    window.update()
    time.sleep(0.001)

You call change_ball_colour() in the function that creates the balls and in the main animation loop when a ball changes speed. The output of the code now looks like this:

Adding More Balls at Regular Intervals

The last step is to add more balls. You can define a parameter to set the time interval between new balls being created and then set a timer that resets every interval after creating a new ball.

Here’s the final version of the Chaotic Balls animation code:

import random
import time
import turtle

# Parameters to set up animation
background_colour = 50, 50, 50
screen_size = 800, 800

grid_size = 16, 16
grid_scale = (
    screen_size[0] / grid_size[0],
    screen_size[1] / grid_size[1]
)
fraction_of_grid_points_used = 0.35
n_tiles = int(
    fraction_of_grid_points_used * grid_size[0] * grid_size[1]
)

ball_colour = 0, 191, 255
new_ball_interval = 2
max_ball_speed = 2
ball_speed_step = 0.2

# Functions to convert between grid and screen coordinates
def convert_grid_to_screen_coords(grid_coords):
    return (
        grid_coords[0] * grid_scale[0] - screen_size[0]/2 + grid_scale[0]/2,
        grid_coords[1] * grid_scale[1] - screen_size[1]/2 + grid_scale[1]/2,
    )

def convert_screen_to_grid_coords(screen_coords):
    return (
        round(
            (screen_coords[0] - grid_scale[0]/2 + screen_size[0]/2) / grid_scale[0]
        ),
        round(
            (screen_coords[1] - grid_scale[1]/2 + screen_size[1]/2) / grid_scale[1]
        ),
    )

# Create window
window = turtle.Screen()
window.tracer(0)
window.colormode(255)
window.setup(*screen_size)
window.bgcolor(background_colour)

# Choose grid coordinates that will contain tiles
tile_grid_coords = set()
while len(tile_grid_coords) < n_tiles:
    tile_grid_coords.add(
        (
            random.randint(0, grid_size[0] - 1),
            random.randint(0, grid_size[1] - 1)
        )
    )

# Define actions based on grid point colour
def speed_up(ball: turtle.Turtle):
    """Increase ball speed until it reaches max_ball_speed"""
    ball.ball_speed += ball_speed_step
    if ball.ball_speed > max_ball_speed:
        ball.ball_speed = max_ball_speed

def slow_down(ball: turtle.Turtle):
    """Decrease ball speed. Hide and remove from list when stationary"""
    ball.ball_speed -= ball_speed_step
    if ball.ball_speed < ball_speed_step:
        ball.hideturtle()
        balls.remove(ball)

def change_direction(ball: turtle.Turtle):
    """Rotate Turtle object by a random angle in [-90, 90] range"""
    ball.left(random.randint(-90, 90))

# Map colours to ball actions
actions = {
    (144, 238, 144): speed_up,
    (220, 20, 60): slow_down,
    (255, 127, 80): change_direction,
}

# Create tiles
tiles = {
    coord: random.choice(tuple(actions.keys()))
    for coord in tile_grid_coords
}

# Create balls
balls = []

def change_ball_colour(ball):
    fraction_of_max_speed = ball.ball_speed / max_ball_speed
    ball.fillcolor(
        int(ball_colour[0] * fraction_of_max_speed),
        int(ball_colour[1] * fraction_of_max_speed),
        int(ball_colour[2] * fraction_of_max_speed),
    )

def create_new_ball():
    ball = turtle.Turtle()
    ball.penup()
    ball.shape("circle")
    ball.pencolor("white")
    ball.setposition(
        random.randint(-screen_size[0] // 2, screen_size[0] // 2),
        random.randint(-screen_size[1] // 2, screen_size[1] // 2)
    )
    ball.setheading(random.randint(0, 359))
    ball.ball_speed = 0.5
    ball.current_grid = None
    change_ball_colour(ball)

    balls.append(ball)

create_new_ball()  # Start animation with one ball

# Draw tiles on screen
grid_draw = turtle.Turtle()
grid_draw.penup()
grid_draw.hideturtle()

for coord, colour in tiles.items():
    coords = convert_grid_to_screen_coords(coord)
    grid_draw.setposition(
        coords[0] - grid_scale[0] / 2 * 0.9,
        coords[1] - grid_scale[1] / 2 * 0.9
    )
    grid_draw.color(colour)
    grid_draw.pendown()
    for _ in range(2):
        grid_draw.forward(grid_scale[0] * 0.8)
        grid_draw.left(90)
        grid_draw.forward(grid_scale[1] * 0.8)
        grid_draw.left(90)
    grid_draw.penup()

# Main animation loop
start_timer = time.time()
while True:
    # Create new ball every time interval elapses
    if time.time() - start_timer > new_ball_interval:
        create_new_ball()
        start_timer = time.time()

    for ball in balls:
        # Move ball
        ball.forward(ball.ball_speed)
        # If ball goes out of bounds, move to other side
        if abs(ball.xcor()) > screen_size[0] / 2:
            ball.setx(-ball.xcor())
        if abs(ball.ycor()) > screen_size[1] / 2:
            ball.sety(-ball.ycor())

        # Check whether ball hit tile and perform required action
        ball_grid_coords = convert_screen_to_grid_coords(ball.position())
        if (
                ball_grid_coords in tiles.keys()
                and ball_grid_coords != ball.current_grid
        ):
            colour = tiles[ball_grid_coords]
            actions[colour](ball)
            ball.current_grid = ball_grid_coords
            change_ball_colour(ball)

    window.update()
    time.sleep(0.001)

And the output of this code is the following animation:

Final Words

In this article, you used the main built-in data structures in Python in a visual animation including many balls flying around a screen with many coloured tiles. The balls interact with each tile depending on the tile’s colour.

When learning about using lists, tuples, dictionaries, and sets in Python, it’s important to write some simple, short, code snippets to explore these data structures. But there’s also a lot of benefit in using them in a more elaborate manner.

This article and the Chaotic Balls simulation aim to demonstrate an alternative way of using lists, tuples, dictionaries, and sets in Python.

Each of these data structures has its own purposes:

  • You used tuples to store the coordinates and the RGB colours since these don’t need to be flexible containers. Using tuples also allowed you to use them as keys in dictionaries, which you wouldn’t have been able to do if you had used lists.
  • You used a list to store all the balls in the animation. This needs to be a flexible container as the number of balls increases and decreases throughout the animation. You need to store the balls in the same data structure to make use of loops to deal with all the balls in the animation effectively.
  • You used a set when you needed to ensure the pairs of coordinates you created randomly for the tiles were unique.
  • You used dictionaries to store the mappings between several bits of information in your code, including the mapping between tile colours and ball actions, and the mapping between the tile coordinates and their colours.

Now you can run the code and watch the hypnotic movement of the balls for a few minutes. You’ve earned the break!

Further Reading

  • Read more about lists in the Chapter about loops and lists in The Python Coding Book
  • You can also read about linked lists and how they compare with lists in the article about stacks and queues
  • You’ll find out more about dictionaries and tuples, including a word analysis project using dictionaries, in the Chapter about data types in The Python Coding Book
  • The example in this article used instance variables defined directly on instances of the class turtle.Turtle. You can read the article about Python instance variables and the full Chapter about Object-Oriented Programming in The Python Coding Book

Python 3.9 was used for the code in this article


Get the latest blog updates

No spam promise. You’ll get an email when a new blog post is published


Appendix: Converting Between Grid and Screen Coordinates

Let’s look back at the simplified illustration of the screen and grid coordinates:

There are three pixels that have been labelled on this illustration: (0, 0), (20, 20), and (-380, -380). Screen coordinates are shown using round brackets in the diagram.

The grid cells are shown using the dashed lines. There are 16 cells in all as this illustration shows a 4x4 grid. Some of the grid cells are labelled using their grid coordinates. These are shown using square brackets.

There are three transformations needed to convert from one coordinate system to the other. Shifts in coordinate systems are obtained through addition and subtraction. Scaling is obtained through multiplication and division:

  • We are assuming that the grid coordinates represent the centre of a cell. Therefore there’s a shift of half the width of a cell horizontally and half the height of a cell vertically. This is a shift of grid_scale / 2.
  • The origin of the turtle screen is at the centre whereas the grid coordinates have the origin at the bottom left. This leads to another shift of screen_size / 2.
  • Finally, there’s a scaling from an NxN grid to an MxM array of pixels, or vice versa.

Let’s now work through the algorithms in the two functions convert_grid_to_screen_coords() and convert_screen_to_grid_coords() to understand each step. For this example, we’ll use the 4x4 grid set up, for simplicity.

Therefore, the parameters you’ll need are:

  • grid_size = 4, 4
  • screen_size = 800, 800
  • grid_scale = 800/4, 800/4 = 200, 200

convert_grid_to_screen_coords()

Consider the grid cell (2, 2) in the diagram. This is labelled with square brackets in the diagram to avoid confusion with screen coordinates, but I’ll stick to round brackets in the text. The aim of this function is to find the screen coordinates of the pixel at the centre of this cell.

  • The tuple (2, 2) is the argument for the function convert_grid_to_screen_coords().
  • Each number in the tuple is multiplied by grid_scale, which gives (400, 400). This is the scaling needed to go from a 4x4 grid to an 800x800 array. If the screen coordinates also had the origin at the bottom left of the screen, (400, 400) would represent the bottom left corner of the cell.
  • However, in turtle, the screen coordinates (0, 0) represent the centre of the screen. Therefore, you subtract the half-width and half-height of the screen to account for this. This is a shift, or translation, between the two coordinate systems. Since the screen size is 800x800 pixels, this results in the tuple (400-400, 400-400), or more simply (0, 0). The bottom left corner of the cell with grid coordinates (2, 2) is the pixel with screen coordinates (0, 0).
  • However, if you want the pixel at the centre of the cell, you’ll need to account for the size of each cell in pixels. Specifically, you need to add half the size of each cell, that’s grid_scale[i] / 2. i represents either 1 or 2 depending on whether it’s the x- or y-coordinates. The function outputs the tuple (100, 100). This means that the pixel at the centre of grid cell (2, 2) is the pixel with screen coordinates (100, 100).

convert_screen_to_grid_coords()

Consider the pixel with screen coordinates (20, 20) in the illustration. The aim of this function is to find the grid coordinates of the cell that contains this pixel.

  • The tuple (20, 20) is the argument for the function convert_screen_to_grid_coords().
  • When you subtract grid_scale[i] / 2, which is the half-width and half-height of a cell, you get (-80, -80). This shift takes into account the fact that the grid coordinates represent the centre of a cell.
  • Next, you account for the difference in the origin between the grid coordinates and the screen coordinates with another shift. Since you’re converting from screen to grid coordinates, you add the half-width and half-height of the screen. This gives the (-80+400, -80+400) which is (320, 320).
  • Finally, you scale down from an array of pixels to cells in a grid. You achieve this by dividing (320, 320) by grid_scale[i] to give (1.6, 1.6) and round to the nearest integer, giving the output (2, 2). Therefore the pixel (20, 20) lies within the cell with grid coordinates (2, 2).

The post Practise Using Lists, Tuples, Dictionaries, and Sets in Python With the Chaotic Balls Animation appeared first on The Python Coding Book.



from Planet Python
via read more

No comments:

Post a Comment

TestDriven.io: Working with Static and Media Files in Django

This article looks at how to work with static and media files in a Django project, locally and in production. from Planet Python via read...