Build Snake in Your macOS Terminal

Build a playable Snake game with Python curses in your macOS Terminal.

Introduction

30 Second Summary

A blank Terminal window usually waits for a command. With the right program, that same space can respond instantly to every keypress.

In this project, you will build a full-screen Snake game in Terminal using Python curses. The game responds as you steer, eat food, grow, score points, or hit a collision.

What You'll Build

You launch myscript.py from Terminal to play a bordered Snake arena where every keypress steers @oo toward food while the score updates above the action.

By the end of this project, you'll have:

  • A full-screen Snake arena you can launch directly from Terminal. Your snake moves continuously inside a bordered playfield.
  • Responsive arrow-key and WASD controls that steer without pressing Enter. Instant reversals stay blocked.
  • A complete game loop where food grows the snake and raises your score. Wall or self-contact ends the run with clear feedback. Pressing q exits cleanly.
  • Secret Mission: Add a pause mode that freezes movement while keeping input responsive. The title shows PAUSED until you resume.

Are there any prerequisites?

The project assumes that Terminal, Python 3.14, IDLE, and the optional curses module are already available on your Mac. IDLE is your editor. Terminal gives curses direct access to the keyboard and screen.

Before We Start

Your macOS tools are ready. This is your moment to commit to the playable full-screen Snake game you are building in Terminal before the hands-on work begins.

Verify Python and Create the Game File

Your full-screen Snake game runs through a specific Python 3.14 interpreter in Terminal. Confirming that interpreter first keeps environment problems separate from game bugs.

Your code also needs one clearly named home. IDLE gives you a simple editor for creating the empty myscript.py file that every later step builds on.

In this step, get ready to:
  • Verify the Python 3.14 interpreter in Terminal.
  • Create an empty myscript.py file in IDLE.
Verify Python in Terminal

The version check requests detailed build information from the confirmed interpreter. Its output proves Terminal can reach the Python version your game uses.

  • Use the macOS application search to find Terminal.
  • Select Terminal from the search results.

You will see a Terminal window ready to accept the interpreter check.

  • Check the Python interpreter by running this command:
python3.14 -VV

What Does This Command Do?

  • The python3.14 executable selects the Python 3.14 interpreter.
  • The -VV flag requests detailed interpreter build information.

You should see detailed build information identifying Python 3.14. That is your interpreter confirmed for the game.

Python Check Not Running?

Compare your command with the code block above. Pay close attention to the executable name plus both capital letters in the flag.

For help with an environment-specific failure, ask for help diagnosing the interpreter check.

Create the empty game file

The myscript.py file becomes the single source file for your game. Starting with an empty editor gives the first board code a clean home.

  • Use the macOS application search to find IDLE.
  • Select IDLE from the search results.

You will see IDLE open on your Mac.

  • Click File in the IDLE menu bar.
  • Select New File.

You will see a separate empty editor window.

  • Click File in the empty editor window.
  • Select Save As….

You will see the save dialog for the empty file.

  • Type myscript.py as the file name.
  • Press Enter to save the file.

Good. You should see myscript.py in the editor title while the editing area remains empty.

File Name Looks Different?

Use File plus Save As… again. Confirm that the name ends with .py.

If the file name still looks wrong, ask for help saving the empty editor correctly.

Confirm the setup

Your setup now has two observable signals. Terminal identifies the interpreter while IDLE shows the saved source file.

  • Switch back to the Terminal window from earlier.

Before you run the final check, consider whether Terminal will show a short version or detailed build information.

  • Confirm the interpreter one final time by running this command:
python3.14 -VV

What Does This Final Check Prove?

Detailed build information confirms that Terminal is using the required interpreter. This separates future game errors from Python setup problems.

You should see Python 3.14 build information again. Your interpreter check now matches the required end state.

  • Switch back to the IDLE editor from earlier.

You should see the saved myscript.py title above an empty editing area.

✔️ Awesome, I've got everything!

Your Python interpreter is confirmed. Your empty myscript.py file is saved in IDLE.

ⓧ I'd like to double check the full code

The complete myscript.py artifact is an empty editor. Its editing area should contain zero characters.

Why Is the File Empty?

This step creates the source file without adding game logic. The next step adds the first visible Snake board.

Your interpreter is confirmed. Next up, the empty myscript.py becomes a bordered Snake board.

Draw the First Snake Board

Your verified interpreter is ready in Terminal. The empty myscript.py file is waiting in IDLE.

A visible arena gives every later game rule a place to act.

Python curses paints that arena directly inside Terminal. This step adds a stationary snake so you can verify the screen before movement adds complexity.

In this step, get ready to:
  • Protect the game from a Terminal window that is too small.
  • Draw a bordered arena with a three-cell snake near the center.
  • Run myscript.py to confirm the first board appears.
Guard against a small Terminal

A full-screen game needs enough rows and columns to draw its arena safely. The first part of main() checks the available space before drawing anything.

  • Enter this foundation in the empty myscript.py editor:
import curses

MIN_HEIGHT = 18
MIN_WIDTH = 50


def main(stdscr):
    height, width = stdscr.getmaxyx()

    if height < MIN_HEIGHT or width < MIN_WIDTH:
        message = f"Resize Terminal to at least {MIN_WIDTH}x{MIN_HEIGHT}, then run again."
        stdscr.addstr(0, 0, message[: max(1, width - 1)])
        stdscr.refresh()
        stdscr.getch()
        return


