Build a GUI Mystery Game

Build a Tkinter mystery game with clickable clues, dialogs, and case resets.

Introduction

30 Second Summary

A mystery loses its tension when every choice feels like another line of text. Each clue feels more immediate when the case responds the moment you investigate.

In this project, you will build a playable desktop mystery game called The Locked Study. Using Python with Tkinter, you will connect each button click to an immediate game response.

What You'll Build

Picture showing someone a detective game where each investigation updates the evidence notebook before the final accusation reveals whether they solved the case.

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

  • A set of investigation buttons that reveal a different clue for the Study, Hall, or Conservatory.
  • An evidence notebook that records each discovered clue once. Repeated searches leave the evidence intact.
  • A complete case loop that blocks early accusations. The game reports a win or loss in a dialog. A New Case reset prepares the mystery for another playthrough.
  • Secret Mission: Replace the fixed criminal with a randomly selected culprit plus a matching set of clues.

Are there any prerequisites?

You only need a Mac with permission to install software. The guide covers every required component inside Visual Studio Code.

Before We Start

Before any hands-on work, lock in the desktop mystery game you are about to build and why its button-driven investigation makes the case engaging.

Set Up Python and Tkinter

A desktop mystery depends on a working GUI runtime before any case window can appear. Python runs the game logic while Tkinter turns callbacks into visible buttons and windows.

Visual Studio Code must point to the same interpreter that provides Tkinter. A mismatched interpreter can leave the editor connected to a Python installation that cannot open the game.

In this step, get ready to:
  • Confirm that Python 3.15.2 can run on your Mac.
  • Install the Python extension in Visual Studio Code.
  • Create a .venv that opens the Tkinter demonstration window.
Check your Python version

The version check reveals which Python installation your Mac currently finds. This matters because the project uses Python 3.15.2 with the Tk runtime included by the official installer.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Enter to open it.
  • Check the installed Python version by running this command:
python3 --version

What does this command do?

The --version option prints the version used by the python3 command. It exits without starting an interactive Python session.

Choose the tab that matches the result in your terminal.

✔️ I see the required version

You should see Python 3.15.2. Your Mac already has the interpreter required for the game.

That is one dependency settled. Continue below to connect this interpreter to your project workspace.

ⓧ I see an older version

Your existing Python installation belongs to a different release. Install Python 3.15.2 from the official macOS release page.

  • Open the Python macOS releases page in your browser.
  • Download the macOS installer for Python 3.15.2 from its release entry.
  • Double-click the downloaded .pkg file in your Downloads folder.

The standard macOS Installer opens with the Python installation steps.

macOS requests administrator approval because Python is installed for every user on the Mac. The password stays inside the macOS installer.

  • Click Continue on the first installer screen.
  • Click Continue after reading the Read Me.
  • Click Continue after reviewing the license.
  • Click Agree to accept the license terms.
  • Click Install on the Installation Type screen.
  • Enter your Mac administrator password when macOS requests approval.
  • Wait for the Summary screen to confirm that installation is complete.

Why install certificates?

Python uses trusted root certificates when it makes secure network connections. The official installer includes a command that prepares those certificates for this Python installation.

  • Click Finder in the Dock.
  • Select Applications in the Finder sidebar.
  • Open the Python 3.15 folder.
  • Double-click Install Certificates.command.

A temporary terminal window completes the certificate setup. You should see messages containing Successfully installed certifi and update complete.

ⓧ Command not found

Your terminal cannot find a usable Python interpreter. The official installer adds Python 3.15.2 to your Mac.

  • Open the Python macOS releases page in your browser.
  • Download the macOS installer for Python 3.15.2 from its release entry.
  • Double-click the downloaded .pkg file in your Downloads folder.

The installer needs administrator approval because it makes Python available across your Mac. Your password is handled by macOS.

  • Click Continue on the first installer screen.
  • Click Continue after reading the Read Me.
  • Click Continue after reviewing the license.
  • Click Agree to accept the license terms.
  • Click Install on the Installation Type screen.
  • Enter your Mac administrator password when macOS requests approval.
  • Wait for the Summary screen to confirm that installation is complete.

Complete the macOS setup

The installer places a certificate command inside the Python application folder. Running it prepares secure network connections for the new interpreter.

  • Click Finder in the Dock.
  • Select Applications in the Finder sidebar.
  • Open the Python 3.15 folder.
  • Double-click Install Certificates.command.

A temporary terminal window completes the certificate setup. You should see messages containing Successfully installed certifi and update complete.

  • Confirm that every installation path now reaches the required version by running the check again:
python3 --version

What should I see?

Your terminal should print Python 3.15.2. This confirms that the current shell can find the interpreter required for the project.

Still seeing another version?

Close Terminal after the installer finishes. Open a fresh Terminal window so the shell reloads its command paths.

If the result still differs, ask for help checking which Python installation your Mac is finding. You can also share the output in the NextWork community.

Good progress. Your Mac can now find the exact Python release that powers the mystery game.

Prepare the VS Code workspace

The Python extension connects Visual Studio Code to installed Python interpreters. It also provides the environment commands used to isolate this project.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code and press Enter to open it.

You will see the Visual Studio Code window ready for the extension setup.

  • Press Cmd+Shift+X to open the Extensions view.
  • Enter @id:ms-python.python in the extension search box.

The search result should show the Python extension published by Microsoft.

  • Select the Python extension published by Microsoft.

If the extension page shows Manage, the extension is already installed. Continue with the workspace setup.

  • Click Install if that button is available.

The button changes to Manage after installation. Visual Studio Code can now discover Python interpreters.

Extension installation stuck?

Confirm that your Mac has an active internet connection. Restart Visual Studio Code before trying the extension page again.

If the installation still does not finish, get help troubleshooting the Python extension installation. You can also share the extension status in the NextWork community.

A folder workspace gives the game one predictable home. The virtual environment created later stays inside this folder.

  • Switch to your Mac desktop.
  • Click an empty area of the desktop to focus Finder.
  • Press Cmd+Shift+N to create a folder.

