Productivity Quest

Build a Windows desktop quest tracker with Python, Tkinter, XP, and JSON saves.

Introduction

30 Second Summary

Checking off a task feels good for a moment. A visible score makes that progress easier to notice when the next task arrives.

In this project, you will build Productivity Quest with Python and Tkinter. Your desktop app turns tasks into quests whose XP remains after you close the window.

What You'll Build

You will demo a desktop quest board that turns each finished task into saved XP.

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

  • A live quest board where every task you add appears as an open quest.
  • One-time 10 XP rewards that move your level progress bar whenever you complete a selected quest.
  • Restart-proof saved progress that restores the full quest board from quests.json.
  • Secret Mission: Add Easy, Medium, and Hard quest difficulties that award 10, 20, or 30 XP.

Are there any prerequisites?

No previous Python experience is required. The guide sets up every missing Python tool on your Windows computer through Visual Studio Code.

Before We Start

First, lock in what Productivity Quest means to you before the hands-on work begins. This gives every feature a clear purpose as you turn tasks into quests with visible XP progress.

Set Up Python and Tkinter

Your quest tracker needs a Python interpreter to turn source code into a running Windows app. Visual Studio Code can edit Python files without providing the interpreter.

This step sets up Python 3.14.7 through the Python install manager. A project-local virtual environment gives Productivity Quest an isolated interpreter with working Tkinter support.

In this step, get ready to:
  • Install Python 3.14.7 through Python Install Manager.
  • Install the Microsoft Python extension in VS Code.
  • Create a .venv environment in the productivity-quest workspace.
  • Open the Tkinter verification window from the selected environment.
Install Python and confirm its version

The Python install manager handles Python runtimes separately from VS Code. Installing runtime line 3.14 gives your terminal the interpreter needed for this project.

  • Open the official Python release page for Python 3.14.7.
  • Choose Download Python install manager.
  • Run the downloaded installer.
  • Select Install in the installer.

The installer makes its commands available to new terminal sessions. A fresh Windows Terminal window picks up that change.

  • Press the Windows key to open Windows Search.
  • Type Windows Terminal into the search box.
  • Press Enter to open a fresh terminal window.
  • Install the Python 3.14 runtime line by running this command:
py install 3.14

What does this command do?

The py install 3.14 command asks Python Install Manager to install the current Python 3.14 runtime. That runtime provides the interpreter used by Productivity Quest.

The runtime is ready when the command finishes and returns control to the terminal prompt.

Having trouble installing the runtime?

  • Confirm that you opened a new Windows Terminal window after completing the installer.
  • Run the downloaded Python install manager again if the installation did not finish.
  • Ask for help with the installation details: help me troubleshoot Python Install Manager on Windows

Before you check, which Python version do you expect the fresh terminal to report?

  • Check the active Python version by running this command:
python --version

What does this command do?

The python --version command prints the version used when you enter python in this terminal. This check confirms whether the new runtime is available.

Choose the tab that matches the output in your terminal.

✔️ I see the expected version

That installation worked. Seeing Python 3.14.7 confirms that your fresh terminal can use the required interpreter.

ⓧ I see an older version

An older result means the terminal is still using a previous runtime. Reinstalling the 3.14 runtime gives Python Install Manager another chance to make the current release available.

  • Install the required runtime again by running this command:
py install 3.14

What does this command do?

This command installs the current Python 3.14 runtime through Python Install Manager.

Still seeing the older runtime?

Close every terminal window opened before the installation. A new terminal session can then detect the current runtime.

Ask for help if the older version remains active: help me select Python 3.14.7 on Windows

  • Close the current Windows Terminal window.
  • Open a fresh Windows Terminal window from Windows Search.
  • Repeat the version check shown above.

You should now see Python 3.14.7 in the terminal.

ⓧ Command not found

This result means the fresh terminal cannot access Python Install Manager yet. Installing the manager before the runtime completes the missing setup.

  • Return to the official Python release page.
  • Choose Download Python install manager.
  • Run the downloaded installer.
  • Select Install in the installer.
  • Close the current Windows Terminal window.
  • Open a fresh Windows Terminal window from Windows Search.
  • Install the required runtime by running this command:
py install 3.14

What does this command do?

This command uses the newly installed Python Install Manager to install the Python 3.14 runtime line.

Still missing the command?

Confirm that the installer reached its completion screen before opening the new terminal.

Ask for help if the command remains unavailable: help me make Python Install Manager available

  • Repeat the version check shown above.

The terminal should report Python 3.14.7.

Install the Python extension and open the workspace

The Python extension for Visual Studio Code connects the editor to your installed interpreter. Your workspace keeps the project files and its environment together in one named folder.

  • Press Ctrl+Shift+X in VS Code to open the Extensions view.
  • Enter ms-python.python in the Extensions search field.
  • Select the extension named Python from Microsoft.
  • Install the selected extension.

