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
| Test input or action | Expected result |
|---|---|
| Normal width | Labels aligned; input fields visible |
| Wider window | Entry column grows |
| Tab navigation | Focus moves between controls in a useful order |
| Close | Window 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.
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
| Test input or action | Expected result |
|---|---|
| Before click | Ready |
| Click Complete | Task complete |
| Reference check | status 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.
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
| Test input or action | Expected result |
|---|---|
| Maya, button | Personalised greeting |
| Maya, Return key | Same greeting |
| Only spaces | Helpful rejection |
| Repeated click | Stable 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.
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
| Test input or action | Expected result |
|---|---|
| Schedule | Window stays responsive while waiting |
| Reschedule before finish | Only the latest intended job remains |
| Cancel before finish | No later finish message from that job |
| Close while pending | Job 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.
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
| Test input or action | Expected result |
|---|---|
| Right for ordinary updates | Moves right at the intended update speed |
| Both directions held | No horizontal movement |
| Left/right edge | Remains inside the screen |
| Escape or close | Clean 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.
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
| Test input or action | Expected result |
|---|---|
| Tap Space | Colour changes once |
| Hold without additional key events | No per-frame colour flipping |
| Arrow held | Continuous movement still works |
| Escape | Closes 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.
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
| Test input or action | Expected result |
|---|---|
| One caught star | Score rises by 10 |
| Same location next frame | No second score from that removed star |
| Two distinct simultaneous hits | 20 points if both are caught |
| All three leave or are collected | Group 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.
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
| Test input or action | Expected result |
|---|---|
| Exact edge contact | False |
| One-pixel intersection | True |
| Two detection passes | Only 10 total points |
| Remaining group size | Zero |
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.
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
| Test input or action | Expected result |
|---|---|
| New store instance | Accepted row is still present |
| Complete | Absent from open, present in completed |
| Reopen | Present in open again |
| Manual relaunch | Real 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.
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
| Test input or action | Expected result |
|---|---|
| Clock 130 from start 100 | 30 seconds remain |
| Start 121 minutes | ValueError; earlier deadline preserved |
| Clock at 160 | Timer finished |
| Cancel after restart | Zero 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.
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
| Test input or action | Expected result |
|---|---|
| Injected star | 10 points once; removed |
| Paused update | Time and player position unchanged |
| Restart | No earlier items, score, damage or spawn time |
| Manual game | Controls 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.
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
| Test input or action | Expected result |
|---|---|
| Two simultaneous unprotected rocks | One life lost; both consumed |
| After protection expiry | A new rock costs another life |
| Final allowed hit | Zero lives and game-over state |
| Partner playtest | Can 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.
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