LEARN PYTHON | 08 DESKTOP APPS AND GAMES

Build it. Test it. Explain it.

Build responsive interfaces and playable games, then test the state behind the screen. Work independently before opening a hint, then compare your tested solution with one possible approach.

YOUR CHALLENGE ROUTE

Complete these tasks in your Python editor. The page is a challenge guide, not a general Python runner. Use fictional details where helpful. Work in order or choose a task matching an area you need to practise.

Tkinter samples require installed Tk support and a desktop display. Pygame samples require the package in your chosen environment. The two capstone models come from their companion downloads. The page does not execute Python; run the files in your editor and use fictional study records.

TRACK YOUR TESTED SOLUTIONS

Tick a task only after you have run it, checked at least two relevant cases and explained one line. These are self-recorded checks, not an automatic assessment of your source code.

Progress lasts while this page is open. Save your actual Python files in your project folder.

01 | TKINTER LAYOUT

BUILD A RESIZABLE STUDY FORM

Build a two-field desktop form and prove it remains usable when the window width changes.

YOUR REQUIREMENTS

  • Create one Tk root and one form frame.
  • Give each Entry a clear label.
  • Use grid for the form and a growing input column.
  • Keep a close button.
  • Explain the parent and layout of each widget.
  • Resize and check keyboard Tab order.
Hint: where to begin