curses.wrapper(main)

What does this foundation do?

  • The curses import gives your program control over Terminal drawing and keyboard input.
  • The MIN_HEIGHT and MIN_WIDTH values define the smallest supported arena.
  • The stdscr.getmaxyx() call reads the current height and width.
  • The resize block shows a message when either dimension is too small.
  • The curses.wrapper(main) call starts the curses screen. It also restores Terminal when the function exits.
  • Save myscript.py by pressing Cmd+S.
  • Switch back to Terminal from earlier.
  • Shrink the Terminal window below 50 columns or 18 rows by dragging one edge.

Before you run the file, do you think the size guard will keep curses from drawing the arena?

  • Test the size guard by running this command:
python3.14 myscript.py

What should I see?

You should see Resize Terminal to at least 50x18, then run again. at the top of the screen. This confirms that main() reads the Terminal size before drawing the game.

  • Press any key to return to the Terminal prompt.
  • Enlarge the Terminal window beyond the minimum size by dragging its edges.

Did the resize message disappear immediately?

Make sure you saved myscript.py before running it. Shrink the Terminal window further if the program exits without showing the message.

Run the command from the folder containing myscript.py if Terminal cannot locate the file.

Help me test the Terminal size guard in my Python curses script.

Draw the arena and snake

A terminal screen uses a coordinate system where each character occupies one position. The vertical coordinate comes first in each drawing call.

  • Switch back to myscript.py in IDLE.
  • Insert the code below on the first blank line after the resize block's return line:
    center_y = height // 2
    center_x = width // 2
    stdscr.clear()
    stdscr.box()
    stdscr.addch(center_y, center_x, "@")
    stdscr.addch(center_y, center_x - 1, "o")
    stdscr.addch(center_y, center_x - 2, "o")
    stdscr.refresh()
    stdscr.getch()

How does the board appear?

  • The center_y and center_x values locate the middle of the current Terminal window.
  • The stdscr.clear() call prepares a clean screen for the game.
  • The stdscr.box() call draws the arena around the edge of the screen.
  • Each stdscr.addch() call paints one snake cell. The @ character is the head.
  • The stdscr.refresh() call updates the visible screen. The final stdscr.getch() call keeps the board visible until you press a key.
  • Save myscript.py by pressing Cmd+S.
  • Switch back to Terminal.

Before you run the game, where do you expect the @oo snake to appear inside the border?

  • Launch the first Snake board by running this command:
python3.14 myscript.py

What should I see?

You should see a bordered playfield filling the Terminal window. A stationary @oo snake should sit near the center.

  • Press any key to close the board.

That first visible slice is working. Your Terminal now displays the arena and snake from myscript.py.

Don't see the bordered board?

Check that the drawing lines remain indented inside main(). A line aligned with curses.wrapper(main) sits outside the function.

Confirm the Terminal window is larger than the minimum dimensions. The resize message means the guard is still protecting the drawing calls.

Help me debug a Python curses board that should show a border and an @oo snake.

✔️ Awesome, I've got everything!

Great. Double check that myscript.py is saved before moving on.

ⓧ I'd like to double check the full code

Compare your myscript.py file with this complete version.

import curses

MIN_HEIGHT = 18
MIN_WIDTH = 50


def main(stdscr):
    height, width = stdscr.getmaxyx()

    if height < MIN_HEIGHT or width < MIN_WIDTH:
        message = f"Resize Terminal to at least {MIN_WIDTH}x{MIN_HEIGHT}, then run again."
        stdscr.addstr(0, 0, message[: max(1, width - 1)])
        stdscr.refresh()
        stdscr.getch()
        return

    center_y = height // 2
    center_x = width // 2
    stdscr.clear()
    stdscr.box()
    stdscr.addch(center_y, center_x, "@")
    stdscr.addch(center_y, center_x - 1, "o")
    stdscr.addch(center_y, center_x - 2, "o")
    stdscr.refresh()
    stdscr.getch()


curses.wrapper(main)

Your first Snake board is visible. Next, you'll turn the stationary snake into a timed animation.

Animate the Snake

Your bordered arena already proves that curses can paint characters at precise positions. The snake is still a snapshot.

This step turns that snapshot into a real-time game loop. You will focus on continuous movement before adding collision rules.

In this step, get ready to:
  • Define the frame timing plus the coordinate changes for each direction.
  • Move Terminal rendering into draw().
  • Run the continuous update loop until its intended boundary failure appears.
Define the movement model

A coordinate system describes every snake cell with a vertical position followed by a horizontal position. Movement changes one of those values during each frame.

  • In myscript.py, place your cursor on the blank line beneath MIN_WIDTH = 50.
  • Add the frame delay plus the direction map by typing the code below:
FRAME_MS = 120

DIRECTIONS = {
    curses.KEY_UP: (-1, 0),
    curses.KEY_DOWN: (1, 0),
    curses.KEY_LEFT: (0, -1),
    curses.KEY_RIGHT: (0, 1),
}

How does the movement model work?

  • FRAME_MS stores a 120 millisecond delay between frames.
  • DIRECTIONS maps each arrow-key constant to a vertical plus horizontal change.
  • KEY_RIGHT uses (0, 1) because the row stays fixed while the column increases.
  • Save myscript.py by pressing Cmd+S.
  • Switch back to Terminal from earlier.
  • Check that the current board still loads by running this command:
python3.14 myscript.py

What does this check prove?

Python 3.14 executes myscript.py with the new movement definitions. A working board confirms that the dictionary syntax is valid.

You should still see the bordered playfield with the stationary @oo snake near the center.

  • Press any key to return to the Terminal prompt.

Does the board fail to open?

Check that every direction entry ends with a comma. Confirm that the closing brace sits beneath the four entries.

Help me check my movement dictionary.

Build a reusable draw function

Animation repeatedly clears the old frame before drawing the current snake positions. A dedicated draw() function keeps that rendering work separate from movement.

  • Switch back to myscript.py in IDLE.
  • Place your cursor between the closing brace of DIRECTIONS plus the existing main() function.
  • Add the reusable renderer by typing the code below:
def draw(stdscr, snake):
    stdscr.erase()
    stdscr.box()

    for index, (y, x) in enumerate(snake):
        stdscr.addch(y, x, "@" if index == 0 else "o")

    stdscr.refresh()

What does this function do?

  • erase() clears the previous frame from the curses window.
  • box() restores the arena border after the clear.
  • enumerate(snake) gives each coordinate its position in the ordered snake list.
  • The cell at index 0 becomes the @ head. Every remaining cell becomes an o body segment.
  • refresh() sends the completed frame to Terminal.
  • Save myscript.py by pressing Cmd+S.
  • Switch back to Terminal.
  • Confirm that the new function loads without disrupting the current board by running this command:
python3.14 myscript.py

What does this check prove?

Python loads the new draw() definition before it calls main(). Seeing the existing board confirms that the renderer contains valid Python code.

You should see the same bordered arena with the stationary snake. Your file now accepts a reusable renderer without breaking the working board.

  • Press any key to return to the Terminal prompt.

Does Terminal return immediately?

Confirm that draw() starts at the left edge of the file. Its body must remain indented beneath the function definition.

Help me fix the draw function indentation.

Run the movement-only loop

Timed input lets each frame continue after a fixed delay. The loop can now update the snake even when you do not press a key.

  • Switch back to myscript.py in IDLE.
  • Find the return line at the end of the Terminal size check.
  • Place your cursor on the blank line after return.
  • Add the timed input setting by typing this line:
    stdscr.timeout(FRAME_MS)

Why use a timeout?

timeout() limits how long getch() waits for input. The FRAME_MS value gives each frame a consistent duration.

  • Locate the existing block that begins with center_y = height // 2.
  • Select from that line through the existing stdscr.getch() line.
  • Replace the selected block by typing the movement loop below:
    center_y = height // 2
    center_x = width // 2
    snake = [
        (center_y, center_x),
        (center_y, center_x - 1),
        (center_y, center_x - 2),
    ]
    direction = curses.KEY_RIGHT

    while True:
        draw(stdscr, snake)
        stdscr.getch()

        dy, dx = DIRECTIONS[direction]
        head_y, head_x = snake[0]
        new_head = (head_y + dy, head_x + dx)
        snake.insert(0, new_head)
        snake.pop()

How does each frame move the snake?

  • snake stores the head first. The body coordinates follow in order.
  • direction starts with curses.KEY_RIGHT.
  • draw() paints the current coordinate list at the start of every frame.
  • new_head adds the active direction change to the current head position.
  • snake.insert(0, new_head) creates the next head. snake.pop() removes the old tail to preserve the length.
  • Save myscript.py by pressing Cmd+S.
  • Switch back to Terminal.

Before you run it, how far do you think the movement-only loop can travel before the screen rejects its next frame?

  • Start the movement-only game without pressing a key by running this command:
python3.14 myscript.py

Why is this failure safe?

curses.wrapper(main) restores Terminal when the program exits through an exception. You return to a readable prompt before Python displays the traceback.

You will see @oo travel to the right across the bordered arena. After the head reaches the border, Terminal returns to the prompt with a traceback containing curses.error.

You found the exact boundary of the movement-only loop. The snake now animates under its own power.

Does the snake stay still?

Confirm that stdscr.timeout(FRAME_MS) appears before the center coordinates. Check that the movement lines remain inside the while True: block.

Help me debug a stationary Snake loop.

✔️ Awesome, I've got everything!

Great. Double check that you saved myscript.py after observing the intended boundary failure.

ⓧ I'd like to double check the full code

Compare your complete myscript.py file with this version:

import curses

MIN_HEIGHT = 18
MIN_WIDTH = 50
FRAME_MS = 120

DIRECTIONS = {
    curses.KEY_UP: (-1, 0),
    curses.KEY_DOWN: (1, 0),
    curses.KEY_LEFT: (0, -1),
    curses.KEY_RIGHT: (0, 1),
}


def draw(stdscr, snake):
    stdscr.erase()
    stdscr.box()

    for index, (y, x) in enumerate(snake):
        stdscr.addch(y, x, "@" if index == 0 else "o")

    stdscr.refresh()