The extension page now identifies the Microsoft Python extension as installed.

  • Click File in the VS Code menu bar.
  • Select Open Folder.
  • Navigate to your Desktop in the folder dialog.
  • Create a folder named productivity-quest on your Desktop.
  • Select the productivity-quest folder.
  • Confirm the folder as your VS Code workspace.

You should see productivity-quest at the top of the Explorer sidebar. The empty workspace is ready for its isolated environment.

Extension or folder missing?

  • Confirm that the extension publisher is Microsoft before installing it.
  • Check that VS Code shows productivity-quest as the open workspace.
  • Ask for help with your local workspace: help me set up the Python extension and workspace in VS Code
Create the environment and verify Tkinter

A project-local environment keeps Productivity Quest tied to the interpreter selected for this folder. VS Code can create that environment and use it automatically in new integrated terminals.

  • Press Ctrl+Shift+P to open the Command Palette.
  • Enter Python: Create Environment.
  • Select Venv as the environment type.
  • Select Python 3.14.7 as the interpreter.
  • Wait for the environment creation process to finish.

You should see a .venv folder inside the productivity-quest workspace. That folder contains the project-local interpreter environment.

  • Press Ctrl+Shift+P if .venv is not selected as the workspace interpreter.
  • Run Python: Select Interpreter from the Command Palette.
  • Select the interpreter entry containing .venv.

VS Code now uses the .venv interpreter for this workspace.

  • Create a new integrated terminal from the VS Code menu.

Before you run the final check, what do you expect Python to open when Tkinter is available?

  • Verify Tkinter from the selected .venv terminal by running this command:
python -m tkinter

What does this command do?

The python -m tkinter command launches Tkinter as a module. Its verification window confirms that Python can access the bundled Tcl/Tk interface.

You should see a small Tk interface window showing the installed Tcl/Tk version. That confirms your selected environment can open desktop interfaces.

Tkinter window not opening?

  • Confirm that .venv remains selected as the workspace interpreter.
  • Close the current VS Code terminal before creating a fresh integrated terminal.
  • Ask for help with the verification command: help me troubleshoot Tkinter in my VS Code virtual environment

Your Windows Python workspace is ready to build desktop interfaces. Next, you will turn this empty workspace into a quest board that accepts real tasks.

Build the Quest Board

Your Windows Python workspace can now open a Tkinter window. That confirms your interpreter can run a local desktop interface.

A verification window cannot capture a real task. This step builds a usable quest board with an entry box and a quest list. You will see your first event-driven interface respond to a button click.

In this step, get ready to:
  • Create a Python file that opens the Productivity Quest window.
  • Build an interface for entering and displaying quests.
  • Connect the Add Quest button to the in-memory quest list.
Create the app window

A desktop app starts with a root window. The root controls the title and size. Its event loop keeps the interface responsive.

Why Tkinter for this app?

Tkinter uses Python's built-in GUI stack to create a local Windows app. It keeps this project focused on Python fundamentals.

A browser-based tool such as Streamlit requires another package. It also introduces a local server.

  • In the Visual Studio Code file sidebar, create app.py inside the open productivity-quest workspace.
  • Create the root window by pasting this code into app.py:
import tkinter as tk
from tkinter import ttk


class ProductivityQuestApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Productivity Quest")
        self.root.geometry("560x460")


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

What does this code do?

  • The imports provide the standard Tk widgets and the themed ttk widgets used by the interface.
  • The ProductivityQuestApp class stores the root window. It also gives the window its title and size.
  • The final block creates the Tk root. It starts mainloop() so the window can respond to events.
  • Save app.py.

Before you run the file, what title do you expect to see at the top of the window?

  • Open the existing VS Code terminal from your workspace.
  • Launch the first version of the app by running this command:
python app.py

How does the window stay open?

The selected .venv interpreter runs app.py. The mainloop() call keeps the process active while the window is open.

You will see an empty window titled Productivity Quest. Its size is 560 pixels wide by 460 pixels tall.

  • Close the Productivity Quest window to return control to the terminal.

Window not opening?

  • Confirm the selected interpreter belongs to the .venv folder inside productivity-quest.
  • Check that the final three lines remain indented beneath the if __name__ == "__main__": condition.
  • Ask for help with the window error.
Build the quest interface

Widgets give the window a purpose. A linked StringVar holds entry text. A Listbox displays the quests stored in memory.

  • In app.py, place the build_interface() method inside ProductivityQuestApp directly below __init__():
    def build_interface(self):
        container = ttk.Frame(self.root, padding=20)
        container.pack(fill=tk.BOTH, expand=True)

        ttk.Label(container, text="Productivity Quest").pack(pady=(0, 12))

        input_row = ttk.Frame(container)
        input_row.pack(fill=tk.X)

        self.quest_entry = ttk.Entry(input_row, textvariable=self.quest_var)
        self.quest_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)

        ttk.Button(
            input_row,
            text="Add Quest",
        ).pack(side=tk.LEFT, padx=(8, 0))

        self.quest_list = tk.Listbox(
            container,
            height=12,
            selectmode=tk.SINGLE,
        )
        self.quest_list.pack(fill=tk.BOTH, expand=True, pady=12)

        ttk.Label(container, textvariable=self.status_var).pack(pady=(10, 0))