You will see a new folder with its name selected for editing.

  • Type locked-study as the folder name.
  • Press Return to save the name.

The locked-study folder now appears on your desktop.

  • Return to Visual Studio Code.
  • Click File in the menu bar.
  • Click Open Folder.
  • Select the locked-study folder from your desktop.
  • Click Open to load the workspace.

Visual Studio Code now shows locked-study in the Explorer. This is the workspace that holds the game.

Create the environment and test Tkinter

A virtual environment gives the project its own Python connection. Visual Studio Code can activate that environment whenever you work inside locked-study.

The environment wizard asks which environment type to create. It then asks which installed interpreter should supply it.

  • Press Cmd+Shift+P to open the Command Palette.
  • Select Python: Create Environment.
  • Select Venv as the environment type.
  • Select the Python 3.15.2 interpreter.

Environment creation can take a moment while Visual Studio Code prepares the folder. You will see .venv appear in the Explorer when it finishes.

That is the editor connection complete. The status bar now identifies the selected .venv environment.

The final check asks Python to launch Tkinter as a module. Before you run it, what kind of window do you expect the GUI toolkit to open? Hold that prediction.

  • Click Terminal in the menu bar.
  • Click New Terminal to open an integrated terminal for the workspace.

The new terminal uses the selected .venv environment. You are ready to test the GUI runtime.

  • Launch the Tkinter demonstration window by running this command:
python -m tkinter

What does this command prove?

  • The python command uses the interpreter from your selected virtual environment.
  • The -m option starts the tkinter module as a program.
  • The demonstration window confirms that Tcl/Tk can draw a native interface on your Mac.

Demonstration window missing?

  • Confirm that the Visual Studio Code status bar shows your .venv environment with Python 3.15.2.
  • Create a fresh integrated terminal after selecting the correct interpreter.

If the command finishes without opening a window, ask for help checking the Tkinter runtime. You can also share the terminal output in the NextWork community.

You should see a Tk demonstration window with sample controls. The window also displays the installed Tcl/Tk version.

  • Close the Tkinter demonstration window after capturing your result.

Your Python environment can now open desktop windows from inside Visual Studio Code. Next up, you will turn that blank capability into the first visible version of The Locked Study.

Open the Locked Study

Python and Tkinter now work inside the .venv selected by Visual Studio Code.

The mystery needs a visible GUI shell before it can remember clues or judge accusations. This limited shell gives you an immediate result while leaving the game rules for later steps.

In this step, get ready to:
  • Create a runnable desktop window for The Locked Study.
  • Display the case story and live text values.
  • Add the investigation, evidence, accusation, and reset controls.
Create the game window

A Tkinter app starts with one root window. Its event loop keeps the window visible while it waits for clicks and other events.

  • Select New File in the Explorer sidebar.
  • Name the file main.py.

You should see main.py inside the locked-study workspace.

  • Build the runnable window foundation by pasting this code into main.py:
import tkinter as tk
from tkinter import messagebox, ttk


class MysteryGame:
    def __init__(self, root):
        self.root = root
        self.root.title("The Locked Study")
        self.root.geometry("700x540")
        self.root.minsize(620, 500)

        self.status_text = tk.StringVar(
            value="Investigate all three locations before making an accusation."
        )
        self.notebook_text = tk.StringVar(value="No clues collected yet.")

        self.build_ui()

    def build_ui(self):
        outer = ttk.Frame(self.root, padding=20)
        outer.pack(fill="both", expand=True)

        ttk.Label(outer, text="THE LOCKED STUDY").pack(pady=(0, 8))


if __name__ == "__main__":
    root = tk.Tk()
    MysteryGame(root)
    root.mainloop()

How Does the Window Start?

  • The imports provide the root window, themed widgets, and message dialogs used by the game.
  • The MysteryGame class keeps the interface together as one application.
  • The two StringVar values hold text that widgets can display and update.
  • The mainloop() call keeps the app responsive until you close it.
  • Save main.py.
  • Select the Run Python File play button in the top-right corner of the editor.

There it is: your Python file now opens a desktop window titled The Locked Study. You should also see the heading inside the window.

Window Not Opening?

Confirm that main.py is the selected editor tab. Check that the .venv environment from the previous step still appears in the VS Code status bar.

Help me diagnose why my Tkinter root window does not open from VS Code.

  • Close the game window.
Show the case and live text

The shell needs to tell the player what happened and what to do next. A label connected to self.status_text displays the current instruction from the game state.

  • In build_ui(), add the story and status labels directly below the label that displays THE LOCKED STUDY:
        ttk.Label(
            outer,
            text=(
                "The curator vanished from a locked study.\n"
                "Search the estate, collect every clue, and name the criminal."
            ),
        ).pack(anchor="w", pady=(0, 12))

        ttk.Label(outer, textvariable=self.status_text).pack(
            anchor="w", pady=(0, 12)
        )

How Does the Story Stay Live?

  • The story label introduces the case across two lines.
  • The textvariable connection makes the status label display the current value of self.status_text.
  • The spacing keeps each piece of case information visually separate.
  • Save main.py.
  • Select the Run Python File play button.

The mystery now has a clear opening scene. You should see the case story followed by the instruction to investigate all three locations.

Story Text Missing?

Check that the new labels sit inside build_ui() with the same indentation as the existing heading label. Confirm that both story strings remain inside the text value.

Help me fix missing story or status text in my Tkinter window.

  • Close the game window.
Add the game controls

Themed ttk frames group related controls into visible sections. The buttons form the game shell that later callbacks will make interactive.

  • Add the investigation section below the status label in build_ui() by pasting this code:
        investigation_frame = ttk.LabelFrame(
            outer, text="Investigate", padding=10
        )
        investigation_frame.pack(fill="x", pady=(0, 12))

        for location in ("Study", "Hall", "Conservatory"):
            ttk.Button(
                investigation_frame,
                text=location,
            ).pack(side="left", padx=5)