def main(stdscr):
    height, width = stdscr.getmaxyx()

    if height < MIN_HEIGHT or width < MIN_WIDTH:
        message = f"Resize Terminal to at least {MIN_WIDTH}x{MIN_HEIGHT}, then run again."
        stdscr.addstr(0, 0, message[: max(1, width - 1)])
        stdscr.refresh()
        stdscr.getch()
        return

    stdscr.timeout(FRAME_MS)

    center_y = height // 2
    center_x = width // 2
    snake = [
        (center_y, center_x),
        (center_y, center_x - 1),
        (center_y, center_x - 2),
    ]
    direction = curses.KEY_RIGHT

    while True:
        draw(stdscr, snake)
        stdscr.getch()

        dy, dx = DIRECTIONS[direction]
        head_y, head_x = snake[0]
        new_head = (head_y + dy, head_x + dx)
        snake.insert(0, new_head)
        snake.pop()


curses.wrapper(main)

What does the complete file contain?

This version combines frame timing, directional coordinate changes, reusable rendering, plus the continuous update loop. It intentionally contains no wall collision check.

Your snake now redraws itself through a timed movement loop. Next, you will add steering plus collision rules that turn the traceback into controlled game-over behavior.

Add Steering and Safe Collisions

Your Python curses snake now moves across the arena on a timed loop. The planned curses.error traceback proved that the loop reaches the border without checking whether the next position is safe.

A real-time game loop must validate each requested move before changing its mutable game state. This step adds responsive steering. Collision checks then turn wall or self-contact into a controlled game-over state.

In this step, get ready to:
  • Map arrow keys and WASD to safe direction changes.
  • Detect wall and self-collisions before moving the snake.
  • Replace the border traceback with a visible game-over state.
Map safe direction changes

Keyboard input arrives as an integer that can represent an arrow key or a letter. Two lookup dictionaries give every supported key a direction while identifying the reverse direction that must be rejected.

  • Return to myscript.py in IDLE.
  • Find the closing brace of DIRECTIONS.
  • Add the keyboard mapping dictionaries below DIRECTIONS by pasting this code:
OPPOSITES = {
    curses.KEY_UP: curses.KEY_DOWN,
    curses.KEY_DOWN: curses.KEY_UP,
    curses.KEY_LEFT: curses.KEY_RIGHT,
    curses.KEY_RIGHT: curses.KEY_LEFT,
}

LETTER_KEYS = {
    ord("w"): curses.KEY_UP,
    ord("W"): curses.KEY_UP,
    ord("s"): curses.KEY_DOWN,
    ord("S"): curses.KEY_DOWN,
    ord("a"): curses.KEY_LEFT,
    ord("A"): curses.KEY_LEFT,
    ord("d"): curses.KEY_RIGHT,
    ord("D"): curses.KEY_RIGHT,
}

What do these mappings do?

  • The OPPOSITES dictionary pairs each direction with its forbidden reverse.
  • The LETTER_KEYS dictionary converts lowercase or uppercase WASD input into matching arrow-key constants.
  • These dictionaries keep key handling separate from movement calculations.
  • Save myscript.py in IDLE.
  • Return to Terminal from earlier.
  • Check that the new mappings load successfully by running:
python3.14 myscript.py

What does this check prove?

This command loads the new dictionaries before starting the game loop. A working arena confirms that their names and punctuation are valid Python.

You'll see the bordered arena with the snake moving right. The planned traceback still appears when the snake reaches the border.

Arena not starting?

Check that both dictionaries sit below the closing brace of DIRECTIONS. Confirm that every mapping line ends with a comma.

Ask for help with the mapping syntax.

The game loop currently reads a key without storing it. Capturing that integer lets the loop translate WASD input before checking whether the requested direction is safe.

  • Return to myscript.py in IDLE.
  • Find the stdscr.getch() line directly below draw(stdscr, snake).
  • Replace that line with this input-handling code:
        key = stdscr.getch()

        requested_direction = LETTER_KEYS.get(key, key)
        if (
            requested_direction in DIRECTIONS
            and requested_direction != OPPOSITES[direction]
        ):
            direction = requested_direction

How does safe steering work?

  • The key variable stores the latest keyboard input from the timed read.
  • The LETTER_KEYS.get(key, key) lookup converts WASD input while preserving arrow-key input.
  • The condition accepts supported directions that are different from OPPOSITES[direction].
  • Blocking an immediate reversal prevents the head from turning directly into the cell behind it.
  • Save myscript.py in IDLE.

Before you run the game, picture what happens when you request an immediate reversal.

  • Return to Terminal from earlier.
  • Test the steering logic by running:
python3.14 myscript.py

What does this test run?

This run sends every timed keypress through the new direction checks. The snake keeps moving between keypresses because the existing timeout remains active.

  • Press the down arrow to steer downward.
  • Press a to steer left.
  • Test reversal protection by pressing d while the snake moves left.
  • Let the snake reach the border.

You'll see the snake respond to the valid turns. It ignores the immediate reversal before the existing border traceback ends the run.

Controls not responding?

Confirm that key = stdscr.getch() replaces the old standalone key read. Check that requested_direction uses the same spelling throughout the condition.

Ask for help debugging the steering input.

Check collisions before moving

The snake's coordinate system uses row and column positions in the form (y, x). Calculating the next head first gives the loop a safe point to check the border and the snake's body.

  • Return to myscript.py in IDLE.
  • Find new_head = (head_y + dy, head_x + dx) inside the game loop.
  • Add the collision checks directly below new_head by pasting this code:
        hit_wall = (
            new_head[0] in (0, height - 1)
            or new_head[1] in (0, width - 1)
        )
        hit_self = new_head in snake[:-1]

        if hit_wall or hit_self:
            break