How is the interface arranged?

  • The container frame adds space around the interface. It expands to fill the root window.
  • The input_row frame places the quest entry beside the button.
  • The quest_list reserves the main area for open quests.
  • The status label reads from status_var. This lets the app change its message without rebuilding the label.
  • In __init__(), find the line self.root.geometry("560x460").
  • Add the app state and interface call directly below that line by pasting this code:
        self.quests = []
        self.quest_var = tk.StringVar()
        self.status_var = tk.StringVar(value="Add your first quest.")

        self.build_interface()

What does the app remember?

  • The self.quests list holds each quest while the app is running.
  • The quest_var value mirrors the text in the entry field.
  • The status_var value starts with guidance for the learner.
  • The build_interface() call creates the widgets after those values exist.
  • Save app.py.

Before you rerun the app, which parts of the new interface do you expect to fill most of the window?

  • Display the quest interface by running this command:
python app.py

What does this rerun check?

This rerun executes the expanded class. It confirms that every widget can be created from the new app state.

You will see the Productivity Quest title and a text entry. You will also see an Add Quest button and an empty list. The status message reads Add your first quest.

  • Close the Productivity Quest window before continuing.

Interface missing or compressed?

  • Check that self.build_interface() sits inside __init__().
  • Confirm that build_interface() uses the same four-space class indentation as __init__().
  • Ask for help with the widget layout.
Add quests to the board

A button becomes useful when it has a callback. The callback reads the entry value. It stores a quest dictionary in the list. It then refreshes the visible board.

  • Place the refresh_quests() method inside ProductivityQuestApp directly below build_interface():
    def refresh_quests(self):
        self.quest_list.delete(0, tk.END)

        for quest in self.quests:
            marker = "[Done]" if quest["completed"] else "[Open]"
            self.quest_list.insert(tk.END, f"{marker} {quest['title']}")

How does the list refresh?

  • The method clears the visible Listbox before rebuilding it from self.quests.
  • Each quest dictionary supplies a title and a completion value.
  • The completion value chooses either the [Open] marker or the [Done] marker.
  • In __init__(), find self.build_interface().
  • Replace that line with these two lines:
        self.build_interface()
        self.refresh_quests()

Why refresh during startup?

The first refresh synchronizes the visible list with the in-memory quest data. The list starts empty because self.quests starts empty.

  • Save app.py.
  • Check the startup refresh by running this command:
python app.py

What does this refresh test prove?

The app now calls refresh_quests() during startup. A clean launch confirms that the empty quest list can be rebuilt without an error.

You will see the complete quest board reopen with an empty list. The original status message remains visible below it.

  • Close the Productivity Quest window.
  • Place the add_quest() method inside ProductivityQuestApp directly above refresh_quests():
    def add_quest(self):
        title = self.quest_var.get().strip()
        if not title:
            self.status_var.set("Type a quest before adding it.")
            return

        self.quests.append({"title": title, "completed": False})
        self.quest_var.set("")
        self.status_var.set(f"Added quest: {title}")
        self.refresh_quests()

How is a quest added?

  • The method reads the entry value. It removes extra space from both ends.
  • An empty value updates the status message. It stops before creating a quest.
  • A valid title becomes a dictionary with a False completion value.
  • The final lines clear the entry. They update the status message. They rebuild the quest list.
  • In build_interface(), find text="Add Quest",.
  • Replace that line with these two lines:
            text="Add Quest",
            command=self.add_quest,

How does the button trigger Python?

The button stores self.add_quest as its command callback. Tkinter calls that method whenever you select the button.

  • Save app.py.

Before you run the finished board, what should appear after the callback reads a valid quest title?

  • Launch the working quest board by running this command:
python app.py

What does this final run check?

This run checks the complete event path. The button sends the entry text to add_quest(). The refresh method turns the stored dictionary into a visible list item.

  • Type Finish Python practice into the quest entry.
  • Select Add Quest.

You will see [Open] Finish Python practice in the quest list. The status message confirms that the quest was added.

  • Type Review class notes into the quest entry.
  • Select Add Quest.

You will see both open quests in the list. You did it. Your desktop app now turns typed tasks into visible quests.

Quest not appearing?

  • Check that the button includes command=self.add_quest without parentheses after the method name.
  • Confirm that self.quests.append remains above self.refresh_quests() inside add_quest().
  • Ask for help with the callback.

✔️ Awesome, I've got everything!

Your quest board accepts task titles. Each new item appears with an open marker.

  • Save app.py before moving on.

ⓧ I'd like to double check the full code

Compare your app.py file with the complete quest board below.

import tkinter as tk
from tkinter import ttk


class ProductivityQuestApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Productivity Quest")
        self.root.geometry("560x460")

        self.quests = []
        self.quest_var = tk.StringVar()
        self.status_var = tk.StringVar(value="Add your first quest.")

        self.build_interface()
        self.refresh_quests()

    def build_interface(self):
        container = ttk.Frame(self.root, padding=20)
        container.pack(fill=tk.BOTH, expand=True)

        ttk.Label(container, text="Productivity Quest").pack(pady=(0, 12))

        input_row = ttk.Frame(container)
        input_row.pack(fill=tk.X)

        self.quest_entry = ttk.Entry(input_row, textvariable=self.quest_var)
        self.quest_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)

        ttk.Button(
            input_row,
            text="Add Quest",
            command=self.add_quest,
        ).pack(side=tk.LEFT, padx=(8, 0))

        self.quest_list = tk.Listbox(
            container,
            height=12,
            selectmode=tk.SINGLE,
        )
        self.quest_list.pack(fill=tk.BOTH, expand=True, pady=12)

        ttk.Label(container, textvariable=self.status_var).pack(pady=(10, 0))

    def add_quest(self):
        title = self.quest_var.get().strip()
        if not title:
            self.status_var.set("Type a quest before adding it.")
            return

        self.quests.append({"title": title, "completed": False})
        self.quest_var.set("")
        self.status_var.set(f"Added quest: {title}")
        self.refresh_quests()

    def refresh_quests(self):
        self.quest_list.delete(0, tk.END)

        for quest in self.quests:
            marker = "[Done]" if quest["completed"] else "[Open]"
            self.quest_list.insert(tk.END, f"{marker} {quest['title']}")


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

Your first interactive quest board is working. Next, you will make quests completable. Each completed quest will reward you with XP.

Complete Quests and Earn XP

Your quest board now turns typed tasks into visible quests. It cannot yet recognize finished work as progress.

This step adds a completion action that awards XP. A level display makes each reward visible.

Afterward, a restart test checks how long that progress lasts.

In this step, get ready to:
  • Add an XP total plus a level progress display.
  • Complete a selected quest for one 10 XP reward.
  • Restart the app to test the lifetime of its state.
Add XP state and progress controls

Tkinter widget variables connect Python values to interface controls. Your new variables hold the total XP display plus the progress bar position.

  • In app.py, select everything from the first import through self.refresh_quests() inside __init__().
  • Replace the selected code with this updated setup:
import tkinter as tk
from tkinter import ttk

XP_PER_QUEST = 10
XP_PER_LEVEL = 50


class ProductivityQuestApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Productivity Quest")
        self.root.geometry("560x460")

        self.quests = []
        self.xp = 0
        self.quest_var = tk.StringVar()
        self.status_var = tk.StringVar(value="Add your first quest.")
        self.level_var = tk.StringVar()
        self.progress_var = tk.IntVar()

        self.build_interface()
        self.refresh_quests()

What does this setup add?

  • XP_PER_QUEST sets one completed quest's reward to 10 XP.
  • XP_PER_LEVEL sets each level interval to 50 XP.
  • self.xp holds the total earned during the current session.
  • self.level_var supplies text to the level label.
  • self.progress_var supplies a number to the progress bar.
  • In build_interface(), find the line that packs self.quest_list.
  • Insert this level display above the existing status label:
        ttk.Label(container, textvariable=self.level_var).pack(pady=(14, 4))

        ttk.Progressbar(
            container,
            mode="determinate",
            maximum=XP_PER_LEVEL,
            variable=self.progress_var,
        ).pack(fill=tk.X)

How does the progress display work?

The label reads its text from self.level_var. The progress bar reads its position from self.progress_var.

Determinate mode shows measured progress toward the 50 XP maximum.

  • Save app.py.
  • Launch the updated quest board by running this command:
python app.py

What should you see?

You'll see a thin empty progress bar above the status message. This confirms that the new XP controls are part of the window.

Missing the progress bar?

  • Check that the ttk.Progressbar block sits inside build_interface().
  • Confirm that self.progress_var = tk.IntVar() sits inside __init__().
  • Help me find why the XP progress bar is missing.
  • Close the Productivity Quest window.
Connect quest completion to XP

A callback runs in response to an interface event. Your completion callback uses the selected row to find the matching quest dictionary.

  • In app.py, find def refresh_quests(self):.
  • Insert the new complete_quest() method directly above it:
    def complete_quest(self):
        selection = self.quest_list.curselection()
        if not selection:
            self.status_var.set("Select a quest to complete.")
            return

        quest = self.quests[selection[0]]
        if quest["completed"]:
            self.status_var.set("That quest is already complete.")
            return

        quest["completed"] = True
        self.xp += XP_PER_QUEST
        self.status_var.set(f"Quest complete. You earned {XP_PER_QUEST} XP.")
        self.refresh_quests()

How does completion work?

  • curselection() returns the selected list position.
  • selection[0] identifies the matching dictionary inside self.quests.
  • quest["completed"] stops a completed quest from paying another reward.
  • self.xp += XP_PER_QUEST adds the fixed reward to the session total.
  • self.refresh_quests() redraws the list after completion.
  • In build_interface(), find the label that uses self.level_var.
  • Insert this completion button directly above that label:
        ttk.Button(
            container,
            text="Complete Selected Quest",
            command=self.complete_quest,
        ).pack(fill=tk.X)