How Are the Locations Built?

  • The titled frame identifies the investigation area.
  • The loop creates one button for each location.
  • The left-side packing places the three location buttons in one row.
  • Save main.py.
  • Select the Run Python File play button.

You should see an Investigate section containing Study, Hall, and Conservatory buttons.

Location Buttons Missing?

Make sure the entire loop stays inside build_ui(). Keep the button creation indented inside the loop.

Help me fix the missing investigation buttons in my Tkinter interface.

  • Close the game window.

An evidence notebook gives every discovered clue a visible home. Its label reads from self.notebook_text so later searches can replace the displayed text.

  • Add the evidence notebook below the investigation loop by pasting this code:
        notebook_frame = ttk.LabelFrame(
            outer, text="Evidence Notebook", padding=10
        )
        notebook_frame.pack(fill="both", expand=True, pady=(0, 12))
        ttk.Label(
            notebook_frame, textvariable=self.notebook_text
        ).pack(anchor="w")

How Does the Notebook Expand?

  • The evidence frame expands to use the available window space.
  • The nested label displays the current value of self.notebook_text.
  • The initial value tells the player that no clues have been collected.
  • Save main.py.
  • Select the Run Python File play button.

You should see an Evidence Notebook section filling the center of the window. It displays No clues collected yet..

Notebook Not Expanding?

Check that the frame uses both fill="both" and expand=True. Confirm that the label belongs to notebook_frame.

Help me fix the evidence notebook layout in my Tkinter window.

  • Close the game window.

The final controls complete the visible game loop. Suspect buttons represent the eventual decision point while New Case provides a clear reset action.

  • Add the accusation and reset controls below the evidence notebook by pasting this code:
        accusation_frame = ttk.LabelFrame(
            outer, text="Make an Accusation", padding=10
        )
        accusation_frame.pack(fill="x", pady=(0, 12))

        for suspect in ("Dr. Vale", "Morgan Reed", "Casey Finch"):
            ttk.Button(
                accusation_frame,
                text=suspect,
            ).pack(side="left", padx=5)

        ttk.Button(outer, text="New Case").pack(anchor="w")

How Do the Final Controls Fit?

  • The accusation frame separates the suspects from the investigation locations.
  • The loop creates one button for each suspect.
  • The New Case button sits below the accusation section as the final control.

✔️ Awesome, I've got everything!

Your file now contains the complete GUI shell for this step.

ⓧ I'd like to double check the full code

  • Compare your main.py file with this complete version:
import tkinter as tk
from tkinter import messagebox, ttk


class MysteryGame:
    def __init__(self, root):
        self.root = root
        self.root.title("The Locked Study")
        self.root.geometry("700x540")
        self.root.minsize(620, 500)

        self.status_text = tk.StringVar(
            value="Investigate all three locations before making an accusation."
        )
        self.notebook_text = tk.StringVar(value="No clues collected yet.")

        self.build_ui()

    def build_ui(self):
        outer = ttk.Frame(self.root, padding=20)
        outer.pack(fill="both", expand=True)

        ttk.Label(outer, text="THE LOCKED STUDY").pack(pady=(0, 8))
        ttk.Label(
            outer,
            text=(
                "The curator vanished from a locked study.\n"
                "Search the estate, collect every clue, and name the criminal."
            ),
        ).pack(anchor="w", pady=(0, 12))

        ttk.Label(outer, textvariable=self.status_text).pack(
            anchor="w", pady=(0, 12)
        )

        investigation_frame = ttk.LabelFrame(
            outer, text="Investigate", padding=10
        )
        investigation_frame.pack(fill="x", pady=(0, 12))

        for location in ("Study", "Hall", "Conservatory"):
            ttk.Button(
                investigation_frame,
                text=location,
            ).pack(side="left", padx=5)

        notebook_frame = ttk.LabelFrame(
            outer, text="Evidence Notebook", padding=10
        )
        notebook_frame.pack(fill="both", expand=True, pady=(0, 12))
        ttk.Label(
            notebook_frame, textvariable=self.notebook_text
        ).pack(anchor="w")

        accusation_frame = ttk.LabelFrame(
            outer, text="Make an Accusation", padding=10
        )
        accusation_frame.pack(fill="x", pady=(0, 12))

        for suspect in ("Dr. Vale", "Morgan Reed", "Casey Finch"):
            ttk.Button(
                accusation_frame,
                text=suspect,
            ).pack(side="left", padx=5)

        ttk.Button(outer, text="New Case").pack(anchor="w")


if __name__ == "__main__":
    root = tk.Tk()
    MysteryGame(root)
    root.mainloop()
  • Save main.py.

Before you run the complete shell, which control groups do you expect to see below the status line?

  • Select the Run Python File play button while main.py is selected.

You should see a window titled The Locked Study. It contains the case story, the empty evidence notebook, three location buttons, three suspect buttons, and a New Case button.

Controls Cut Off or Missing?

Check that each frame is created inside build_ui(). Confirm that the accusation section appears after the evidence notebook code.

Help me compare my Tkinter game shell with the expected main.py layout.

You have crossed the first visible milestone: The Locked Study now opens as a complete desktop interface. Next, you will connect its location buttons to clues and visible status changes.

Investigate the Crime Scene

The Tkinter window you opened in the previous step looks like a mystery game. However, its location buttons cannot change the evidence notebook.

A callback creates the bridge from each click to the clue shown. You will move the mystery content into data structures before using application state to record each investigation.

In this step, get ready to:
  • Define the mystery content in shared data structures.
  • Record selected clues with an investigation callback.
  • Expose duplicate evidence through a repeated search.
Separate mystery data from widgets

The current location names sit directly inside the widget loop. A dictionary gives each location one clue.

A tuple keeps the suspect names together. The culprit constant identifies the correct suspect for the result logic added later.

  • In main.py, find the line containing from tkinter import messagebox, ttk.
  • Paste the mystery data directly below that import line:
CLUES = {
    "Study": "A torn prescription signed by Dr. Vale lies beside the safe.",
    "Hall": "The security log places Dr. Vale outside the study at 9:10 p.m.",
    "Conservatory": "Rare conservatory soil is pressed into Dr. Vale's shoes.",
}

SUSPECTS = ("Dr. Vale", "Morgan Reed", "Casey Finch")
CULPRIT = "Dr. Vale"

How Does the Mystery Data Work?

  • CLUES maps each location to the evidence found there.
  • SUSPECTS stores the three accusation choices in a fixed order.
  • CULPRIT identifies Dr. Vale as the solution to this case.
  • Save main.py.
  • Select Run Python File in the top-right corner of the editor.

You will see the case window open with its three location buttons. That successful launch confirms Python accepted the new mystery data.

Does the Window Fail to Open?

Compare every quote with the data block above. Confirm that CLUES has matching braces.

Help me check the mystery data in main.py.

  • Close the game window before editing main.py again.

The investigation buttons can now take their names from the keys in CLUES. This keeps each button aligned with an available clue.

  • In build_ui(), find the loop that creates the location buttons.
  • Replace the entire location loop with this block:
        for location in CLUES:
            ttk.Button(
                investigation_frame,
                text=location,
            ).pack(side="left", padx=5)

What Does This Loop Do?

  • for location in CLUES: visits the Study, Hall, and Conservatory keys.
  • text=location uses the current key as the button label.
  • The loop keeps the visible locations synchronized with the available clue data.
  • Save main.py.
  • Select Run Python File in the top-right corner of the editor.

You will see Study, Hall, and Conservatory in the investigation area. Those labels now come from CLUES.

Are Location Buttons Missing?

Check that CLUES is spelled identically in the data block and the loop. Keep the button block indented inside the loop.

Help me restore the location buttons.

  • Close the game window before continuing.

The accusation buttons can use the same data-driven pattern. Their labels will come from SUSPECTS.

  • In build_ui(), find the loop that creates the suspect buttons.
  • Replace the entire suspect loop with this block:
        for suspect in SUSPECTS:
            ttk.Button(
                accusation_frame,
                text=suspect,
            ).pack(side="left", padx=5)

How Are the Suspects Displayed?

The loop creates one button for every name in SUSPECTS. The tuple remains the single source for the visible accusation choices.

  • Save main.py.
  • Select Run Python File in the top-right corner of the editor.

You will see Dr. Vale, Morgan Reed, and Casey Finch in the accusation area. Those labels now come from SUSPECTS.

Are Suspect Buttons Missing?

Confirm that the loop uses SUSPECTS with the same capitalization as the tuple. Keep text=suspect inside the button constructor.

Help me restore the suspect buttons.

  • Close the game window before adding the investigation behavior.
Store each collected clue

The game needs a list to preserve clues in the order you discover them. Each MysteryGame instance keeps its own list in clues_found.

  • In __init__(), find this window-size line:
        self.root.minsize(620, 500)

Why Find This Line?

This line sits immediately before the text variables in __init__(). It provides a precise anchor for the new game state.

  • Insert self.clues_found = [] directly below the window-size line so that section looks like this:
        self.root.minsize(620, 500)

        self.clues_found = []

What Does This State Hold?

clues_found starts as an empty list whenever the game starts. Investigation clicks will append clue text to this list in discovery order.

  • Save main.py.
  • Select Run Python File in the top-right corner of the editor.

You will see the full case window open successfully. That launch confirms the new list is initialized when MysteryGame starts.

Does the Game Stop During Startup?

Keep self.clues_found = [] aligned with the other self assignments in __init__(). Confirm that the line remains inside the method.

Help me fix the clues_found state.

  • Close the game window before adding the callback.

The investigation callback receives a location name. It uses that name to find the matching clue before rebuilding the notebook text.

  • In main.py, find the New Case button at the end of build_ui().
  • Add the following method below build_ui() with the same indentation as def build_ui(self):.
    def investigate(self, location):
        self.clues_found.append(CLUES[location])
        notebook = "\n".join(f"• {clue}" for clue in self.clues_found)
        self.notebook_text.set(notebook)
        self.status_text.set(f"Clue collected in the {location}.")

What Does the Investigation Callback Do?

  • CLUES[location] retrieves the clue mapped to the selected location.
  • append() adds that clue to clues_found.
  • join() rebuilds the notebook as separate bullet-prefixed lines.
  • set() updates the notebook and status variables so their labels redraw.
  • Save main.py.
  • Select Run Python File in the top-right corner of the editor.

You will see the case window open successfully. This confirms the callback is defined inside MysteryGame.

Does the Callback Break Startup?

Align def investigate(self, location): with def build_ui(self):. Keep the callback body indented beneath its definition.

Help me place the investigate callback correctly.

  • Close the game window before connecting the buttons.
Connect the investigation buttons

A button uses its command option to call game logic when you click it. Each button also needs to preserve the location from its own loop iteration.

  • In build_ui(), find the current location loop:
        for location in CLUES:
            ttk.Button(
                investigation_frame,
                text=location,
            ).pack(side="left", padx=5)

What Is Missing From This Loop?

The current loop creates each visible button. It does not assign a callback to command.

  • Replace the current location loop with this connected version:
        for location in CLUES:
            ttk.Button(
                investigation_frame,
                text=location,
                command=lambda place=location: self.investigate(place),
            ).pack(side="left", padx=5)

How Does Each Button Keep Its Location?

The lambda stores the current location in place when the button is created. A later click passes that preserved value to investigate().

Study therefore retrieves the Study clue. Hall and Conservatory retrieve their own mapped clues.

  • Save main.py.

✔️ Awesome, I've got everything!

  • Keep main.py saved before testing the investigation buttons.

ⓧ I'd like to double check the full code

  • Compare your main.py with this complete version:
import tkinter as tk
from tkinter import messagebox, ttk