Extra width has to reach the frame and its input column before sticky ew can stretch the Entry.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Normal widthLabels aligned; input fields visible
Wider windowEntry column grows
Tab navigationFocus moves between controls in a useful order
CloseWindow and event loop finish
Reveal one possible solution and explanation
import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("Study task form")
root.columnconfigure(0, weight=1)
frame = ttk.Frame(root, padding=16)
frame.grid(row=0, column=0, sticky="nsew")
frame.columnconfigure(1, weight=1)
subject = tk.StringVar(root)
task = tk.StringVar(root)
ttk.Label(frame, text="Subject:").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Entry(frame, textvariable=subject).grid(row=0, column=1, sticky="ew", pady=6)
ttk.Label(frame, text="Task:").grid(row=1, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Entry(frame, textvariable=task).grid(row=1, column=1, sticky="ew", pady=6)
ttk.Button(frame, text="Close", command=root.destroy).grid(row=2, column=1, sticky="e", pady=10)
root.mainloop()

The geometry rules make the form adapt without manually moving every control. This prototype displays entries but does not claim to save them.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add a labelled minutes field and adjust the button row; test long labels and narrower windows.

Revisit the related lesson

02 | TKINTER REPAIR

REPAIR A LOST WIDGET REFERENCE

A chained layout assignment makes a widget variable None. Retain the object and update its text through a callback.

YOUR REQUIREMENTS

  • Create the Label separately from its layout call.
  • Store the widget reference as status.
  • Pass a callback, not its immediate result.
  • Change the label on a button click.
  • Explain the original None value.

SOURCE TO REPAIR

status = ttk.Label(root, text="Ready").grid(row=0, column=0)
status.configure(text="Task complete")
Hint: where to begin

grid returns a layout-operation result, not the Label you want to configure.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Before clickReady
Click CompleteTask complete
Reference checkstatus is the Label, not None
Reveal one possible solution and explanation
import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("Keep the widget reference")
status = ttk.Label(root, text="Ready")
status.grid(row=0, column=0, padx=16, pady=12)

def finish():
    status.configure(text="Task complete")

ttk.Button(root, text="Complete", command=finish).grid(row=1, column=0, pady=12)
root.mainloop()

Separating construction from placement retains the object. The callback is registered now and invoked later, so the message does not change during setup.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add a Reset button and prove the same label can be updated repeatedly without creating new widgets.

Revisit the related lesson

03 | TKINTER EVENTS

USE ENTER AND CLICK WITH ONE VALIDATED ACTION

Create a greeting form whose button and Return key call the same checked action.

YOUR REQUIREMENTS

  • Use a StringVar for entered text.
  • Trim outer whitespace.
  • Reject a blank value with helpful feedback.
  • Use event=None in the common handler.
  • Keep accepted behaviour identical for keyboard and button.
Hint: where to begin

bind supplies an event argument; a button command does not. The optional parameter lets both use one function.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Maya, buttonPersonalised greeting
Maya, Return keySame greeting
Only spacesHelpful rejection
Repeated clickStable message, no extra windows
Reveal one possible solution and explanation
import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("A helpful greeting")
name = tk.StringVar(root)
message = tk.StringVar(root, value="Enter a first name.")

def greet(event=None):
    cleaned = name.get().strip()
    if not cleaned:
        message.set("Please enter a non-blank name.")
        return
    message.set(f"Hello, {cleaned}. What will you build today?")

frame = ttk.Frame(root, padding=16)
frame.pack(fill="both", expand=True)
entry = ttk.Entry(frame, textvariable=name)
entry.pack(pady=8)
entry.bind("<Return>", greet)
ttk.Button(frame, text="Greet", command=greet).pack(pady=8)
ttk.Label(frame, textvariable=message).pack(pady=8)
entry.focus_set()
root.mainloop()

The handler reads and checks the value before rendering. Pressing Enter is another route to the same rule, not a second implementation to keep in sync.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Convert the idea into a minutes form that requires a whole number from 1 to 120 and preserves earlier accepted state on rejection.

Revisit the related lesson

04 | TKINTER RESPONSIVENESS

SCHEDULE ONE CANCELABLE JOB

Create a delayed message that supports replacement, cancellation and clean window closing.

YOUR REQUIREMENTS

  • Schedule with after, not time.sleep.
  • Retain the job identifier.
  • Cancel an earlier pending job before replacement.
  • Provide a Cancel button.
  • Set pending back to None after finishing.
  • Cancel pending work before destroying the root.
Hint: where to begin

after schedules a short callback on the same loop. Cancelling uses its returned identifier.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
ScheduleWindow stays responsive while waiting
Reschedule before finishOnly the latest intended job remains
Cancel before finishNo later finish message from that job
Close while pendingJob cancelled before widget destruction
Reveal one possible solution and explanation
import tkinter as tk
from tkinter import ttk

root = tk.Tk()
root.title("Responsive scheduled work")
message = tk.StringVar(root, value="Ready")
pending = None

def finish():
    global pending
    pending = None
    message.set("Finished the scheduled step")

def schedule():
    global pending
    if pending is not None:
        root.after_cancel(pending)
    message.set("Scheduled; the window is still responsive")
    pending = root.after(1500, finish)

def cancel():
    global pending
    if pending is not None:
        root.after_cancel(pending)
        pending = None
    message.set("Cancelled")

def close():
    if pending is not None:
        root.after_cancel(pending)
    root.destroy()

ttk.Label(root, textvariable=message).pack(padx=16, pady=12)
ttk.Button(root, text="Schedule / reschedule", command=schedule).pack(pady=12)
ttk.Button(root, text="Cancel", command=cancel).pack(pady=8)
root.protocol("WM_DELETE_WINDOW", close)
root.mainloop()

The job handle describes pending work. It is distinct from the callback function and from the app’s task IDs. Late dispatch is possible, so this is not a precision timer.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Replace the message delay with a monotonic deadline model and a short recurring display refresh.

Revisit the related lesson

05 | PYGAME MOVEMENT

MOVE BY SECONDS, NOT FRAME COUNTS

Build the moving-rectangle program and explain every part of its timing and boundary policy.

YOUR REQUIREMENTS

  • Process QUIT and Escape.
  • Use held keys for continuous movement.
  • Keep float position.
  • Calculate displacement from speed times seconds.
  • Clamp the player inside the screen.
  • Draw and present after updating.
Hint: where to begin

tick returns milliseconds. Divide by 1000 once, and explain the deliberate maximum update duration.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Right for ordinary updatesMoves right at the intended update speed
Both directions heldNo horizontal movement
Left/right edgeRemains inside the screen
Escape or closeClean exit
Reveal one possible solution and explanation
import pygame

pygame.init()
try:
    screen = pygame.display.set_mode((640, 480))
    pygame.display.set_caption("Move the rectangle")
    clock = pygame.time.Clock()
    x, y = 300.0, 220.0
    speed = 240.0
    running = True
    while running:
        dt = min(clock.tick(60) / 1000.0, 0.05)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
                running = False
        if not running:
            break
        keys = pygame.key.get_pressed()
        direction = int(keys[pygame.K_RIGHT]) - int(keys[pygame.K_LEFT])
        x = max(0.0, min(600.0, x + direction * speed * dt))
        screen.fill((18, 28, 38))
        pygame.draw.rect(screen, (36, 152, 143), (round(x), round(y), 40, 40))
        pygame.display.flip()
finally:
    pygame.quit()

The clamp uses screen width minus player width. The bounded dt reduces jumps but can make simulation run slowly after a stall; it is not an exact wall-clock guarantee.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add up/down movement with a separate float y and clamp. Explain diagonal speed before normalising it.

Revisit the related lesson

06 | PYGAME INPUT

MAKE A ONE-PRESS COLOUR ACTION

Add a Space action that changes the player colour once per press while arrow keys still control continuous movement.

YOUR REQUIREMENTS

  • Keep arrow movement through get_pressed.
  • Handle Space through KEYDOWN.
  • Keep colour choice in model state.
  • Do not toggle colour once each frame while Space is held.
  • Draw from the current colour.
Hint: where to begin

One-shot actions and held movement need different input policies.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Tap SpaceColour changes once
Hold without additional key eventsNo per-frame colour flipping
Arrow heldContinuous movement still works
EscapeCloses normally
Reveal one possible solution and explanation
import pygame

pygame.init()
try:
    screen = pygame.display.set_mode((640, 480))
    pygame.display.set_caption("Move the rectangle")
    clock = pygame.time.Clock()
    x, y = 300.0, 220.0
    speed = 240.0
    colour_choice = False
    running = True
    while running:
        dt = min(clock.tick(60) / 1000.0, 0.05)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    running = False
                elif event.key == pygame.K_SPACE:
                    colour_choice = not colour_choice
        if not running:
            break
        keys = pygame.key.get_pressed()
        direction = int(keys[pygame.K_RIGHT]) - int(keys[pygame.K_LEFT])
        x = max(0.0, min(600.0, x + direction * speed * dt))
        screen.fill((18, 28, 38))
        colour = (236, 112, 162) if colour_choice else (36, 152, 143)
        pygame.draw.rect(screen, colour, (round(x), round(y), 40, 40))
        pygame.display.flip()
finally:
    pygame.quit()

A KEYDOWN occurrence controls the transition. This simple example assumes ordinary key-repeat settings; deliberately enabling repeat would require an explicit repeated-event policy.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Apply the same distinction to pause: continue events and rendering while skipping movement updates.

Revisit the related lesson

07 | PYGAME SPRITES

COLLECT DISTINCT SPRITES ONCE

Build the three-star prototype and prove that each collectible can award points only once.

YOUR REQUIREMENTS

  • Subclass Sprite and initialise its base.
  • Supply image and rect.
  • Keep float positions in moving sprites.
  • Update and draw groups.
  • Consume collision hits with dokill=True.
  • Remove off-screen stars.
Hint: where to begin

The score changes in the collision update, not when its text is drawn.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
One caught starScore rises by 10
Same location next frameNo second score from that removed star
Two distinct simultaneous hits20 points if both are caught
All three leave or are collectedGroup eventually empty; prototype remains open
Reveal one possible solution and explanation
import pygame

pygame.init()
try:
    screen = pygame.display.set_mode((640, 480))
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 28)

    class FallingStar(pygame.sprite.Sprite):
        def __init__(self, x, y):
            super().__init__()
            self.image = pygame.Surface((24, 24), pygame.SRCALPHA)
            pygame.draw.circle(self.image, (255, 217, 94), (12, 12), 12)
            self.rect = self.image.get_rect(topleft=(x, y))
            self.y = float(y)

        def update(self, dt):
            self.y += 140.0 * dt
            self.rect.y = round(self.y)
            if self.rect.top >= 480:
                self.kill()

    class Player(pygame.sprite.Sprite):
        def __init__(self):
            super().__init__()
            self.image = pygame.Surface((60, 24))
            self.image.fill((36, 152, 143))
            self.rect = self.image.get_rect(midbottom=(320, 450))
            self.x = float(self.rect.x)

        def update(self, dt, direction):
            self.x = max(0.0, min(580.0, self.x + direction * 300.0 * dt))
            self.rect.x = round(self.x)

    player = Player()
    stars = pygame.sprite.Group(FallingStar(100, 0), FallingStar(300, 40), FallingStar(500, 80))
    players = pygame.sprite.GroupSingle(player)
    score = 0
    running = True
    while running:
        dt = min(clock.tick(60) / 1000.0, 0.05)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
                running = False
        if not running:
            break
        keys = pygame.key.get_pressed()
        direction = int(keys[pygame.K_RIGHT]) - int(keys[pygame.K_LEFT])
        player.update(dt, direction)
        stars.update(dt)
        hits = pygame.sprite.spritecollide(player, stars, dokill=True)
        score += 10 * len(hits)
        screen.fill((18, 28, 38))
        stars.draw(screen)
        players.draw(screen)
        screen.blit(font.render(f"Score: {score}", True, (255, 255, 255)), (16, 16))
        pygame.display.flip()