How do these checks prevent the crash?

  • The hit_wall condition checks whether the next row or column touches the border.
  • The hit_self condition checks whether the next head overlaps the snake's body.
  • The snake[:-1] slice excludes the tail because the tail moves away during a normal tick.
  • The break statement stops the loop before the unsafe head is drawn.
  • Save myscript.py in IDLE.

Before you run the game, picture whether wall contact ends with the same traceback.

  • Return to Terminal from earlier.
  • Test the wall check by running:
python3.14 myscript.py

What does this collision test prove?

This run calculates each new head position before changing the snake. The loop can now stop while every coordinate remains inside the arena.

  • Steer the snake into any wall.

You'll return to the Terminal prompt without a traceback. Good work. The loop now stops before curses tries to draw outside the arena.

Still seeing a traceback?

Check that the collision block appears before snake.insert(0, new_head). Confirm that both border comparisons use height - 1 or width - 1.

Ask for help tracing the collision order.

Show a deliberate game over

The loop now exits safely when a collision occurs. A final render preserves the arena while a centered message explains why movement stopped.

The timed input mode must also return to blocking mode. This keeps the message on screen until you choose to dismiss it.

  • Return to myscript.py in IDLE.
  • Find snake.pop() at the end of the game loop.
  • Place the cursor on the blank line below the game loop.
  • Dedent the cursor to the same level as while True:.
  • Add the final game-over render by pasting this code:
    draw(stdscr, snake)
    message = " GAME OVER! Press any key "
    message_x = max(1, (width - len(message)) // 2)
    stdscr.addstr(height // 2, message_x, message[: width - 2], curses.A_BOLD)
    stdscr.refresh()
    stdscr.timeout(-1)
    stdscr.getch()

What does the ending sequence do?

  • The final draw() call keeps the last safe snake position visible.
  • The message_x calculation centers the game-over message across the Terminal width.
  • The curses.A_BOLD attribute makes the message stand out from the arena.
  • The stdscr.timeout(-1) call restores blocking input so the final key read waits indefinitely.
  • Save myscript.py in IDLE.

Before you run the game, picture what Terminal shows after the snake touches a wall.

  • Return to Terminal from earlier.
  • Run the completed collision flow with:
python3.14 myscript.py

What does the final run verify?

This run exercises keyboard input, collision checks, loop termination, and the blocking game-over screen. It proves that border contact now follows a controlled ending sequence.

  • Steer with the arrow keys or WASD.
  • Guide the snake into a wall.

You'll see GAME OVER! Press any key centered inside the arena. The message remains visible without a traceback.

Game-over message not staying visible?

Confirm that the final message block sits outside the while True: loop. Check that stdscr.timeout(-1) appears before the final stdscr.getch() call.

Ask for help checking the final indentation.

  • Press any key to close the game-over screen.

Use the tabs below to confirm that every steering and collision block is in the correct place.

✔️ Awesome, I've got everything!

Your snake responds to arrow keys or WASD. Wall contact now shows a game-over message without a traceback.

ⓧ I'd like to double check the full code

Compare your saved myscript.py with this complete version.

import curses

MIN_HEIGHT = 18
MIN_WIDTH = 50
FRAME_MS = 120

DIRECTIONS = {
    curses.KEY_UP: (-1, 0),
    curses.KEY_DOWN: (1, 0),
    curses.KEY_LEFT: (0, -1),
    curses.KEY_RIGHT: (0, 1),
}

OPPOSITES = {
    curses.KEY_UP: curses.KEY_DOWN,
    curses.KEY_DOWN: curses.KEY_UP,
    curses.KEY_LEFT: curses.KEY_RIGHT,
    curses.KEY_RIGHT: curses.KEY_LEFT,
}

LETTER_KEYS = {
    ord("w"): curses.KEY_UP,
    ord("W"): curses.KEY_UP,
    ord("s"): curses.KEY_DOWN,
    ord("S"): curses.KEY_DOWN,
    ord("a"): curses.KEY_LEFT,
    ord("A"): curses.KEY_LEFT,
    ord("d"): curses.KEY_RIGHT,
    ord("D"): curses.KEY_RIGHT,
}


def draw(stdscr, snake):
    stdscr.erase()
    stdscr.box()

    for index, (y, x) in enumerate(snake):
        stdscr.addch(y, x, "@" if index == 0 else "o")

    stdscr.refresh()


def main(stdscr):
    height, width = stdscr.getmaxyx()

    if height < MIN_HEIGHT or width < MIN_WIDTH:
        message = f"Resize Terminal to at least {MIN_WIDTH}x{MIN_HEIGHT}, then run again."
        stdscr.addstr(0, 0, message[: max(1, width - 1)])
        stdscr.refresh()
        stdscr.getch()
        return

    stdscr.timeout(FRAME_MS)

    center_y = height // 2
    center_x = width // 2
    snake = [
        (center_y, center_x),
        (center_y, center_x - 1),
        (center_y, center_x - 2),
    ]
    direction = curses.KEY_RIGHT

    while True:
        draw(stdscr, snake)
        key = stdscr.getch()

        requested_direction = LETTER_KEYS.get(key, key)
        if (
            requested_direction in DIRECTIONS
            and requested_direction != OPPOSITES[direction]
        ):
            direction = requested_direction

        dy, dx = DIRECTIONS[direction]
        head_y, head_x = snake[0]
        new_head = (head_y + dy, head_x + dx)

        hit_wall = (
            new_head[0] in (0, height - 1)
            or new_head[1] in (0, width - 1)
        )
        hit_self = new_head in snake[:-1]

        if hit_wall or hit_self:
            break

        snake.insert(0, new_head)
        snake.pop()

    draw(stdscr, snake)
    message = " GAME OVER! Press any key "
    message_x = max(1, (width - len(message)) // 2)
    stdscr.addstr(height // 2, message_x, message[: width - 2], curses.A_BOLD)
    stdscr.refresh()
    stdscr.timeout(-1)
    stdscr.getch()


curses.wrapper(main)

How is the full file organized?

The key dictionaries define supported movement before main() starts. The game loop reads input before calculating a candidate head position.

Collision checks guard the state update. The final block renders the controlled game-over screen after the loop ends.

Your snake can now turn safely and end cleanly on contact. Next, you'll add food so the snake can grow while its score climbs.

Add Food, Growth, and Scoring

Your steering rules now keep the snake under control. The curses game loop still has no goal, so every move leads to an eventual collision.

Food gives the player a target. Growth raises the difficulty after each bite.

A visible score records progress. Clean quitting plus win feedback complete the playable loop.

In this step, get ready to:
  • Choose random food positions that never overlap the snake.
  • Grow the snake while tracking the score.
  • Finish the playable loop with quitting plus final feedback.
Place food in open cells

The Python random module chooses a food position from the available coordinates. Filtering those coordinates first keeps every piece of food inside the border and away from the snake.

  • Return to IDLE from the previous step.
  • Find import curses at the top of myscript.py.
  • Add the new import directly below it:
import random

Why import random?

The random module provides random.choice(seq). This function selects one item from a non-empty sequence.

  • Find the closing brace of LETTER_KEYS.
  • Place your cursor on the blank line before draw().
  • Define the food-placement helper by pasting this code:
def random_food(height, width, snake):
    occupied = set(snake)
    free_cells = [
        (y, x)
        for y in range(1, height - 1)
        for x in range(1, width - 1)
        if (y, x) not in occupied
    ]
    return random.choice(free_cells) if free_cells else None

How does food placement work?

  • The occupied set makes snake-coordinate checks direct.
  • The free_cells list contains every coordinate between the four borders.
  • The condition excludes coordinates already occupied by the snake.
  • A return value of None means the snake has filled every available cell.
  • Press Cmd+S to save myscript.py.
  • Switch back to Terminal from the previous step.
  • Run the existing game as a regression check with this command:
python3.14 myscript.py

What does this command do?

This command launches the saved myscript.py file with the verified interpreter. The regression check confirms the new helper has not disturbed movement or collision handling.

  • Steer the snake into a wall to end the regression check.

You'll return to the Terminal prompt after the game-over message. The food helper is now ready to join the live game state.

Game does not start?

Confirm that you saved myscript.py before running the command. Run the game in Terminal because direct keyboard access does not work when the IDLE Shell controls the screen.

Check that import random starts at the left edge of the file. An indented import can trigger a syntax problem.

Help me diagnose the game after adding random food placement.

Grow the snake and track progress

Food becomes part of the mutable game state because its coordinate changes after every bite. The drawing function receives the current food plus score on every frame.

  • Find the existing draw() function in myscript.py.
  • Replace that entire function with this version:
def draw(stdscr, snake, food, score):
    height, width = stdscr.getmaxyx()
    stdscr.erase()
    stdscr.box()

    title = f" SNAKE | Score: {score} | Arrows/WASD move | q quits "
    stdscr.addstr(0, 2, title[: width - 4])

    if food is not None:
        stdscr.addch(food[0], food[1], "*")

    for index, (y, x) in enumerate(snake):
        stdscr.addch(y, x, "@" if index == 0 else "o")

    stdscr.refresh()

What does the new drawing function do?

  • The title displays the current score plus the available controls.
  • The food coordinate receives a visible * character.
  • The width slice prevents the title from extending beyond the right border.
  • The snake still uses @ for its head plus o for its body.
  • Find direction = curses.KEY_RIGHT inside main().
  • Replace the code from that line through the first keyboard read with this block:
    direction = curses.KEY_RIGHT
    food = random_food(height, width, snake)
    score = 0
    won = False

    while True:
        draw(stdscr, snake, food, score)
        key = stdscr.getch()

        if key in (ord("q"), ord("Q")):
            return

How does the new state work?

  • The first call to random_food() creates the initial target.
  • The score starts at zero.
  • The won Boolean records whether the player fills the arena.
  • The keyboard check returns immediately when the player presses lowercase or uppercase q.
  • Find the final call to draw() after the game loop.
  • Replace that call with this updated line:
    draw(stdscr, snake, food, score)

Why update both draw calls?

Every call to draw() must provide the four values in its new function signature. The final call preserves the food plus score when the ending message is painted.

  • Press Cmd+S to save myscript.py.

Before you run this version, where do you expect the food to appear inside the arena?

  • Launch the food-rendering check by running:
python3.14 myscript.py

What does this check prove?

This run connects the food coordinate to the terminal renderer. It also proves the score plus quit control are available during the timed loop.

You'll see a * inside the border plus a score of zero in the title. The food does not cover any snake cell.

  • Press q to return cleanly to the Terminal prompt.

Good progress. Your arena now has a visible objective plus a live score display.

Food or score missing?

Check that both calls to draw() pass food plus score. Confirm that food = random_food(height, width, snake) appears before the loop.

Help me fix missing food or score rendering.

The current snake still removes its tail on every tick. Growth requires the loop to preserve that tail whenever the new head reaches food.

  • Find the collision block that begins with hit_wall.
  • Replace the code from that assignment through snake.pop() with this logic:
        hit_wall = (
            new_head[0] in (0, height - 1)
            or new_head[1] in (0, width - 1)
        )
        will_grow = new_head == food
        body_to_check = snake if will_grow else snake[:-1]
        hit_self = new_head in body_to_check

        if hit_wall or hit_self:
            break

        snake.insert(0, new_head)

        if will_grow:
            score += 1
            food = random_food(height, width, snake)
            if food is None:
                won = True
                break
        else:
            snake.pop()

How does growth stay safe?

  • The will_grow Boolean becomes true when the new head reaches food.
  • The body_to_check value includes the tail during growth because that tail stays in place.
  • A normal move removes the tail after inserting the new head.
  • A bite keeps the tail plus increments the score before selecting replacement food.
  • Press Cmd+S to save myscript.py.
  • Switch back to Terminal.

Before you test a bite, what should happen to the tail when the head reaches the food?

  • Start the growth test by running:
python3.14 myscript.py

What does this run test?

This run exercises the updated movement branch. Reaching food selects the growth path while every ordinary tick continues to remove the tail.

  • Steer the snake's head onto the * character.
  • Press q after the score changes.

You'll see the score increase by one after the bite. The snake keeps its previous tail cell, so its body becomes one cell longer.

Snake does not grow?

Confirm that snake.pop() sits only inside the else branch. Check that the growth branch increments score before creating replacement food.

Help me debug food collection without losing my game state.

Verify the complete game

The final message must distinguish a filled arena from a collision. The won state chooses the correct ending without changing the collision flow.

  • Find the message assignment near the bottom of main().
  • Replace that assignment with this conditional message:
    message = " YOU WIN! Press any key " if won else " GAME OVER! Press any key "

How is the ending selected?

A full arena sets won to true before the loop ends. Every wall or self-collision leaves it false, so the game-over message remains the normal ending.

  • Press Cmd+S to save myscript.py.
  • Switch back to Terminal.

Before the final run, what changes should one bite make to the snake plus the score?

  • Launch the complete game by running:
python3.14 myscript.py

What does the final run verify?

This run exercises food placement, growth, scoring, steering, collision handling, plus clean quitting in one continuous loop. Each frame reads input before updating the game state.

  • Steer the snake onto one * character.
  • Observe the updated score in the top border.
  • Count the snake cells after the bite.
  • Press q to exit the game cleanly.

You'll see the score increase immediately after eating food. The snake grows by one cell before q returns you to the Terminal prompt.

That's the complete game loop working. Your Snake game now has an objective, escalating difficulty, visible progress, plus deliberate endings.

Final game check failing?

Confirm that every call to draw() supplies stdscr, snake, food, plus score. Check that the quit condition appears immediately after the keyboard read.

If collision produces an exception, compare the indentation from hit_wall through the growth branch with the full reference below.

Help me diagnose the final Snake game behavior.

Use the reference below to compare your finished file before moving on.

✔️ Awesome, I've got everything!

Great. Double check that myscript.py is saved before you finish this step.

ⓧ I'd like to double check the full code

Your complete myscript.py file should match this reference exactly.

import curses
import random

MIN_HEIGHT = 18
MIN_WIDTH = 50
FRAME_MS = 120

DIRECTIONS = {
    curses.KEY_UP: (-1, 0),
    curses.KEY_DOWN: (1, 0),
    curses.KEY_LEFT: (0, -1),
    curses.KEY_RIGHT: (0, 1),
}

OPPOSITES = {
    curses.KEY_UP: curses.KEY_DOWN,
    curses.KEY_DOWN: curses.KEY_UP,
    curses.KEY_LEFT: curses.KEY_RIGHT,
    curses.KEY_RIGHT: curses.KEY_LEFT,
}

LETTER_KEYS = {
    ord("w"): curses.KEY_UP,
    ord("W"): curses.KEY_UP,
    ord("s"): curses.KEY_DOWN,
    ord("S"): curses.KEY_DOWN,
    ord("a"): curses.KEY_LEFT,
    ord("A"): curses.KEY_LEFT,
    ord("d"): curses.KEY_RIGHT,
    ord("D"): curses.KEY_RIGHT,
}


def random_food(height, width, snake):
    occupied = set(snake)
    free_cells = [
        (y, x)
        for y in range(1, height - 1)
        for x in range(1, width - 1)
        if (y, x) not in occupied
    ]
    return random.choice(free_cells) if free_cells else None


def draw(stdscr, snake, food, score):
    height, width = stdscr.getmaxyx()
    stdscr.erase()
    stdscr.box()

    title = f" SNAKE | Score: {score} | Arrows/WASD move | q quits "
    stdscr.addstr(0, 2, title[: width - 4])

    if food is not None:
        stdscr.addch(food[0], food[1], "*")

    for index, (y, x) in enumerate(snake):
        stdscr.addch(y, x, "@" if index == 0 else "o")

    stdscr.refresh()


def main(stdscr):
    height, width = stdscr.getmaxyx()

    if height < MIN_HEIGHT or width < MIN_WIDTH:
        message = f"Resize Terminal to at least {MIN_WIDTH}x{MIN_HEIGHT}, then run again."
        stdscr.addstr(0, 0, message[: max(1, width - 1)])
        stdscr.refresh()
        stdscr.getch()
        return

    stdscr.timeout(FRAME_MS)

    center_y = height // 2
    center_x = width // 2
    snake = [
        (center_y, center_x),
        (center_y, center_x - 1),
        (center_y, center_x - 2),
    ]
    direction = curses.KEY_RIGHT
    food = random_food(height, width, snake)
    score = 0
    won = False

    while True:
        draw(stdscr, snake, food, score)
        key = stdscr.getch()

        if key in (ord("q"), ord("Q")):
            return

        requested_direction = LETTER_KEYS.get(key, key)
        if (
            requested_direction in DIRECTIONS
            and requested_direction != OPPOSITES[direction]
        ):
            direction = requested_direction

        dy, dx = DIRECTIONS[direction]
        head_y, head_x = snake[0]
        new_head = (head_y + dy, head_x + dx)

        hit_wall = (
            new_head[0] in (0, height - 1)
            or new_head[1] in (0, width - 1)
        )
        will_grow = new_head == food
        body_to_check = snake if will_grow else snake[:-1]
        hit_self = new_head in body_to_check

        if hit_wall or hit_self:
            break

        snake.insert(0, new_head)

        if will_grow:
            score += 1
            food = random_food(height, width, snake)
            if food is None:
                won = True
                break
        else:
            snake.pop()

    draw(stdscr, snake, food, score)
    message = " YOU WIN! Press any key " if won else " GAME OVER! Press any key "
    message_x = max(1, (width - len(message)) // 2)
    stdscr.addstr(height // 2, message_x, message[: width - 2], curses.A_BOLD)
    stdscr.refresh()
    stdscr.timeout(-1)
    stdscr.getch()


curses.wrapper(main)

How to compare the file

Check the order of the helper functions before comparing the loop. Indentation determines which statements belong to the collision branch plus the growth branch.

Secret mission

Add Pause and Resume

Your Snake game moves continuously until you quit or collide. Add a pause state that freezes the snake while the loop keeps listening for input. The title will show whether the game is running or paused.

Clean Up Your Resources

Clean Up Your Resources

Everything you built stays on your macOS computer. The project uses no cloud resources, so there are no ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The playable Snake source code in myscript.py, saved through IDLE.

Keep everything running

No action is needed. Choose this if you want to keep playing or experimenting with the Snake game.

  • Leave myscript.py in its current location.
  • Keep IDLE open if you plan to edit the game again soon.
  • Keep Python 3.14 available for future projects.

Pause - I'll come back to this later

Stop any current game session to free up your Terminal. Keep the source file so you can continue later.

  • Return to the Terminal from earlier if Snake is running.
  • Press q to quit the running game cleanly.
  • Save myscript.py in IDLE.
  • Close the IDLE window from earlier.

Delete - I don't want to use this again

Deleting the source file is permanent, so you will confirm its folder before removing it. Python remains installed for anything you build next.

  • Return to the Terminal from earlier if Snake is running.
  • Press q to quit the running game cleanly.
  • Switch back to myscript.py in IDLE.
  • Save myscript.py.
  • Select File from the menu bar.
  • Select Save As… to identify the folder containing the file.
  • Record the folder shown in the save window here: folder containing myscript.py.
  • Close the save window without saving another copy.
  • Close IDLE before deleting the source file.
  • Delete myscript.py and list the remaining files by running this command:
cd "[[SCRIPT_DIRECTORY="folder containing myscript.py"]]" && rm myscript.py && ls

What does this command do?

This command moves Terminal to the folder you recorded. It removes myscript.py only after that move succeeds.

The final file listing lets you confirm which files remain in that folder.

You should see the folder's remaining files. myscript.py should be absent.

You have cleared the Snake project without removing Python from your Mac.

Can't Remove the File?

Check that folder containing myscript.py matches the folder shown by IDLE. Confirm that the filename is exactly myscript.py.

Help me check the deletion safely.

Nice Work!

Nice Work!

Brilliant work! Your full-screen Snake game now runs inside Terminal. You built its real-time loop with Python's curses module.

You've learned how to:

  • Build a bordered full-screen arena around a three-cell snake. A timed real-time game loop redraws the game continuously.
  • Handle non-blocking keyboard input through arrow-key plus WASD controls. Direction rules prevent an instant reversal. Collision detection turns wall or self-contact into a clear game-over state.
  • Create random food placement that avoids occupied cells. Eating food grows the snake by one cell. The score updates immediately. Pressing q exits cleanly. Filling the board displays a win message.
  • Secret Mission: Extend the loop with a visible pause and resume state. Pressing p freezes movement while q stays active. Pressing p again resumes the snake from the same position.

Ready to quiz yourself?