CLUES = {
    "Study": "A torn prescription signed by Dr. Vale lies beside the safe.",
    "Hall": "The security log places Dr. Vale outside the study at 9:10 p.m.",
    "Conservatory": "Rare conservatory soil is pressed into Dr. Vale's shoes.",
}

SUSPECTS = ("Dr. Vale", "Morgan Reed", "Casey Finch")
CULPRIT = "Dr. Vale"


class MysteryGame:
    def __init__(self, root):
        self.root = root
        self.root.title("The Locked Study")
        self.root.geometry("700x540")
        self.root.minsize(620, 500)

        self.clues_found = []

        self.status_text = tk.StringVar(
            value="Investigate all three locations before making an accusation."
        )
        self.notebook_text = tk.StringVar(value="No clues collected yet.")

        self.build_ui()

    def build_ui(self):
        outer = ttk.Frame(self.root, padding=20)
        outer.pack(fill="both", expand=True)

        ttk.Label(outer, text="THE LOCKED STUDY").pack(pady=(0, 8))
        ttk.Label(
            outer,
            text=(
                "The curator vanished from a locked study.\n"
                "Search the estate, collect every clue, and name the criminal."
            ),
        ).pack(anchor="w", pady=(0, 12))

        ttk.Label(outer, textvariable=self.status_text).pack(
            anchor="w", pady=(0, 12)
        )

        investigation_frame = ttk.LabelFrame(
            outer, text="Investigate", padding=10
        )
        investigation_frame.pack(fill="x", pady=(0, 12))

        for location in CLUES:
            ttk.Button(
                investigation_frame,
                text=location,
                command=lambda place=location: self.investigate(place),
            ).pack(side="left", padx=5)

        notebook_frame = ttk.LabelFrame(
            outer, text="Evidence Notebook", padding=10
        )
        notebook_frame.pack(fill="both", expand=True, pady=(0, 12))
        ttk.Label(
            notebook_frame, textvariable=self.notebook_text
        ).pack(anchor="w")

        accusation_frame = ttk.LabelFrame(
            outer, text="Make an Accusation", padding=10
        )
        accusation_frame.pack(fill="x", pady=(0, 12))

        for suspect in SUSPECTS:
            ttk.Button(
                accusation_frame,
                text=suspect,
            ).pack(side="left", padx=5)

        ttk.Button(outer, text="New Case").pack(anchor="w")

    def investigate(self, location):
        self.clues_found.append(CLUES[location])
        notebook = "\n".join(f"• {clue}" for clue in self.clues_found)
        self.notebook_text.set(notebook)
        self.status_text.set(f"Clue collected in the {location}.")


if __name__ == "__main__":
    root = tk.Tk()
    MysteryGame(root)
    root.mainloop()

How to Use This Reference

Use this full file to compare indentation, spelling, and placement. Keep your working file identical before running the final investigation test.

Before you run the game, consider what the notebook will show after its first investigation.

  • Select Run Python File in the top-right corner of the editor.
  • Click Study once.

You will see the torn prescription clue in the evidence notebook. The status line will read Clue collected in the Study..

Before the second click, predict whether the current list will recognize Study or append its clue again.

  • Click Study a second time.

You will see the Study clue twice in the evidence notebook. This duplicate is intentional because the callback records every click without tracking which locations were already searched.

Does the Study Clue Fail to Appear?

Confirm that the Study button loop includes command=lambda place=location: self.investigate(place). Check that investigate() appends CLUES[location].

Help me connect the Study button to its clue.

That is the first interactive slice working. Each location click now changes visible evidence.

The investigation now works. Next, you will protect the notebook so each location contributes one clue.

Protect the Evidence Notebook

Your investigation buttons now turn room searches into visible clues. A second Study search also adds the same clue again.

Repeated clicks corrupt the notebook because its list does not remember which locations produced those clues. You will add a set that blocks repeat searches before they change the evidence.

In this step, get ready to:
  • Track searched locations in a set.
  • Guard the investigation callback against duplicate evidence.
  • Verify that each location contributes exactly one clue.
Add unique search state

The game currently stores the clues shown in the notebook. It also needs a separate record of the locations that produced them.

  • In main.py, find the __init__() method inside MysteryGame.
  • Locate this line:
        self.clues_found = []

Why is this state incomplete?

The self.clues_found list records evidence text. It cannot tell investigate() whether a location was searched earlier.

  • Replace that line with these two state values:
        self.investigated = set()
        self.clues_found = []

What does this state do?

  • The self.investigated set starts empty because no locations have been searched.
  • A set keeps each location name unique.
  • The self.clues_found list still preserves the clues in discovery order.
Guard repeated searches

A guard clause checks for a repeated location at the start of the callback. An early return then stops the notebook update.

  • In main.py, scroll to the investigate() function near the bottom of the file.
  • Find the current function:
    def investigate(self, location):
        self.clues_found.append(CLUES[location])
        notebook = "\n".join(f"• {clue}" for clue in self.clues_found)
        self.notebook_text.set(notebook)
        self.status_text.set(f"Clue collected in the {location}.")

Why do duplicates get through?

Every click reaches self.clues_found.append(). The function has no check before that update.

  • Replace the current function with the guarded version below:
    def investigate(self, location):
        if location in self.investigated:
            self.status_text.set(f"{location} was already searched.")
            return

        self.investigated.add(location)
        self.clues_found.append(CLUES[location])
        notebook = "\n".join(f"• {clue}" for clue in self.clues_found)
        self.notebook_text.set(notebook)
        self.status_text.set(f"Clue collected in the {location}.")

What does this guard do?

  • The membership check asks whether location already exists in self.investigated.
  • The early return prevents a repeated search from reaching the notebook update.
  • The add() call records a location during its first search.
  • The remaining lines append the new clue before refreshing the visible notebook.
  • Save main.py.

✔️ Awesome, I've got everything!

Your location set and duplicate guard are in place. The next test proves that the notebook stays clean.