How does the button connect?

The button stores self.complete_quest as its command. Tkinter calls that method whenever you select Complete Selected Quest.

  • Save app.py.
  • Launch the completion version by running:
python app.py

What does this run test?

This run checks the connection between the quest list and the completion callback. The status message provides the first visible reward signal.

  • Type Finish Python practice into the quest entry.
  • Select Add Quest.
  • Select [Open] Finish Python practice in the quest list.
  • Select Complete Selected Quest.

You'll see [Done] Finish Python practice in the list. The status message reads Quest complete. You earned 10 XP..

You have the completion loop working. One selected quest now produces a visible reward.

Quest not completing?

  • Select a quest row before choosing Complete Selected Quest.
  • Check that the button contains command=self.complete_quest.
  • Confirm that self.refresh_quests() is the final line of complete_quest().
  • Help me debug quest completion.
  • Close the Productivity Quest window.
Calculate levels and test a restart

The completion callback changes the XP total in Python. The refreshed interface now needs to translate that total into a level plus progress within the current level.

  • In app.py, select the existing refresh_quests() method.
  • Replace the selected method with this XP-aware version:
    def refresh_quests(self):
        self.quest_list.delete(0, tk.END)

        for quest in self.quests:
            marker = "[Done]" if quest["completed"] else "[Open]"
            self.quest_list.insert(tk.END, f"{marker} {quest['title']}")

        level = self.xp // XP_PER_LEVEL + 1
        level_progress = self.xp % XP_PER_LEVEL
        self.level_var.set(f"Level {level} | {self.xp} total XP")
        self.progress_var.set(level_progress)

How is progress calculated?

  • marker displays [Open] or [Done] from each quest's completion value.
  • level uses whole-number division to move up after every 50 XP.
  • level_progress keeps the XP earned within the current level.
  • self.level_var receives the current level plus the total XP.
  • self.progress_var receives the current level's progress.
  • Save app.py.

Before you run this version, what XP total do you expect after one completed quest?

  • Launch the complete XP version by running:
python app.py

What should you see first?

The app opens with an empty quest list. The level label reads Level 1 | 0 total XP above the empty progress bar.

  • Type Finish Python practice into the quest entry.
  • Select Add Quest.
  • Select [Open] Finish Python practice in the quest list.
  • Select Complete Selected Quest.

You'll see [Done] Finish Python practice in the list. The level label now reads Level 1 | 10 total XP.

  • Select the completed quest again.
  • Select Complete Selected Quest again.

The status message reads That quest is already complete. The level label remains at 10 total XP.

XP total not updating?

  • Check that self.xp += XP_PER_QUEST appears after the quest becomes complete.
  • Confirm that self.level_var.set sits inside refresh_quests().
  • Verify that the progress bar uses variable=self.progress_var.
  • Help me debug the XP total.

✔️ Awesome, I've got everything!

Your app.py now contains quest completion plus the complete XP display. Save the file before testing its state across a restart.

ⓧ I'd like to double check the full code

Compare your app.py file with this complete version for the step.

import tkinter as tk
from tkinter import ttk

XP_PER_QUEST = 10
XP_PER_LEVEL = 50


class ProductivityQuestApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Productivity Quest")
        self.root.geometry("560x460")

        self.quests = []
        self.xp = 0
        self.quest_var = tk.StringVar()
        self.status_var = tk.StringVar(value="Add your first quest.")
        self.level_var = tk.StringVar()
        self.progress_var = tk.IntVar()

        self.build_interface()
        self.refresh_quests()

    def build_interface(self):
        container = ttk.Frame(self.root, padding=20)
        container.pack(fill=tk.BOTH, expand=True)

        ttk.Label(container, text="Productivity Quest").pack(pady=(0, 12))

        input_row = ttk.Frame(container)
        input_row.pack(fill=tk.X)

        self.quest_entry = ttk.Entry(input_row, textvariable=self.quest_var)
        self.quest_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)

        ttk.Button(
            input_row,
            text="Add Quest",
            command=self.add_quest,
        ).pack(side=tk.LEFT, padx=(8, 0))

        self.quest_list = tk.Listbox(
            container,
            height=12,
            selectmode=tk.SINGLE,
        )
        self.quest_list.pack(fill=tk.BOTH, expand=True, pady=12)

        ttk.Button(
            container,
            text="Complete Selected Quest",
            command=self.complete_quest,
        ).pack(fill=tk.X)

        ttk.Label(container, textvariable=self.level_var).pack(pady=(14, 4))

        ttk.Progressbar(
            container,
            mode="determinate",
            maximum=XP_PER_LEVEL,
            variable=self.progress_var,
        ).pack(fill=tk.X)

        ttk.Label(container, textvariable=self.status_var).pack(pady=(10, 0))

    def add_quest(self):
        title = self.quest_var.get().strip()
        if not title:
            self.status_var.set("Type a quest before adding it.")
            return

        self.quests.append({"title": title, "completed": False})
        self.quest_var.set("")
        self.status_var.set(f"Added quest: {title}")
        self.refresh_quests()

    def complete_quest(self):
        selection = self.quest_list.curselection()
        if not selection:
            self.status_var.set("Select a quest to complete.")
            return

        quest = self.quests[selection[0]]
        if quest["completed"]:
            self.status_var.set("That quest is already complete.")
            return

        quest["completed"] = True
        self.xp += XP_PER_QUEST
        self.status_var.set(f"Quest complete. You earned {XP_PER_QUEST} XP.")
        self.refresh_quests()

    def refresh_quests(self):
        self.quest_list.delete(0, tk.END)

        for quest in self.quests:
            marker = "[Done]" if quest["completed"] else "[Open]"
            self.quest_list.insert(tk.END, f"{marker} {quest['title']}")

        level = self.xp // XP_PER_LEVEL + 1
        level_progress = self.xp % XP_PER_LEVEL
        self.level_var.set(f"Level {level} | {self.xp} total XP")
        self.progress_var.set(level_progress)


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