finally:
    pygame.quit()

The limited prototype has one initial wave. It demonstrates groups and one-time collection without claiming restart or an endless spawning system.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Add a bounded spawn accumulator and a clean restart, then state which objects and counters must reset.

Revisit the related lesson

08 | COLLISION RULES

TEST HITBOX EDGES AND CONSUMPTION

Use Pygame rectangles and a controlled Group encounter to test boundary contact and one-time scoring without opening a window.

YOUR REQUIREMENTS

  • Compare touching boxes with one-pixel overlap.
  • Create a stationary player and one overlapping collectible.
  • Collect using spritecollide with dokill=True.
  • Repeat detection after collection.
  • Explain the difference between detection and an outcome.
Hint: where to begin

Surface and Rect checks can be exercised without setting a display mode. The default test is rectangular.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Exact edge contactFalse
One-pixel intersectionTrue
Two detection passesOnly 10 total points
Remaining group sizeZero
Reveal one possible solution and explanation
import pygame

player = pygame.sprite.Sprite()
player.image = pygame.Surface((48, 24))
player.rect = pygame.Rect(100, 400, 48, 24)
print("Touching:", player.rect.colliderect(pygame.Rect(148, 400, 24, 24)))
print("Overlap:", player.rect.colliderect(pygame.Rect(147, 400, 24, 24)))
star = pygame.sprite.Sprite()
star.image = pygame.Surface((24, 24))
star.rect = pygame.Rect(147, 400, 24, 24)
stars = pygame.sprite.Group(star)
score = 10 * len(pygame.sprite.spritecollide(player, stars, dokill=True))
score += 10 * len(pygame.sprite.spritecollide(player, stars, dokill=True))
print("Score:", score)
print("Remaining:", len(stars))