ⓧ I'd like to double check the full code

import tkinter as tk
from tkinter import messagebox, ttk

CLUES = {
    "Study": "A torn prescription signed by Dr. Vale lies beside the safe.",
    "Hall": "The security log places Dr. Vale outside the study at 9:10 p.m.",
    "Conservatory": "Rare conservatory soil is pressed into Dr. Vale's shoes.",
}

SUSPECTS = ("Dr. Vale", "Morgan Reed", "Casey Finch")
CULPRIT = "Dr. Vale"


class MysteryGame:
    def __init__(self, root):
        self.root = root
        self.root.title("The Locked Study")
        self.root.geometry("700x540")
        self.root.minsize(620, 500)

        self.investigated = set()
        self.clues_found = []

        self.status_text = tk.StringVar(
            value="Investigate all three locations before making an accusation."
        )
        self.notebook_text = tk.StringVar(value="No clues collected yet.")

        self.build_ui()

    def build_ui(self):
        outer = ttk.Frame(self.root, padding=20)
        outer.pack(fill="both", expand=True)

        ttk.Label(outer, text="THE LOCKED STUDY").pack(pady=(0, 8))
        ttk.Label(
            outer,
            text=(
                "The curator vanished from a locked study.\n"
                "Search the estate, collect every clue, and name the criminal."
            ),
        ).pack(anchor="w", pady=(0, 12))

        ttk.Label(outer, textvariable=self.status_text).pack(
            anchor="w", pady=(0, 12)
        )

        investigation_frame = ttk.LabelFrame(
            outer, text="Investigate", padding=10
        )
        investigation_frame.pack(fill="x", pady=(0, 12))

        for location in CLUES:
            ttk.Button(
                investigation_frame,
                text=location,
                command=lambda place=location: self.investigate(place),
            ).pack(side="left", padx=5)

        notebook_frame = ttk.LabelFrame(
            outer, text="Evidence Notebook", padding=10
        )
        notebook_frame.pack(fill="both", expand=True, pady=(0, 12))
        ttk.Label(
            notebook_frame, textvariable=self.notebook_text
        ).pack(anchor="w")

        accusation_frame = ttk.LabelFrame(
            outer, text="Make an Accusation", padding=10
        )
        accusation_frame.pack(fill="x", pady=(0, 12))

        for suspect in SUSPECTS:
            ttk.Button(
                accusation_frame,
                text=suspect,
            ).pack(side="left", padx=5)

        ttk.Button(outer, text="New Case").pack(anchor="w")

    def investigate(self, location):
        if location in self.investigated:
            self.status_text.set(f"{location} was already searched.")
            return

        self.investigated.add(location)
        self.clues_found.append(CLUES[location])
        notebook = "\n".join(f"• {clue}" for clue in self.clues_found)
        self.notebook_text.set(notebook)
        self.status_text.set(f"Clue collected in the {location}.")


if __name__ == "__main__":
    root = tk.Tk()
    MysteryGame(root)
    root.mainloop()

How does the full file fit together?

The set records each searched location. The early return keeps repeat clicks from changing the evidence list.

Prove the notebook stays clean

A protected notebook has one rule: each location contributes no more than one clue. The status line handles every repeat search without changing that notebook.

Before you test it, do you expect the second Study click to change the clue count or only the status line?

  • Select the Run Python File play button while main.py is active.
  • Click Study once.
  • Click Study a second time.

You will see the Study clue exactly once. The status line reads Study was already searched..

  • Click Hall.
  • Click Conservatory.

You will see exactly three notebook entries. That is the evidence guard working: every location now contributes one clue.

Still seeing duplicate clues?

  • Confirm that self.investigated = set() appears before self.clues_found = [] in __init__().
  • Confirm that return is indented inside the repeated-location check.
  • Confirm that self.investigated.add(location) appears before the clue is appended.

Help me find why repeated searches still add duplicate evidence.

Your evidence notebook now preserves a trustworthy case record. Next, you will turn those clues into validated accusations with clear win and loss outcomes.

Accuse a Suspect and Reset the Case

Your evidence notebook now keeps one clue for each searched location. That gives the player a reliable record of the investigation.

A mystery needs a payoff once every clue is collected. In this step, your Tkinter interface will validate accusations, reveal the outcome, protect completed cases, and reset the application state for another game.

In this step, get ready to:
  • Block accusations until every location has been investigated.
  • Show a win or loss dialog after a valid accusation.
  • Reset the evidence notebook for a new case.
Block premature accusations

An accusation should only resolve the mystery after the player has collected every clue. A modal dialog pauses interaction with the game window while it presents that validation message.

  • In main.py, find the state block containing self.investigated and self.clues_found.
  • Replace that state block by pasting this code:
        self.investigated = set()
        self.clues_found = []
        self.case_over = False

What does this state track?

  • self.investigated records the locations the player has searched.
  • self.clues_found preserves the clues shown in the evidence notebook.
  • self.case_over records whether an accusation has completed the current case.
  • Scroll below the existing investigate() method.
  • Add the first version of accuse() above if __name__ == "__main__": by pasting this code:
    def accuse(self, suspect):
        if self.case_over:
            return

        if len(self.investigated) < len(CLUES):
            messagebox.showwarning(
                "More Evidence Needed",
                "Investigate every location before making an accusation.",
                parent=self.root,
            )
            return

How does accusation validation work?

  • The first guard clause stops the callback when case_over is already true.
  • The length comparison checks whether the number of searched locations matches the number of entries in CLUES.
  • messagebox.showwarning() keeps an early accusation from resolving the case.
  • parent=self.root places the dialog above the game window.
  • Save main.py.
  • Select the Run Python File play button in the top-right corner of VS Code.

You should see The Locked Study window open again. This confirms that the new state and callback contain valid Python.

Does the game fail to open?

Check that self.case_over = False has the same indentation as self.clues_found = [].

Confirm that accuse() lines up with investigate() inside MysteryGame.