The app now proves completion within one running session. A restart checks the lifetime of that in-memory state.

  • Close the Productivity Quest window.

Before you reopen the app, do you expect the completed quest plus its XP to survive?

  • Reopen the app by running this command:
python app.py

What should you see?

You'll see an empty quest list. The level label has returned to Level 1 | 0 total XP.

That reset is the intended shortfall. Closing the app ended the Python process that held self.quests plus self.xp.

  • Close the Productivity Quest window after confirming the reset.

You have exposed the restart reset. Next, you'll store progress in JSON so the app can restore each quest plus its XP.

Save Progress Between Sessions

Your quest board now marks completed quests. The restart test proved that every quest and XP point disappears when the process ends.

JSON persistence gives that in-memory state a home on disk. Your app will save each change to quests.json.

It will restore that saved state before the interface refreshes. The result is a tracker you can close without erasing your progress.

In this step, get ready to:
  • Load saved quest data with safe defaults for a missing or invalid file.
  • Write every quest change to quests.json.
  • Restart the app to prove the full progress state returns.
Prepare the JSON loader

The quests.json file stores the app's state as a JSON object. A safe loader also needs a starting value when the file is missing or invalid.

  • In app.py, replace the import block at the top with this expanded setup:
import json
import tkinter as tk
from tkinter import ttk

DATA_FILE = "quests.json"
XP_PER_QUEST = 10
XP_PER_LEVEL = 50

What Does This Setup Add?

  • The json module converts saved JSON into Python values. It also converts Python values back into JSON.
  • DATA_FILE keeps the storage filename in one place.
  • Save app.py.
  • Confirm the new setup still launches by running this command:
python app.py

What Does This Check Prove?

This run confirms that the new import and filename constant contain no syntax errors. Your existing quest board should still open normally.

You will see the quest board open with 0 total XP. The storage code has not loaded anything yet.

  • Close the Productivity Quest window.

App Not Opening?

  • Check that import json appears above the existing Tkinter imports.
  • Check that DATA_FILE uses matching quotation marks around quests.json.
  • Help me fix the new JSON setup in app.py.

The loader reads the file when it exists. Missing files or invalid JSON receive an empty quest list with zero XP, which keeps the first launch usable.

  • Add the load_data() method directly below refresh_quests() by pasting this code:
    def load_data(self):
        try:
            with open(DATA_FILE, "r", encoding="utf-8") as data_file:
                data = json.load(data_file)
        except (FileNotFoundError, json.JSONDecodeError):
            data = {"quests": [], "xp": 0}

        self.quests = data.get("quests", [])
        self.xp = data.get("xp", 0)

What Does the Loader Do?

  • open() reads quests.json as UTF-8 text.
  • json.load() turns the saved object into the data dictionary.
  • FileNotFoundError covers the first run before a save file exists.
  • json.JSONDecodeError protects the app from invalid JSON.
  • data.get() restores the saved quests and XP with safe defaults.
  • In __init__(), find the line containing self.build_interface().
  • Insert the loader above that line so the three calls look like this:
        self.load_data()
        self.build_interface()
        self.refresh_quests()

Why Load Before Refreshing?

The loader fills self.quests with saved data before the first refresh. The interface can display restored progress as soon as the window opens.

  • Save app.py.
  • Test the safe default by running the app again:
python app.py

What Should This Run Confirm?

The app attempts to read quests.json. The safe default lets the empty quest board open when that file does not exist.

You will see the empty board with 0 total XP. This proves the missing-file path works.

  • Close the Productivity Quest window.

Loader Stopping the App?

  • Check that load_data() is indented at the same level as refresh_quests().
  • Check that the except line catches both exception types inside one pair of parentheses.
  • Help me debug the load_data method.
Save every quest change