A transparent image does not automatically change the collision shape. kill changes memberships; it does not prove the Python object has been destroyed.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Compare a circle-based or mask-based test with the bounding box and explain which gameplay policy you want.

Revisit the related lesson

09 | DESKTOP CAPSTONE

SAVE AND REOPEN A STUDY TASK

Run a temporary persistence check beside the planner’s model.py, then verify the same story in its GUI.

YOUR REQUIREMENTS

  • Use a temporary database for the automated check.
  • Create a valid task.
  • Open a new store for the same file.
  • Complete it and compare filters.
  • Reopen it.
  • Run the GUI, close it and relaunch to check saved data.
Hint: where to begin

The model should be tested directly rather than by parsing widget labels. This sample needs model.py from the planner download.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
New store instanceAccepted row is still present
CompleteAbsent from open, present in completed
ReopenPresent in open again
Manual relaunchReal study.sqlite3 retains committed tasks
Reveal one possible solution and explanation
from pathlib import Path
from tempfile import TemporaryDirectory
from model import StudyStore

with TemporaryDirectory() as folder:
    path = Path(folder) / "test.sqlite3"
    first = StudyStore(path)
    task_id = first.add("Computer Science", "Trace loops", "2026-10-15", 25)
    second = StudyStore(path)
    assert second.list()[0]["id"] == task_id
    second.set_done(task_id, True)
    assert second.list("open") == []
    assert len(second.list("completed")) == 1
    second.set_done(task_id, False)
    assert len(second.list("open")) == 1
print("Saved task and filters checked")

The temporary directory disappears after the exercise; this does not imply the actual app deletes its database. Stable IDs connect UI selection to records.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Check an impossible date and prove rejection does not add a partial task or clear an earlier record.

Revisit the related lesson

10 | CLOCK AND STATE

TEST A TIMER WITHOUT WAITING

Test deadline behaviour, cancellation and rejected starts using an injected clock beside the planner’s model.py.

YOUR REQUIREMENTS

  • Supply a controllable numeric clock.
  • Start a one-minute focus session.
  • Advance thirty and sixty seconds without sleeping.
  • Reject an out-of-range replacement without losing the earlier deadline.
  • Cancel and verify zero remaining.
Hint: where to begin

Callback count is not elapsed time. The clock supplies the model’s evidence.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Clock 130 from start 10030 seconds remain
Start 121 minutesValueError; earlier deadline preserved
Clock at 160Timer finished
Cancel after restartZero remaining
Reveal one possible solution and explanation
from model import FocusTimer

now = [100.0]
timer = FocusTimer(lambda: now[0])
timer.start(1)
now[0] = 130.0
assert timer.remaining() == 30
before = timer.deadline
try:
    timer.start(121)