Help me fix the indentation around my new state and accuse method.

The suspect buttons currently display names without calling accuse(). Each button needs a callback that preserves its own suspect name.

  • In build_ui(), find the for suspect in SUSPECTS: loop.
  • Replace the complete suspect button loop by pasting this code:
        for suspect in SUSPECTS:
            ttk.Button(
                accusation_frame,
                text=suspect,
                command=lambda name=suspect: self.accuse(name),
            ).pack(side="left", padx=5)

What does the button callback do?

  • The command option runs accuse() when the player selects a suspect.
  • name=suspect captures the correct name while the loop creates each button.
  • self.accuse(name) passes that captured suspect into the validation logic.

Before you test the button, do you think the game accepts an accusation with an empty evidence notebook?

  • Save main.py.
  • Select the Run Python File play button.
  • Select Dr. Vale before investigating a location.

You should see a warning dialog containing Investigate every location before making an accusation. The empty notebook proves that the accusation guard is working.

That is the first rule enforced. Players now have to investigate before they can solve the mystery.

Does the suspect button stay silent?

Check that the command line sits inside the suspect button definition. Confirm that it ends with a comma.

Verify that the callback uses name=suspect and calls self.accuse(name).

Help me debug a suspect button that does not call accuse.

Resolve the accusation

A valid accusation ends the current case. The result updates the status line before a dialog reports whether the selected suspect matches CULPRIT.

  • In accuse(), find the return that follows messagebox.showwarning().
  • Add the outcome logic directly below that return by pasting this code:
        self.case_over = True

        if suspect == CULPRIT:
            self.status_text.set(f"Case solved. {suspect} is the criminal.")
            messagebox.showinfo(
                "Case Solved",
                f"Correct. {suspect} committed the crime.",
                parent=self.root,
            )
        else:
            self.status_text.set(f"Wrong accusation. {suspect} is innocent.")
            messagebox.showerror(
                "Case Closed",
                f"{suspect} is innocent. The criminal escaped.",
                parent=self.root,
            )

How does the game choose a result?

  • self.case_over = True closes the case before the result is displayed.
  • suspect == CULPRIT checks the player's accusation against the fixed solution.
  • messagebox.showinfo() reports the correct accusation.
  • messagebox.showerror() reports an incorrect accusation.

The investigation buttons also need to respect a completed case. A guard at the start of investigate() stops later clicks before they can change the evidence.

  • Find the complete investigate() method in main.py.
  • Replace that method by pasting this code:
    def investigate(self, location):
        if self.case_over:
            return

        if location in self.investigated:
            self.status_text.set(f"{location} was already searched.")
            return

        self.investigated.add(location)
        self.clues_found.append(CLUES[location])
        notebook = "\n".join(f"• {clue}" for clue in self.clues_found)
        self.notebook_text.set(notebook)
        self.status_text.set(f"Clue collected in the {location}.")

How is a completed case protected?

  • The first guard returns immediately when case_over is true.
  • The duplicate-location guard still protects the notebook during an active case.
  • The remaining lines add evidence only during a valid first search.

Before you run the completed accusation flow, which dialog do you expect after selecting the suspect named by CULPRIT?

  • Save main.py.
  • Select the Run Python File play button.
  • Select Study.
  • Select Hall.
  • Select Conservatory.
  • Select Dr. Vale.

You should see an information dialog confirming that Dr. Vale committed the crime. The status line should read Case solved. Dr. Vale is the criminal..

  • Close the result dialog.
  • Select Study again.

The notebook and status line should remain unchanged. Your completed case now rejects further investigation.

The mystery now has a real ending. A valid accusation produces a visible result while the completed-case guard preserves that outcome.

Does a valid accusation still show the warning?

Confirm that you searched Study, Hall, and Conservatory once each before selecting a suspect.

Check that the comparison uses len(self.investigated) < len(CLUES).

Help me debug why a complete investigation still triggers the evidence warning.

Reset the case

A reset returns every piece of mutable state to its starting value. The same game window can then host a fresh playthrough without restarting Python.

  • Scroll below accuse() in main.py.
  • Add new_case() above if __name__ == "__main__": by pasting this code:
    def new_case(self):
        self.investigated.clear()
        self.clues_found.clear()
        self.case_over = False
        self.status_text.set(
            "Investigate all three locations before making an accusation."
        )
        self.notebook_text.set("No clues collected yet.")

What does the reset restore?

  • clear() empties the investigated locations and collected clues.
  • self.case_over = False allows the next case to accept investigation clicks.
  • status_text returns to the original investigation prompt.
  • notebook_text returns to No clues collected yet..

The reset button needs to call new_case(). This gives the existing control its final behavior.

  • In build_ui(), find the existing New Case button.
  • Replace that button definition by pasting this code:
        ttk.Button(outer, text="New Case", command=self.new_case).pack(
            anchor="w"
        )

How does the reset button work?

command=self.new_case passes the reset callback to the button. Tkinter runs that method each time the player selects New Case.

  • Save main.py.

✔️ Awesome, I've got everything!

Great. Double-check that you saved main.py before testing the finished game.

ⓧ I'd like to double check the full code

import tkinter as tk
from tkinter import messagebox, ttk

CLUES = {
    "Study": "A torn prescription signed by Dr. Vale lies beside the safe.",
    "Hall": "The security log places Dr. Vale outside the study at 9:10 p.m.",
    "Conservatory": "Rare conservatory soil is pressed into Dr. Vale's shoes.",
}

SUSPECTS = ("Dr. Vale", "Morgan Reed", "Casey Finch")
CULPRIT = "Dr. Vale"