Loading solves only half of persistence. The app must also write a fresh snapshot whenever a quest changes.

  • Add the save_data() method directly below load_data() by pasting this code:
    def save_data(self):
        data = {"quests": self.quests, "xp": self.xp}
        with open(DATA_FILE, "w", encoding="utf-8") as data_file:
            json.dump(data, data_file, indent=2)

What Does the Saver Do?

  • The data dictionary groups the current quest list with the current XP value.
  • open() creates or replaces quests.json using UTF-8 text.
  • json.dump() writes the dictionary with two-space indentation for readability.
  • In add_quest(), find the line containing self.status_var.set(f"Added quest: {title}").
  • Insert the save call below that line so the end of the method looks like this:
        self.status_var.set(f"Added quest: {title}")
        self.save_data()
        self.refresh_quests()

Why Save Here?

The quest dictionary already exists in self.quests at this point. Saving here captures the new quest before the interface refreshes.

  • Save app.py.
  • Launch the updated app by running this command:
python app.py

What Happens When You Add a Quest?

The new save call creates quests.json when you add a quest. The file records the open quest with zero XP.

  • Type Finish Python practice into the quest entry box.
  • Select Add Quest.
  • Close the Productivity Quest window.
  • Return to the file list in Visual Studio Code.

You now see quests.json beside app.py. Your first saved quest now has a durable copy on disk.

Save File Missing?

  • Check that self.save_data() appears inside add_quest() before self.refresh_quests().
  • Check that you added a quest before closing the window.
  • Help me find why quests.json was not created.

Quest completion changes both the selected dictionary and the XP total. That event needs the same save step so the whole progress snapshot stays synchronized.

  • In complete_quest(), find the line containing the completed-quest status message.
  • Insert the save call below that line so the end of the method looks like this:
        self.status_var.set(f"Quest complete. You earned {XP_PER_QUEST} XP.")
        self.save_data()
        self.refresh_quests()

What Does This Save Capture?

The saved quest now has completed set to True. The same snapshot includes the updated self.xp value.

  • Save app.py.
  • Launch the app by running this command:
python app.py

What Should the App Load?

The app reads the quest you saved during the previous run. You should see [Open] Finish Python practice in the list.

  • Select [Open] Finish Python practice in the quest list.
  • Select Complete Selected Quest.
  • Close the Productivity Quest window.

The app shows 10 total XP before it closes. The completed status and XP value are now saved together.

Completion Not Saved?

  • Check that the save call appears after the XP update inside complete_quest().
  • Check that the save call remains above self.refresh_quests().
  • Help me debug quest completion persistence.
Prove progress survives a restart

The app now writes every meaningful change. The restart test proves that the loader can rebuild the same visible state from quests.json.

Before the restart test, consider whether the completed quest and 10 XP return when the app reads the saved file.

  • Reopen Productivity Quest by running this command:
python app.py

What Does the Restart Test Prove?

A new Python process starts with fresh memory. The restored board proves that the visible state came from quests.json.

You will see [Done] Finish Python practice in the quest list. The level label will show Level 1 | 10 total XP.

That restart closes the persistence loop. Your tracker now keeps real progress between sessions.

  • Close the Productivity Quest window.
  • Select quests.json from the file list in Visual Studio Code.
  • Find the quests array in the saved JSON object.
  • Find the xp value in the same object.

The quests array contains a dictionary for Finish Python practice with completed set to true. The xp key stores 10.

Progress Still Resetting?

  • Check that self.load_data() runs before self.build_interface().
  • Check that both state-changing methods call self.save_data().
  • Check that DATA_FILE uses the same quests.json filename for reading and writing.
  • Help me fix the restart test.

✔️ Awesome, I've got everything!

Your persistent version is complete. The app now restores its saved quest board after every restart.

ⓧ I'd like to double check the full code

This is the complete persistent version of app.py.

import json
import tkinter as tk
from tkinter import ttk

DATA_FILE = "quests.json"
XP_PER_QUEST = 10
XP_PER_LEVEL = 50


class ProductivityQuestApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Productivity Quest")
        self.root.geometry("560x460")

        self.quests = []
        self.xp = 0
        self.quest_var = tk.StringVar()
        self.status_var = tk.StringVar(value="Add your first quest.")
        self.level_var = tk.StringVar()
        self.progress_var = tk.IntVar()

        self.load_data()
        self.build_interface()
        self.refresh_quests()

    def build_interface(self):
        container = ttk.Frame(self.root, padding=20)
        container.pack(fill=tk.BOTH, expand=True)

        ttk.Label(container, text="Productivity Quest").pack(pady=(0, 12))

        input_row = ttk.Frame(container)
        input_row.pack(fill=tk.X)

        self.quest_entry = ttk.Entry(input_row, textvariable=self.quest_var)
        self.quest_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)

        ttk.Button(
            input_row,
            text="Add Quest",
            command=self.add_quest,
        ).pack(side=tk.LEFT, padx=(8, 0))

        self.quest_list = tk.Listbox(
            container,
            height=12,
            selectmode=tk.SINGLE,
        )
        self.quest_list.pack(fill=tk.BOTH, expand=True, pady=12)

        ttk.Button(
            container,
            text="Complete Selected Quest",
            command=self.complete_quest,
        ).pack(fill=tk.X)

        ttk.Label(container, textvariable=self.level_var).pack(pady=(14, 4))

        ttk.Progressbar(
            container,
            mode="determinate",
            maximum=XP_PER_LEVEL,
            variable=self.progress_var,
        ).pack(fill=tk.X)

        ttk.Label(container, textvariable=self.status_var).pack(pady=(10, 0))

    def add_quest(self):
        title = self.quest_var.get().strip()
        if not title:
            self.status_var.set("Type a quest before adding it.")
            return

        self.quests.append({"title": title, "completed": False})
        self.quest_var.set("")
        self.status_var.set(f"Added quest: {title}")
        self.save_data()
        self.refresh_quests()

    def complete_quest(self):
        selection = self.quest_list.curselection()
        if not selection:
            self.status_var.set("Select a quest to complete.")
            return

        quest = self.quests[selection[0]]
        if quest["completed"]:
            self.status_var.set("That quest is already complete.")
            return

        quest["completed"] = True
        self.xp += XP_PER_QUEST
        self.status_var.set(f"Quest complete. You earned {XP_PER_QUEST} XP.")
        self.save_data()
        self.refresh_quests()

    def refresh_quests(self):
        self.quest_list.delete(0, tk.END)

        for quest in self.quests:
            marker = "[Done]" if quest["completed"] else "[Open]"
            self.quest_list.insert(tk.END, f"{marker} {quest['title']}")

        level = self.xp // XP_PER_LEVEL + 1
        level_progress = self.xp % XP_PER_LEVEL
        self.level_var.set(f"Level {level} | {self.xp} total XP")
        self.progress_var.set(level_progress)

    def load_data(self):
        try:
            with open(DATA_FILE, "r", encoding="utf-8") as data_file:
                data = json.load(data_file)
        except (FileNotFoundError, json.JSONDecodeError):
            data = {"quests": [], "xp": 0}

        self.quests = data.get("quests", [])
        self.xp = data.get("xp", 0)

    def save_data(self):
        data = {"quests": self.quests, "xp": self.xp}
        with open(DATA_FILE, "w", encoding="utf-8") as data_file:
            json.dump(data, data_file, indent=2)


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

What Should Match?

Your file should include the JSON import and storage constant. It should also include both persistence methods plus their calls inside the app lifecycle.

Secret mission

Add Difficulty-Based XP

Give every quest an Easy, Medium, or Hard difficulty. Your app will save the choice, show it on the quest board, and award 10, 20, or 30 XP when the quest is completed.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project lives on your Windows computer. Your files create no ongoing charges.

Choose whether to keep the workspace available, pause your work, or delete the local files.

Resources you used:

  • The productivity-quest workspace folder.
  • The app.py desktop application.
  • The .venv project-local virtual environment.
  • The quests.json JSON data file containing your saved progress.

Keep everything running

No action is needed. Choose this option if you want to keep using the tracker or extend its features.

  • Keep the productivity-quest folder in its current location.
  • Keep app.py, .venv, and quests.json together so the workspace remains complete.
  • Continue using the workspace in Visual Studio Code whenever you want to add more features.

Pause - I'll come back to this later

Pausing ends your active local sessions. Every project file stays on disk for your next session.

  • Close Productivity Quest with the window's X.
  • Close Visual Studio Code.
  • Leave the productivity-quest folder in its current location.

Your complete saved progress remains in quests.json for your next session.

Delete - I don't want to use this again

Deleting the folder permanently removes the application files. Your virtual environment and saved progress disappear too.

  • Close Productivity Quest with the window's X.
  • Close Visual Studio Code.
  • Press the Windows key to open search.
  • Type File Explorer into the search box.
  • Press Enter to open File Explorer.
  • Locate the productivity-quest folder you opened earlier.
  • Select the productivity-quest folder.
  • Press Shift+Delete to request permanent deletion.
  • Confirm the deletion when Windows asks.

You should no longer see the productivity-quest folder in File Explorer. This confirms that the app files and saved progress are removed.

Nice Work!

Nice Work!

Quest complete! You've built a Python desktop quest tracker with Tkinter.

You've learned how to:

  • Verified a Python workspace running version 3.14.7 inside a project-local .venv. Confirmed Tkinter by opening its test window.
  • Built an event-driven quest board that turns tasks into selectable quests. Used callbacks to respond to button clicks. Applied completion rules that prevent duplicate rewards. Connected widget variables to the XP display.
  • Observed the reset caused by in-memory state. Added JSON persistence that restores the entire board from quests.json after a restart.
  • Secret Mission: Added a difficulty selector with three reward tiers. Easy quests award 10 XP. Medium quests award 20 XP. Hard quests award 30 XP. Older saved quests still load with Easy difficulty.

Ready to quiz yourself?