except ValueError:
    assert timer.deadline == before
else:
    raise AssertionError("Bad duration was accepted")
now[0] = 160.0
assert timer.remaining() == 0
timer.start(2)
timer.cancel()
assert timer.remaining() == 0
print("Deadline, rejection and cancellation checked")

The test checks time as state without opening a GUI or waiting. Native responsiveness and cancellation on closing still need interface checks.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Test a fractional instant before expiry and explain why the display rounds upward.

Revisit the related lesson

11 | ARCADE CAPSTONE

PROVE PAUSE AND CLEAN RESTART

Check Star Runner’s model independently of rendering, then run its Pygame front end to verify the controls.

YOUR REQUIREMENTS

  • Use a seeded model for repeatability.
  • Inject one stationary overlapping star.
  • Verify one score award and removal.
  • Pause and attempt an update.
  • Restart and inspect all important round fields.
  • Check Enter, P and Escape in the real game.
Hint: where to begin

The direct check needs game_model.py from the arcade download. A zero-duration encounter isolates the collision rule.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Injected star10 points once; removed
Paused updateTime and player position unchanged
RestartNo earlier items, score, damage or spawn time
Manual gameControls remain usable across modes
Reveal one possible solution and explanation
from game_model import Game, Falling

game = Game(seed=7)
game.start()
game.items.append(Falling(game.player_x, game.player_y, "star", 0))
game.step(0, 0)
assert game.score == 10 and game.items == []
game.toggle_pause()
before = (game.elapsed, game.player_x)
game.step(0.02, 1)
assert (game.elapsed, game.player_x) == before
game.start()
assert game.state == "playing"
assert game.score == 0 and game.lives == 3
assert game.items == [] and game.elapsed == 0
assert game.spawn_elapsed == 0 and game.invulnerable == 0
print("Collection, pause and fresh round checked")

Restart resets the whole round-owned model rather than only the displayed score. The model’s random generator is not automatically reseeded by start.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Assert that a completed round does not advance until a deliberate restart action.

Revisit the related lesson

12 | QUALITY AND PLAYTEST

TEST DAMAGE AND HAND OVER A FINISHED GAME

Check the damage protection rule and provide a partner with a playable, documented project.

YOUR REQUIREMENTS

  • Inject two simultaneous rocks.
  • Expect only one life lost.
  • Advance beyond protection, then inject another hit.
  • End at zero lives and verify over state.
  • Run all 13 game tests.
  • Give install commands, controls and explicit timing/score-lifetime limitations.
Hint: where to begin

Each overlapping rock is consumed. Protection gates damage, not geometric overlap.

TEST YOUR PROGRAM

Check predictions against actual results
Test input or actionExpected result
Two simultaneous unprotected rocksOne life lost; both consumed
After protection expiryA new rock costs another life
Final allowed hitZero lives and game-over state
Partner playtestCan start, move, pause, restart and quit without coaching
Reveal one possible solution and explanation
from game_model import Game, Falling

game = Game(seed=7)
game.start()
def rock():
    game.items.append(Falling(game.player_x, game.player_y, "rock", 0))

rock()
rock()
game.step(0, 0)
assert game.lives == 2 and game.items == []
for _ in range(17):
    game.step(0.05, 0)
rock()
game.step(0, 0)
assert game.lives == 1
game.invulnerable = 0
rock()
game.step(0, 0)
assert game.lives == 0 and game.state == "over"
print("Damage, protection and ending checked")

The direct final protection assignment is a controlled test setup, not a suggested player action. A test suite supports selected rules; a partner playtest checks clarity and fairness.

A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.

Extension: Choose one improvement from the playtest, define tests first and commit the working version before changing it.

Revisit the related lesson

KEEP A SMALL TEST RECORD

Record starting state, user action or update duration, expected outcome, actual outcome and whether accepted state changed. Separate automated core tests from native interface and playtest evidence.

Include blank input, exact boundaries, repeated actions, closing, paused updates and restart. For persistent tasks, test a new process; for game scores and timers, state explicitly that they are session-only.

READY FOR THE NEXT SECTION?

You are ready to move on when you can lay out a usable interface, write responsive callbacks, explain input/update/render, handle collisions once, persist study records and reset a complete game round.

If one skill is uncertain, revisit its lesson and change a worked example before repeating the challenge. The Section 8 workbook is coming soon.

Next: Flask setup, routes and responses Python course navigation