class MysteryGame:
    def __init__(self, root):
        self.root = root
        self.root.title("The Locked Study")
        self.root.geometry("700x540")
        self.root.minsize(620, 500)

        self.investigated = set()
        self.clues_found = []
        self.case_over = False

        self.status_text = tk.StringVar(
            value="Investigate all three locations before making an accusation."
        )
        self.notebook_text = tk.StringVar(value="No clues collected yet.")

        self.build_ui()

    def build_ui(self):
        outer = ttk.Frame(self.root, padding=20)
        outer.pack(fill="both", expand=True)

        ttk.Label(outer, text="THE LOCKED STUDY").pack(pady=(0, 8))
        ttk.Label(
            outer,
            text=(
                "The curator vanished from a locked study.\n"
                "Search the estate, collect every clue, and name the criminal."
            ),
        ).pack(anchor="w", pady=(0, 12))

        ttk.Label(outer, textvariable=self.status_text).pack(
            anchor="w", pady=(0, 12)
        )

        investigation_frame = ttk.LabelFrame(
            outer, text="Investigate", padding=10
        )
        investigation_frame.pack(fill="x", pady=(0, 12))

        for location in CLUES:
            ttk.Button(
                investigation_frame,
                text=location,
                command=lambda place=location: self.investigate(place),
            ).pack(side="left", padx=5)

        notebook_frame = ttk.LabelFrame(
            outer, text="Evidence Notebook", padding=10
        )
        notebook_frame.pack(fill="both", expand=True, pady=(0, 12))
        ttk.Label(
            notebook_frame, textvariable=self.notebook_text
        ).pack(anchor="w")

        accusation_frame = ttk.LabelFrame(
            outer, text="Make an Accusation", padding=10
        )
        accusation_frame.pack(fill="x", pady=(0, 12))

        for suspect in SUSPECTS:
            ttk.Button(
                accusation_frame,
                text=suspect,
                command=lambda name=suspect: self.accuse(name),
            ).pack(side="left", padx=5)

        ttk.Button(outer, text="New Case", command=self.new_case).pack(
            anchor="w"
        )

    def investigate(self, location):
        if self.case_over:
            return

        if location in self.investigated:
            self.status_text.set(f"{location} was already searched.")
            return

        self.investigated.add(location)
        self.clues_found.append(CLUES[location])
        notebook = "\n".join(f"• {clue}" for clue in self.clues_found)
        self.notebook_text.set(notebook)
        self.status_text.set(f"Clue collected in the {location}.")

    def accuse(self, suspect):
        if self.case_over:
            return

        if len(self.investigated) < len(CLUES):
            messagebox.showwarning(
                "More Evidence Needed",
                "Investigate every location before making an accusation.",
                parent=self.root,
            )
            return

        self.case_over = True

        if suspect == CULPRIT:
            self.status_text.set(f"Case solved. {suspect} is the criminal.")
            messagebox.showinfo(
                "Case Solved",
                f"Correct. {suspect} committed the crime.",
                parent=self.root,
            )
        else:
            self.status_text.set(f"Wrong accusation. {suspect} is innocent.")
            messagebox.showerror(
                "Case Closed",
                f"{suspect} is innocent. The criminal escaped.",
                parent=self.root,
            )

    def new_case(self):
        self.investigated.clear()
        self.clues_found.clear()
        self.case_over = False
        self.status_text.set(
            "Investigate all three locations before making an accusation."
        )
        self.notebook_text.set("No clues collected yet.")


if __name__ == "__main__":
    root = tk.Tk()
    MysteryGame(root)
    root.mainloop()

The full-file reference combines accusation validation, completed-case protection, and reset behavior in one runnable main.py file.

Before the final test, what do you expect the evidence notebook to show after you solve the case and select New Case?

  • Select the Run Python File play button.
  • Select Study.
  • Select Hall.
  • Select Conservatory.
  • Select Dr. Vale.
  • Close the solved-case information dialog.
  • Select New Case.

You should see No clues collected yet. in the evidence notebook. The status line should return to Investigate all three locations before making an accusation..

You have finished the full mystery loop. Players can investigate, accuse a suspect, see the result, and begin another case from the same window.

Does New Case leave old evidence behind?

Check that new_case() calls clear() on both self.investigated and self.clues_found.

Confirm that the button uses command=self.new_case without parentheses.

Help me debug why New Case does not reset my mystery game.

Secret mission

Randomize the Criminal

Your mystery currently reveals the same criminal on every playthrough. In this mission, you will give each suspect a complete clue set and make New Case select a different culprit every time.

Clean Up Your Resources

Clean Up Your Resources

Your mystery game runs entirely on your Mac. It has no cloud resources or ongoing costs.

Resources you used:

  • Your local main.py game file.

Keep everything running

No action is needed. Choose this if you are still playing or editing the game.

  • Keep main.py in the workspace from earlier.
  • Leave the game window open while you continue testing cases.

Pause - I'll come back to this later

Closing the game stops its local process. Your main.py file stays ready for another session.

  • Close the game window titled The Locked Study if it is still open.
  • Keep main.py in the workspace from earlier.

Delete - I don't want to use this again

Deletion can feel risky. The command below targets only main.py.

  • Close the game window titled The Locked Study if it is still open.
  • Open a new integrated terminal in the workspace from earlier.
  • Remove the local game file by running this command:
rm main.py

What does this command do?

The command removes main.py from the current workspace. Other files remain untouched.

  • Return to the workspace file list.

Cleanup complete. main.py is no longer listed in the workspace.

Still See main.py?

  • Check that the terminal is using the workspace that contains main.py.
  • Ask for focused help if the file is still listed: Help me remove the local game file safely.

Nice Work!

Nice Work!

Case closed! You built a replayable Tkinter mystery game that turns each button press into a visible case update.

You've learned how to:

  • Built a themed desktop interface that presents the mystery as a playable window. Its evidence notebook makes each discovery visible.
  • Connected GUI buttons to Python callbacks that reveal clues. Tracked searched locations with a set to keep each clue unique.
  • Added accusation validation that blocks guesses until every location has been searched. Displayed clear results through modal dialogs. Restored a clean game state with the New Case button.
  • Secret Mission: Replaced the fixed criminal with randomized cases. Each reset now selects a different culprit with a matching clue set.

Ready to quiz yourself?