Codewars: Make a spiral Link to full problem description: https://www.codewars.com/kata/534e01fbbb17187c7e0000c6
My code:
def spiralize(size):
    def get_change_values():
        change_x = 0
        change_y = 0
        if direction == 'right':
            change_x = 1
        elif direction == 'left':
            change_x = -1
        elif direction == 'down':
            change_y = 1
        elif direction == 'up':
            change_y = -1
        return change_x, change_y

    def check_collision():
        if 0 &lt;= y + change_y * 2 &lt; size and 0 &lt;= x + change_x * 2 &lt; size:
            if direction in ('right', 'left'):
                if spiral[y][x + change_x * 2]:
                    return True
            elif direction in ('down', 'up'):
                if spiral[y + change_y * 2][x]:
                    return True
        return False

    spiral = [[0 for _ in range(size)] for _ in range(size)]
    directions = ['right', 'down', 'left', 'up']
    x = y = 0
    change_directions = 0
    while change_directions &lt; size:
        direction = directions[change_directions % 4]
        change_x, change_y = get_change_values()
        while 0 &lt;= y + change_y &lt; size and 0 &lt;= x + change_x &lt; size:
            spiral[y][x] = 1
            if check_collision():
                break
            y += change_y
            x += change_x
        change_directions += 1
    return spiral

The basic idea of what my algortihm is trying to do here:

Initialize a matrix of 0s of the given size
Starting from the top left and heading towards the right, go through each element and change it to '1' until it can no longer move in that direction
Once it can no longer move in a given direction, change the direction based on the order right -&gt; down -&gt; left -&gt; up
Repeat until spiral is complete (until size-1 changes of direction have been made)

The main advice I'm looking for here is how to better structure this code to make it more readable and understandable to the average viewer. Granted, if there are any glaring performance optimizations that can be made to improve this code, I'd be more than willing to hear those out as well. But again, I'm mostly looking for advice on how to make this code cleaner in general. I'm not exactly a new programmer per se, but the concept of 'clean code' is still something I very much struggle with.
Without changing your algorithm we can make your code simpler in a few places. I'd say the algorithm is quite sensible, I would've chosen the same approach.

Comments and docstrings
Providing some comments here and there and explaining the general algorithmic approach in a docstring would be good start for increasing readability. In particular, it's not intuitively obvious why while change_directions &lt; size:
is the correct approach.

Nested functions
I find nested functions to be relatively hard to read. Especially when they start using variables from the outer scope (the outer function), thereby mixing namespaces. Personally, I generally try to avoid them. In the case of check_collision I'd say it might improve readability as it reduces the maximum indentation level. That's generally a good thing, since every indentation level is another layer of context to keep track of.

get_change_values()
We can easily get rid of this function by taking a closer look at the underlying logic. We don't actually need to pass around direction strings, since we always follow the same pattern RIGHT -&gt; DOWN -&gt; LEFT -&gt; UP -&gt; RIGHT -&gt; .... The itertools module from the standard library provides cycle, which basically provides an endless iterator for a given iterable (example from the docs: cycle('ABCD') --&gt; A B C D A B C D A B C D ...). So we might want to cycle(['right', 'down', 'left', 'up']). But since we only need the strings to get the corresponding change_x, change_y values, we can replace them by those values directly:
directions = cycle(((1, 0), (0, 1), (-1, 0), (0, -1)))

We now replace
direction = directions[change_directions % 4]
change_x, change_y = get_change_values()

by
change_x, change_y = next(directions)

and get rid of get_change_values().

0 &lt;= variable &lt; limit
Conditions that follow the pattern 0 &lt;= variable &lt; limit can be replaced by variable in range(limit). I personally find this to be more readable, but it's not always preferable and up to personal preference.

check_collision()
This function can be simplified to the point where we could directly put it into our nested while loop. This is a valid option here. But as mentioned above you might want to keep it in a seperate function to reduce the maximum indentation level.
We don't actually need to check for the direction:
if direction in ('right', 'left'):
    if spiral[y][x + change_x * 2]:
        return True
elif direction in ('down', 'up'):
    if spiral[y + change_y * 2][x]:
        return True

if we simply check
if spiral[y + change_y * 2][x + change_x * 2] == 1:
    return True

We can also directly return:
def check_collision() -&gt; bool:
    if y + change_y * 2 in range(size) and x + change_x * 2 in range(size):
        return spiral[y + change_y * 2][x + change_x * 2] == 1
    
    return False

I find exit conditions to generally improve code readability as well. They're usually closer to the reader's thought process (&quot;If the step is out of bounds there's no collision, so we don't need to ckeck anything else. Otherwise check if the step leads to a 1.&quot;):
def check_collision() -&gt; bool:
    if y + change_y * 2 not in range(size) or x + change_x * 2 not in range(size):
        return False
    
    return spiral[y + change_y * 2][x + change_x * 2] == 1

Both options are already pretty clean. I personally prefer this branchless approach:
def check_collision() -&gt; bool:
    step_inside_bounds = y + change_y * 2 in range(size) and x + change_x * 2 in range(size)
    return step_inside_bounds and spiral[y + change_y * 2][x + change_x * 2] == 1

This works because and is evaluated lazily, so if step_inside_bounds is False, the rest of the condition is not evaluated.

spiral initialization
Instead of
spiral = [[0 for _ in range(size)] for _ in range(size)]

we can initialize spiral like this
spiral = [[0] * size for _ in range(size)]

This again is just another way to do things, and I wouldn't say it's definitely better for multi-dimensional lists. For one-dimensional lists, I would prefer the suggested syntax. Compare these examples:
my_list = [0 for _ in range(my_length)]
my_list = [0] * my_length


Complete code
I renamed change_directions to direction_changes and check_collision to collision_in_two_steps. I also added type hints (PEP 484).
from itertools import cycle


def spiralize(size: int) -&gt; list[list[int]]:
    def collision_in_two_steps() -&gt; bool:
        step_inside_bounds = y + change_y * 2 in range(size) and x + change_x * 2 in range(size)
        return step_inside_bounds and spiral[y + change_y * 2][x + change_x * 2] == 1

    directions = cycle(((1, 0), (0, 1), (-1, 0), (0, -1)))

    spiral = [[0] * size for _ in range(size)]
    x, y = 0, 0
    direction_changes = 0

    while direction_changes &lt; size:
        change_x, change_y = next(directions)

        while y + change_y in range(size) and x + change_x in range(size):
            spiral[y][x] = 1

            if collision_in_two_steps():
                break

            y += change_y
            x += change_x

        direction_changes += 1

    return spiral