Build a D&D Companion App with Python

Build a browser-based D&D app in pure Python with live monster data.

Introduction

30 Second Summary

Every Dungeons & Dragons session involves flipping through pages of monster stats and character options. What if you could replace all that page-flipping with a searchable, interactive app you built yourself?

In this project, you will build a D&D Companion App that runs in your browser, written entirely in Python. You will pull live data from a REST API to power a searchable Monster Guide and an interactive Character Builder.

What You'll Build

Open your app, search through all 334 official D&D monsters, click one to see its full stat block, then switch tabs to build a character complete with racial bonuses and ability score modifiers.

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

  • A searchable Monster Guide where you type a name and instantly filter through all 334 D&D monsters, then click any one to see its full stat block with HP, AC, abilities, and actions.
  • A Character Builder where you pick a race and class, set your ability scores, and watch the app compute totals with racial bonuses and modifiers.
  • A dark-themed app with tabbed navigation you can demo right in your browser, built entirely in Python using NiceGUI.
  • Secret Mission: a custom monster creator that lets you add your own homebrew creatures to the Monster Guide.

Are there any prerequisites?

You need basic Python knowledge: variables, functions, loops, and dictionaries. No web development or D&D experience is needed.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters for your learning.

Set Up Python and NiceGUI

Your D&D Companion App is built entirely in Python. To run it, you need Python installed on your machine along with two packages: NiceGUI (the framework that turns Python code into a browser-based UI) and requests (the library that fetches data from the D&D 5e API).

In this step, you will install Python, set up a project folder, install the packages you need, and launch a minimal app to prove everything works.

In this step, get ready to:
  • Install Python and verify it is on your PATH.
  • Create a project folder and install NiceGUI and requests.
  • Run a minimal NiceGUI app in your browser.
Check your Python version

Python is the programming language your entire app runs on. Before writing any code, you need to confirm it is installed and that the version is 3.10 or higher (NiceGUI requires this).

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

Look at the output in your terminal and find your situation below.

✔️ I see version 3.10 or higher

You are all set. Python is installed and ready to go.

ⓧ I see an older version

Your Python version is too old for NiceGUI. You need 3.10 or higher.

  • Open https://www.python.org/downloads/ in your browser.
  • Download the latest Python 3.14 installer for your operating system.
  • Run the installer. On Windows, check the Add Python to PATH checkbox before clicking Install Now.
  • Close your terminal and open a new one, then re-run the version check command above.

macOS version shadowing

macOS ships with an older Python that can shadow a newer install. After installing Python 3.14 from python.org, you may still see the old version when you run python3 --version.

Try python3.14 --version instead. If that works, use python3.14 wherever these instructions say python3.

ⓧ Command not found

Python is not installed yet. Let's fix that.

  • Open https://www.python.org/downloads/ in your browser.
  • Download the latest Python 3.14 installer for your operating system.
  • Run the installer. On Windows, check the Add Python to PATH checkbox before clicking Install Now.
  • Close your terminal and open a new one, then re-run the version check command above.

Python command on macOS vs Windows

On macOS, the Python command is usually python3 and pip3. On Windows, it is usually python and pip. Throughout this project, use whichever version works on your machine.

Create your project folder and install packages
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir dnd-companion
cd dnd-companion
  • Confirm the folder exists by running:
ls

The output should be empty because the folder is brand new. That is expected.

  • Install NiceGUI and requests by running:

macOS

pip3 install nicegui==3.15.0 requests==2.34.2

Windows

pip install nicegui==3.15.0 requests==2.34.2

What does this command do?

pip is Python's package installer. This command downloads two libraries and pins them to exact versions so your project behaves predictably.

  • nicegui==3.15.0 is the UI framework that lets you build browser-based apps entirely in Python.
  • requests==2.34.2 is the library you will use to fetch monster, race, and class data from the D&D 5e API.
  • Verify the install succeeded by running:

macOS

pip3 show nicegui

Windows

pip show nicegui

You should see output that includes Name: nicegui and Version: 3.15.0. That confirms the package installed correctly.

Seeing 'pip: command not found'?

  • On macOS, try pip3 instead of pip.
  • If that also fails, try running python3 -m pip followed by the rest of the install command.
  • On Windows, make sure you checked Add Python to PATH during the Python installer. If you missed it, re-run the installer and check that box.
  • Close and reopen your terminal after installing Python. The PATH changes only take effect in a new terminal session.

Still stuck? Help me troubleshoot pip not being found after installing Python.

Create and run your first NiceGUI app

With Python and NiceGUI installed, it is time to write your first three lines of code and see them running in the browser.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the dnd-companion folder on your Desktop, then click Open.
  • In the Explorer sidebar on the left, hover over the DND-COMPANION header and click the New File icon.
  • Type main.py and press Enter to create the file.
  • Paste the following code into main.py:
# Import the NiceGUI UI toolkit
from nicegui import ui

# Display a greeting label on the page
ui.label('Hello, Adventurer!')

# Start the NiceGUI server on port 8080
ui.run(title='D&D Companion', port=8080)

What does this code do?

  • from nicegui import ui loads NiceGUI's UI toolkit so you can use components like labels, buttons, and tables.
  • ui.label('Hello, Adventurer!') creates a text label that appears on the page.
  • ui.run(title='D&D Companion', port=8080) starts a local web server. NiceGUI uses FastAPI and Uvicorn under the hood, so your Python file becomes a fully interactive web app.
  • Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Before you run this, what do you think will happen? Will you see output in the terminal, in the browser, or both?

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, below Escape).
  • Run the app with this command:

macOS

python3 main.py

Windows

python main.py

Your terminal prints a message that includes NiceGUI ready to go followed by a URL. A browser tab automatically opens showing the text "Hello, Adventurer!" on the page.

Why did both the terminal and the browser change?

NiceGUI starts a web server in your terminal (that is the "ready to go" message). It also opens your default browser to show the UI it is serving. Your Python code is the server. The browser is just displaying what it sends.

Browser did not open?

  • If the terminal shows the ready message but no browser appeared, open your browser manually and navigate to http://localhost:8080.
  • If you see an error about port 8080 being in use, another process is occupying that port. Close other running servers or restart your computer.
  • If you see ModuleNotFoundError: No module named 'nicegui', the pip install from the previous substep did not succeed. Re-run the install command and try again.

Still stuck? Help me get my NiceGUI app running in the browser.

  • When you are done, press Ctrl+C in the terminal to stop the server.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that the server stopped after you pressed Ctrl+C.

ⓧ I'd like to double check the full code

Here is the complete main.py you should have so far. Compare it with yours line by line.

from nicegui import ui

ui.label('Hello, Adventurer!')

ui.run(title='D&D Companion', port=8080)

Your environment is ready and you have proof that NiceGUI can serve a page in the browser. Next up, you will connect to the D&D 5e API and fill that page with 334 real monsters.

Build the Monster Guide Page

Your environment is ready and your app is running. Now it's time to build the first real feature: a Monster Guide powered by live D&D data.

The D&D 5e SRD API is a free, open API that serves every monster, class, and race from the D&D System Reference Document. In this step, you'll fetch all 334 monsters from it and display them in a searchable table in your browser.

In this step, get ready to:
  • Fetch all 334 monsters from the D&D 5e SRD API using Python's requests library.
  • Display the monsters in a searchable, sortable table.
  • Add dark mode and a header to style the app.
Fetch all monsters from the D&D 5e API

The D&D 5e SRD API lives at https://www.dnd5eapi.co/api/2014. It requires no API key, no sign-up, and no authentication. You send a GET request and get JSON back.

  • Switch back to your terminal and stop the running NiceGUI server by pressing Ctrl+C.
  • Return to main.py in VS Code. Select all the existing code and delete it. You are starting fresh.
  • Add the imports, API base URL, and a helper function by pasting this code into the empty file:
import requests
from nicegui import ui

# Base URL for the D&D 5e SRD API
API_BASE = 'https://www.dnd5eapi.co/api/2014'

# Placeholder list for custom monsters (used in a later step)
custom_monsters = []


def fetch_json(path):
    # Send a GET request to the API and return parsed JSON
    response = requests.get(f'{API_BASE}{path}')
    response.raise_for_status()
    return response.json()

What does this code do?

  • API_BASE stores the root URL so every API call starts from the same place.
  • fetch_json(path) takes a path like /monsters, combines it with the base URL, sends the request, and returns a Python dictionary from the JSON response.
  • raise_for_status() stops the script with a clear error if the API returns a failure code (like 404 or 500) instead of silently continuing.
  • Below the code you just pasted, add this main() function that fetches the monster list and displays how many it found:
def main():
    # Fetch all monsters from the API
    monsters_response = fetch_json('/monsters')
    monster_rows = [{'name': m['name'], 'index': m['index']} for m in monsters_response['results']]

    ui.label(f'Loaded {len(monster_rows)} monsters')

    ui.run(title='D&D Companion', port=8080)


main()

What does this code do?

  • fetch_json('/monsters') calls the API endpoint that returns the full list of 334 SRD monsters.
  • The list comprehension extracts each monster's name and index (a URL-friendly ID like aboleth) into a list of dictionaries.
  • ui.label() displays the count so you can confirm the API connection works. You will replace this label with a proper table in the next chunk.
  • Save main.py.
  • Run the app by entering this command in your terminal:
python main.py

Your browser opens to http://localhost:8080. You should see the text "Loaded 334 monsters" on the page.

Seeing an error instead of the count?

  • If you see ModuleNotFoundError: No module named 'requests', the requests library is not installed. Run pip install requests==2.34.2 and try again.
  • If the page loads but shows 0 monsters, check that your internet connection is active. The app needs to reach dnd5eapi.co to fetch data.
  • If the port is still in use from the previous run, make sure you pressed Ctrl+C in the terminal to stop the old server first.

Why can't my app connect to the D&D 5e API?

Display monsters in a searchable table

A plain label with a number is proof the API works, but your Monster Guide needs an actual table. NiceGUI's ui.table component handles columns, rows, pagination, and text filtering out of the box.

  • Stop the running server with Ctrl+C.
  • In main.py, add this new function above def main(): to build the monster table:
def build_monster_guide(monster_rows):
    ui.label('Monster Guide').classes('text-h4')

    # Create a search input that filters the table
    search = ui.input(label='Search monsters...').classes('w-64')

    # Define the table columns
    columns = [
        {'name': 'name', 'label': 'Name', 'field': 'name', 'align': 'left', 'sortable': True},
        {'name': 'index', 'label': 'Index', 'field': 'index', 'align': 'left'},
    ]

    # Create the monster table with pagination and single-row selection
    table = ui.table(
        columns=columns,
        rows=monster_rows,
        row_key='index',
        selection='single',
        pagination=20,
    ).classes('w-full')

    # Bind the search input to the table's built-in filter
    search.bind_value(table, 'filter')

    return table

What does this code do?

  • ui.table() creates an interactive table with two columns (Name and Index), showing 20 rows per page.
  • selection='single' lets the user click a row to select it. You will wire this up to show monster details in the next step.
  • search.bind_value(table, 'filter') connects the search input to the table's built-in filter. Whatever the user types automatically filters every column.

Now update main() to use this new function instead of the temporary label.

  • Inside def main(), find this line:
    ui.label(f'Loaded {len(monster_rows)} monsters')
  • Replace it with a call to the new function:
    build_monster_guide(monster_rows)
  • Save main.py and run the app again:
python main.py

Your browser shows a table listing all 334 monsters with Name and Index columns. You can page through them 20 at a time.

Table not showing up?

  • Make sure the build_monster_guide function is defined above def main(): in the file. Python reads top to bottom, so main() must be able to see it.
  • Check that you deleted the old ui.label line and replaced it with build_monster_guide(monster_rows).

Why isn't my monster table rendering?

Add dark mode and a header

The table works, but the page has a plain white background and no title bar. NiceGUI's ui.dark_mode() switches the entire UI to a dark theme in one line. You will also add a sticky header with the app title and a toggle switch.

  • Stop the running server with Ctrl+C.
  • In main.py, replace the entire main() function and the main() call at the bottom with this updated version:
def main():
    # Enable dark mode by default
    dark = ui.dark_mode(True)

    # Create a sticky header with the app title and a dark mode toggle
    with ui.header().classes('items-center justify-between'):
        ui.label('D&D Companion').classes('text-h5')
        ui.switch('Dark Mode', value=True).bind_value(dark)

    # Fetch all monsters from the API
    monsters_response = fetch_json('/monsters')
    monster_rows = [{'name': m['name'], 'index': m['index']} for m in monsters_response['results']]

    build_monster_guide(monster_rows)

    ui.run(title='D&D Companion', port=8080)


main()

What changed here?

  • ui.dark_mode(True) turns on the dark theme immediately when the app loads.
  • ui.header() creates a sticky bar at the top. Inside it, a label shows "D&D Companion" and a toggle switch lets the user flip between dark and light mode.
  • .bind_value(dark) links the switch to the dark mode object. Flipping the switch toggles the theme in real time.
  • Save main.py and run the app:
python main.py

Your browser shows a dark-themed page with a "D&D Companion" header at the top and the full monster table below it. Try flipping the Dark Mode toggle to see the theme switch.

Before you type anything in the search box, what do you think will happen when you type "goblin"?

Type goblin in the search box. The table instantly filters to show only matching monsters. Clear the search to bring back all 334.

Dark mode not working or header missing?

  • Make sure ui.dark_mode(True) is the very first line inside def main(), before the header or any other UI elements.
  • Check that the with ui.header() block is indented inside def main(). If it sits outside the function, NiceGUI will not render it.
  • If the toggle switch does not change the theme, verify that .bind_value(dark) is chained after ui.switch() and that dark matches the variable name from the ui.dark_mode(True) line.

Why isn't dark mode or the header showing in my NiceGUI app?

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py.

ⓧ I'd like to double check the full code

Your main.py should contain these sections in order. Scroll up and confirm each one is present:

  • Imports: import requests and from nicegui import ui.
  • Constants: API_BASE set to the D&D 5e API URL, and an empty custom_monsters list.
  • fetch_json(path): Helper function that sends a GET request and returns parsed JSON.
  • build_monster_guide(monster_rows): Creates the Monster Guide heading, search input, table with Name and Index columns, and binds the search to the filter.
  • main(): Enables dark mode, creates the header with a dark mode toggle, fetches monsters, calls build_monster_guide(), and runs the app on port 8080.
  • main() call: A bare main() at the very bottom of the file to start the app.

Your Monster Guide is live with all 334 SRD monsters at your fingertips. Next up, you'll make clicking a monster actually do something: fetching and displaying its full stat block with HP, AC, abilities, and actions.

Add Monster Stat Block Details

Your Monster Guide lists all 334 creatures, but clicking one does nothing. A Dungeon Master needs the full stat block to run an encounter.

In this step, you will wire up row selection so that clicking any monster fetches its complete data from the D&D 5e SRD API and displays a detailed stat block card with HP, AC, ability scores, and more.

In this step, get ready to:
  • Wire up an on_select callback to fetch monster data from the API.
  • Display combat stats (HP, AC, speed) in a detail card.
  • Show all six ability scores with modifiers, plus special abilities and actions.
Wire up row selection to fetch monster data

Your table already has selection='single' enabled from the previous step. Now you need a callback function that fires when a row is selected, fetches that monster's full data from the API, and renders it.

  • In main.py, find the line search.bind_value(table, 'filter') inside the build_monster_guide function.
  • Add the following code between that line and return table:
    # Container that holds the monster detail card
    detail_container = ui.column().classes('w-full')

    def show_monster_detail(e):
        # Clear any previous detail card
        detail_container.clear()
        if not e.selection:
            return
        selected = e.selection[0]
        index = selected['index']

        # Look up custom monsters locally, fetch SRD monsters from API
        if index.startswith('custom-'):
            for cm in custom_monsters:
                if cm['index'] == index:
                    monster = cm
                    break
            else:
                return
        else:
            monster = fetch_json(f'/monsters/{index}')

        # Display the monster name in a detail card
        with detail_container:
            with ui.card().classes('w-full'):
                ui.label(monster.get('name', 'Unknown')).classes('text-h5')

    # Fire show_monster_detail when a table row is selected
    table.on_select(show_monster_detail)

What does this code do?

detail_container is an empty column that sits below the table. Every time you click a monster, the callback clears it and rebuilds it with fresh content.

The if index.startswith('custom-') check is future-proofing. Later in the Secret Mission you will add homebrew monsters stored locally. For now, every click hits the API endpoint https://www.dnd5eapi.co/api/2014/monsters/{index} to fetch the full stat block.

  • Save main.py. NiceGUI auto-reloads on save, so your browser should refresh automatically.
  • Click any monster in the table. You should see a card appear below the table showing just the monster's name.

Don't see a card when you click?

Make sure the new code is indented inside the build_monster_guide function, at the same level as the search and table variables. The return table line must come after table.on_select(show_monster_detail).

If the browser did not refresh, stop the server with Ctrl+C and run python main.py again.

help me debug my on_select callback in NiceGUI

Display the monster's combat stats

The API returns armor_class as an array of objects and speed as a dictionary with keys like walk and fly. The code below handles both formats gracefully.

  • In the show_monster_detail function, find this line:
                ui.label(monster.get('name', 'Unknown')).classes('text-h5')
  • Add the following code directly below it, inside the with ui.card() block:
                # Show HP, AC, and speed in a horizontal row
                with ui.row().classes('gap-8'):
                    hp = monster.get('hit_points', 'N/A')
                    ui.label(f'HP: {hp}')

                    # API returns armor_class as an array of objects
                    ac_data = monster.get('armor_class', [])
                    if isinstance(ac_data, list) and len(ac_data) > 0:
                        ac_val = ac_data[0].get('value', 'N/A')
                    elif isinstance(ac_data, (int, float)):
                        ac_val = ac_data
                    else:
                        ac_val = 'N/A'
                    ui.label(f'AC: {ac_val}')

                    # API returns speed as a dict of movement types
                    speed = monster.get('speed', {})
                    if isinstance(speed, dict):
                        speed_str = ', '.join(f'{k}: {v}' for k, v in speed.items())
                    else:
                        speed_str = str(speed)
                    ui.label(f'Speed: {speed_str}')

Why is armor_class handled differently?

The D&D 5e API returns armor_class as an array like [{"type": "armor", "value": 15}]. The code extracts the numeric value from the first entry. The isinstance checks handle edge cases where the format might differ.

  • Save main.py and wait for the auto-reload.
  • Click any monster in the table. You should now see its name, HP, AC, and speed displayed in the detail card.

Seeing an indentation error?

The with ui.row() block must be indented to the same level as the ui.label(monster.get('name'...)) line above it. Both sit inside the with ui.card() context manager.

help me fix an indentation error in my NiceGUI show_monster_detail function

Display ability scores, special abilities, and actions

Every D&D monster has six ability scores. The standard modifier formula is (score - 10) // 2. A score of 14 gives a modifier of +2. A score of 8 gives -1.

  • In the show_monster_detail function, find the closing line of the ui.row block you just added (the last ui.label(f'Speed: {speed_str}') line).
  • Add the following code below it, still inside the with ui.card() block:
                ui.separator()
                ui.label('Ability Scores').classes('text-subtitle1')

                # The six core D&D ability scores
                ability_names = ['strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma']
                ability_abbr = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']

                with ui.row().classes('gap-4'):
                    for abbr, full_name in zip(ability_abbr, ability_names):
                        score = monster.get(full_name, 10)
                        # Standard D&D modifier: (score - 10) // 2
                        modifier = (score - 10) // 2
                        sign = '+' if modifier >= 0 else ''
                        with ui.column().classes('items-center'):
                            ui.label(abbr).classes('text-weight-bold')
                            ui.label(str(score))
                            ui.label(f'({sign}{modifier})').classes('text-caption')

What does this code do?

The loop pairs each abbreviation (STR, DEX, etc.) with the full JSON key (strength, dexterity, etc.) and reads the score from the monster data. Python's floor division (//) handles odd scores correctly. A score of 9 produces (9 - 10) // 2 = -1, not -0.5.

Now add the final piece. Many monsters have special abilities (like a dragon's Frightful Presence) and actions (like a Bite attack).

  • Directly below the ability scores ui.row block you just added, paste the following code at the same indentation level:
                # Display special abilities if the monster has any
                special = monster.get('special_abilities', [])
                if special:
                    ui.separator()
                    ui.label('Special Abilities').classes('text-subtitle1')
                    for ability in special:
                        ui.label(ability.get('name', '')).classes('text-weight-bold')
                        ui.label(ability.get('desc', ''))

                # Display actions if the monster has any
                actions = monster.get('actions', [])
                if actions:
                    ui.separator()
                    ui.label('Actions').classes('text-subtitle1')
                    for action in actions:
                        ui.label(action.get('name', '')).classes('text-weight-bold')
                        ui.label(action.get('desc', ''))

What does this code do?

Both sections check if the list is non-empty before rendering. Not every monster has special abilities, so the if special guard prevents empty headings from appearing. Each ability and action displays its name in bold followed by its full description.

Before you run this, think about what you expect to see when you click a monster like a Goblin. What sections do you think will appear in the stat block?

  • Save main.py and wait for the auto-reload.
  • Click any monster in the table. You should see a full stat block card appear below the table showing the monster's name, HP, AC, speed, all six ability scores with modifiers, and any special abilities and actions.

Try a monster with special abilities

Search for "aboleth" and click it. You should see special abilities like Amphibious and Mucous Cloud, plus multiple actions. Compare it with a simpler monster like "rat" that has fewer sections.

Card shows the name but nothing else?

Make sure all the new code (the ui.row for combat stats, the ability scores ui.row, and the special abilities/actions blocks) is indented inside the with ui.card().classes('w-full'): context manager, not outside it.

help me debug my monster stat block display in NiceGUI

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that clicking a monster displays the full stat block.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point. Scroll through and confirm your file matches.

Your file has four sections in the build_monster_guide function. Scroll up and confirm each is present in this order:

  • The label, search input, columns, and table setup (from Step 2).
  • The detail_container and the show_monster_detail function definition.
  • Inside show_monster_detail: the card with name, HP/AC/speed row, ability scores row, special abilities, and actions.
  • table.on_select(show_monster_detail) followed by return table.

The main() function at the bottom of the file is unchanged from Step 2.

Your Monster Guide is fully functional. Click any of the 334 monsters and get its complete stat block. Next up, you will add a Character Builder tab where users pick a race and class from the API.

Build the Character Builder

Your Monster Guide fetches all 334 creatures from the D&D 5e SRD API and displays full stat blocks on click. That covers one side of the DM screen.

A D&D companion needs a Character Builder too. In this step you will add tabbed navigation to your app so users can switch between the Monster Guide and a new Character Builder page. The Character Builder will pull races and classes live from the API and display their traits and features.

In this step, get ready to:
  • Restructure the app with tabbed navigation between Monster Guide and Character Builder.
  • Fetch race and class data from the D&D 5e API and populate selection dropdowns.
  • Display race traits and class features in detail cards when the user makes a selection.
Restructure the app with tabbed navigation

Right now your app calls build_monster_guide() directly inside main(), so the Monster Guide fills the entire page. To make room for a Character Builder, you need tabbed navigation that lets the user switch between the two pages.

  • In main.py, add the following placeholder function above the def main(): line:
def build_character_builder():
    ui.label('Character Builder').classes('text-h4')

What does this do?

This is a temporary placeholder. It creates a simple heading so you can verify the tab structure works before building the full Character Builder.

Now you need to wrap your existing Monster Guide and this new placeholder inside a tab layout.

  • Inside the main() function, find these lines:
    build_monster_guide(monster_rows)

    ui.run(title='D&D Companion', port=8080)
  • Replace those lines with the following tab structure:
    # Create two tabs at the top of the page
    with ui.tabs().classes('w-full') as tabs:
        monster_tab = ui.tab('Monster Guide')
        builder_tab = ui.tab('Character Builder')

    # Each tab panel holds one page of content
    with ui.tab_panels(tabs, value=monster_tab).classes('w-full'):
        with ui.tab_panel(monster_tab):
            build_monster_guide(monster_rows)
        with ui.tab_panel(builder_tab):
            build_character_builder()

    ui.run(title='D&D Companion', port=8080)

What does this code do?

NiceGUI's ui.tabs() creates a row of clickable tab headers. ui.tab_panels() maps each tab to a content panel that shows when that tab is selected.

The value=monster_tab parameter sets the Monster Guide as the default active tab when the app loads.

  • Save main.py.
  • Stop the running server by pressing Ctrl+C in the terminal, then restart it by running:
python main.py
  • In your browser at http://localhost:8080, you should see two tab headers at the top: Monster Guide and Character Builder.
  • Click Character Builder. You should see just the "Character Builder" heading text.
  • Click Monster Guide to confirm your monster table still works as before.

Don't see two tabs?

  • Check that build_character_builder() is defined above def main():. Python needs to see the function definition before it is called.
  • Make sure you replaced the old build_monster_guide(monster_rows) line entirely. It should only appear inside ui.tab_panel(monster_tab) now.
  • Help me debug my tabbed navigation
Fetch race and class data with selection dropdowns

The Character Builder tab is empty except for a heading. Your next job is to pull the 9 SRD races and 12 classes from the API and display them in searchable dropdowns.

  • In main.py, find the placeholder build_character_builder function you just added.
  • Replace the entire function with the following code, which fetches race data from the API and displays traits when the user picks a race:
def build_character_builder():
    ui.label('Character Builder').classes('text-h4')
    # Fetch all available races and classes from the API
    races_data = fetch_json('/races')
    classes_data = fetch_json('/classes')
    race_options = {r['index']: r['name'] for r in races_data['results']}
    class_options = {c['index']: c['name'] for c in classes_data['results']}
    # Containers for detail cards that appear below the dropdowns
    race_detail_container = ui.column().classes('w-full')
    class_detail_container = ui.column().classes('w-full')

    # Display race traits when the user selects a race
    def on_race_change(e):
        race_detail_container.clear()
        if not e.value:
            return
        race_data = fetch_json(f'/races/{e.value}')
        with race_detail_container:
            with ui.card().classes('w-full'):
                ui.label(f'{race_data["name"]} Traits').classes('text-subtitle1')
                ui.label(f'Speed: {race_data.get("speed", "N/A")} ft.')
                ui.label(f'Size: {race_data.get("size", "N/A")}')
                traits = race_data.get('traits', [])
                if traits:
                    ui.label('Traits:').classes('text-weight-bold')
                    for trait in traits:
                        ui.label(f'  {trait["name"]}')
                bonuses = race_data.get('ability_bonuses', [])
                if bonuses:
                    ui.label('Ability Bonuses:').classes('text-weight-bold')
                    for ab in bonuses:
                        ui.label(f'  {ab["ability_score"]["name"]} +{ab["bonus"]}')

What does this code do?

  • fetch_json('/races') and fetch_json('/classes') call the D&D 5e API to get all 9 races and 12 classes.
  • race_options and class_options are dictionaries that map each index (like elf) to a display name (like Elf). NiceGUI uses these dicts to populate the dropdown labels.
  • on_race_change fires when the user picks a race. It fetches that race's full data and renders a card showing speed, size, traits (like Darkvision and Fey Ancestry for Elf), and ability bonuses (like DEX +2).
Display class features and wire up the dropdowns

The race callback handles one half of the builder. You still need the class callback and the actual dropdown widgets that trigger both callbacks.

  • Directly below the on_race_change function you just added (still inside build_character_builder), add the class callback and the dropdown layout:
    # Display class features when the user selects a class
    def on_class_change(e):
        class_detail_container.clear()
        if not e.value:
            return
        class_data = fetch_json(f'/classes/{e.value}')
        with class_detail_container:
            with ui.card().classes('w-full'):
                ui.label(f'{class_data["name"]} Features').classes('text-subtitle1')
                ui.label(f'Hit Die: d{class_data.get("hit_die", "?")}')
                saves = class_data.get('saving_throws', [])
                if saves:
                    save_names = ', '.join(s['name'] for s in saves)
                    ui.label(f'Saving Throws: {save_names}')
                profs = class_data.get('proficiencies', [])
                if profs:
                    ui.label('Proficiencies:').classes('text-weight-bold')
                    for p in profs:
                        ui.label(f'  {p["name"]}')

    # Create the race and class dropdown selects
    with ui.row().classes('gap-4 w-full'):
        ui.select(race_options, label='Choose Race', on_change=on_race_change, with_input=True).classes('w-48')
        ui.select(class_options, label='Choose Class', on_change=on_class_change, with_input=True).classes('w-48')

    # Place detail containers side by side
    with ui.row().classes('gap-4 w-full'):
        race_detail_container
        class_detail_container

What does this code do?

  • on_class_change fetches the selected class's full data and displays its hit die (like d10 for Fighter), saving throw proficiencies, and other proficiencies in a card.
  • ui.select() creates a dropdown. The with_input=True parameter adds search-as-you-type filtering so the user can quickly find a specific race or class.
  • The two ui.row() blocks lay out the dropdowns side by side at the top, and the detail containers side by side below them.
  • Save main.py.
  • Stop the server with Ctrl+C and restart it by running:
python main.py

Before you click around, think about what you expect to see. You added two dropdowns for race and class. What do you think will happen when you select "Elf" from the race dropdown?

  • In your browser, click the Character Builder tab.
  • You should see two dropdowns: Choose Race and Choose Class.
  • Select Elf from the race dropdown. A card should appear showing Elf Traits: speed, size, traits like Darkvision and Fey Ancestry, and an ability bonus of DEX +2.
  • Select Fighter from the class dropdown. A second card should appear showing Fighter Features: Hit Die d10, saving throws STR and CON, and a list of proficiencies.

Dropdowns not showing or cards not appearing?

  • If the dropdowns are empty, check your terminal for error messages. A ConnectionError means the API is unreachable. Make sure you have an internet connection.
  • If you see a card but it is missing data, make sure the indentation of the on_race_change and on_class_change functions is correct. Both must be nested inside build_character_builder.
  • Make sure the ui.select lines come after both callback function definitions. Python needs the functions to exist before they are referenced.
  • Help me debug my Character Builder dropdowns

Why does selecting a race make an API call?

The initial fetch to /races only returns names and indices for each race. The full details (traits, speed, ability bonuses) live at a deeper endpoint like /races/elf. This two-step pattern (index first, details on demand) keeps the initial page load fast.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that both race and class selection are working in the browser.

ⓧ I'd like to double check the full code

Your main.py is long at this point. Scroll through and confirm these sections are present in this order:

  • Imports (requests and nicegui) and API_BASE constant at the top.
  • The custom_monsters list and fetch_json() helper function.
  • The full build_monster_guide() function with the table, search, and stat block detail.
  • The build_character_builder() function with API fetches, on_race_change, on_class_change, and two ui.select dropdowns.
  • The main() function with ui.tabs, ui.tab_panels, and ui.run() at the bottom.
  • main() called at the very last line of the file.

Your app now has two fully functional pages. The Monster Guide shows all 334 SRD monsters with stat blocks, and the Character Builder lets you explore races and classes from the API. Next up, you will add ability score inputs so the builder can compute totals with racial bonuses and modifiers.

Add Ability Score Calculator

Your Character Builder now lets you pick a race and a class, and it shows their traits and features. But there is a gap. Without actual ability scores, the racial bonuses have nothing to add to, and the character sheet feels incomplete.

In this step, you will add six number inputs for base ability scores, compute totals that combine those base values with racial bonuses, and display the D&D modifier for each ability. The scores will update reactively whenever the learner changes a base value or selects a different race.

In this step, get ready to:
  • Add six number inputs for the base ability scores (STR, DEX, CON, INT, WIS, CHA) with min/max validation.
  • Connect racial bonuses from the API so selecting a race updates the computed totals.
  • Display a reactive summary card showing each ability's base, bonus, total, and modifier.
Set up ability score tracking and display

Before you can render ability scores, you need data structures to track them and a function to display them. You will add dictionaries for base scores and racial bonuses, a mapping between API abbreviations and your internal names, and a function that rebuilds a score summary card on demand.

  • In main.py, find these two lines inside build_character_builder():
    race_detail_container = ui.column().classes('w-full')
    class_detail_container = ui.column().classes('w-full')
  • Add the following code directly below those two lines:
    # Container for the ability score summary
    score_container = ui.column().classes('w-full')

    ability_names = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']
    base_scores = {name: 10 for name in ability_names}  # starting values
    racial_bonuses = {name: 0 for name in ability_names}  # updated on race selection

    # Map API abbreviations to internal names
    ability_abbr_to_full = {
        'str': 'STR', 'dex': 'DEX', 'con': 'CON',
        'int': 'INT', 'wis': 'WIS', 'cha': 'CHA',
    }

    def update_scores():
        # Rebuild the ability score summary card
        score_container.clear()
        with score_container:
            with ui.card().classes('w-full'):
                ui.label('Ability Scores').classes('text-subtitle1')
                with ui.row().classes('gap-4'):
                    for name in ability_names:
                        base = base_scores[name]
                        bonus = racial_bonuses[name]
                        total = base + bonus
                        modifier = (total - 10) // 2  # D&D modifier formula
                        sign = '+' if modifier >= 0 else ''
                        with ui.column().classes('items-center'):
                            ui.label(name).classes('text-weight-bold')
                            bonus_str = f' + {bonus}' if bonus > 0 else ''
                            ui.label(f'{base}{bonus_str} = {total}')
                            ui.label(f'({sign}{modifier})').classes('text-caption')

What does this code do?

You set up three dictionaries. base_scores holds the raw values the learner enters (defaulting to 10). racial_bonuses holds the bonuses that come from the selected race. ability_abbr_to_full translates the lowercase abbreviations the API returns (like dex) into the uppercase keys you use internally (like DEX).

The update_scores() function clears the summary card and rebuilds it. For each ability it computes the total (base + bonus) and the modifier using the standard D&D formula: (total - 10) // 2. Python's floor division handles odd scores correctly.

Now you need the actual number inputs so the learner can set their base scores. You also need a closure pattern to make sure each input updates the correct ability.

  • Scroll to the very end of the build_character_builder() function. Find these lines:
    with ui.row().classes('gap-4 w-full'):
        race_detail_container
        class_detail_container
  • Add the following code directly below that block, still inside build_character_builder():
    ui.separator()
    ui.label('Set Base Ability Scores').classes('text-h6')

    with ui.row().classes('gap-4'):
        for name in ability_names:
            # Closure to capture the current ability name
            def make_handler(ability_name):
                def handler(e):
                    base_scores[ability_name] = int(e.value) if e.value else 10
                    update_scores()
                return handler

            ui.number(label=name, value=10, min=3, max=18, on_change=make_handler(name)).classes('w-20')

    update_scores()

Why use a closure here?

The make_handler() function captures the current value of ability_name as a parameter. Without this closure, every input would reference the same loop variable and only the last ability (CHA) would ever update.

The final update_scores() call at the bottom renders the initial score card when the page first loads.

  • Save main.py.
  • Stop any running server with Ctrl+C in your terminal, then start it again by running:
python main.py
  • Click the Character Builder tab.
  • You should see a Set Base Ability Scores section with six number inputs (STR through CHA) and an Ability Scores summary card below them showing all sixes scores at 10 with a modifier of (+0).
  • Change one of the number inputs (for example, set STR to 15). The summary card should instantly update to show 15 = 15 (+2) for STR.

Don't see the number inputs?

  • Make sure the new code is inside build_character_builder() and indented at the same level as the two ui.row() blocks above it.
  • Check your terminal for a traceback. A NameError mentioning ability_names means the tracking variables were added in the wrong location.
  • If the score card doesn't update when you change a number, make sure make_handler calls update_scores() inside the inner handler function.

Help me debug my ability score inputs.

Connect racial bonuses to the score calculator

The number inputs work, but selecting a race does not change the ability score totals. The existing on_race_change() function only displays traits. You need it to also read the API's ability bonuses, update the racial_bonuses dictionary, and recalculate the score card.

  • In main.py, find the beginning of the on_race_change function. It currently looks like this:
    def on_race_change(e):
        race_detail_container.clear()
        if not e.value:
            return
        race_data = fetch_json(f'/races/{e.value}')
        with race_detail_container:
  • Replace those lines with the following, which adds bonus resetting, the early-return score update, and the ability bonus parsing:
    def on_race_change(e):
        race_detail_container.clear()
        # Reset all racial bonuses when race changes
        for name in ability_names:
            racial_bonuses[name] = 0

        if not e.value:
            update_scores()
            return

        race_data = fetch_json(f'/races/{e.value}')

        # Read racial ability bonuses from the API response
        for ab in race_data.get('ability_bonuses', []):
            abbr = ab['ability_score']['index']
            full_name = ability_abbr_to_full.get(abbr, '')
            if full_name:
                racial_bonuses[full_name] = ab['bonus']

        with race_detail_container:

What changed?

  • The reset loop zeros out every ability's racial bonus at the start, so switching from Elf to Human correctly removes the old DEX bonus.
  • The early return now calls update_scores() so clearing the race selection also clears the bonuses from the card.
  • The new parsing loop reads each entry in the API's ability_bonuses array, translates the abbreviation (dex) to the internal key (DEX), and stores the bonus value.

The display card inside the with race_detail_container: block stays unchanged. You only need to add one more line at the very end of the function.

  • Scroll to the bottom of on_race_change. Find the last line of the with race_detail_container: block (the line with ui.label(f' {ab["ability_score"]["name"]} +{ab["bonus"]}')).
  • Add this line after the entire with race_detail_container: block, at the same indentation level as with race_detail_container: itself:
        update_scores()
  • Save main.py.
  • Stop the server with Ctrl+C and restart it by running:
python main.py

Before you test, think about this: if you set DEX to 14 and then select Elf as the race, what do you expect the DEX total and modifier to be?

  • Click the Character Builder tab.
  • Set the STR input to 15, DEX to 14, CON to 13, INT to 12, WIS to 10, and CHA to 8.
  • Select Elf from the race dropdown.

The DEX total updates to 16 (14 base + 2 racial bonus from Elf) with a modifier of (+3). Every other ability shows its base value unchanged because Elf only grants a DEX bonus.

Racial bonuses not showing up?

  • Make sure update_scores() is the last line of on_race_change, placed after the entire with race_detail_container: block, not inside it.
  • Verify that the reset loop (for name in ability_names: racial_bonuses[name] = 0) is at the top of on_race_change, right after race_detail_container.clear().
  • If you see the bonus parsing loop but the totals still show zero bonus, check that ability_abbr_to_full uses lowercase keys ('str', 'dex', etc.) to match what the API returns.

Help me debug my racial bonus calculation.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and your app is running.

ⓧ I'd like to double check the full code

Scroll through your main.py and confirm each section is present in this order inside build_character_builder():

  • The API fetch calls for races and classes, and the two option dictionaries (race_options, class_options).
  • Three container declarations: race_detail_container, class_detail_container, and the new score_container.
  • Tracking dictionaries: ability_names, base_scores, racial_bonuses, and ability_abbr_to_full.
  • The update_scores() function that clears and rebuilds the score card.
  • The updated on_race_change() with the bonus reset loop, early return calling update_scores(), the bonus parsing loop, and update_scores() as its last line.
  • The unchanged on_class_change() function.
  • The two ui.select dropdowns for race and class.
  • A ui.separator(), the Set Base Ability Scores label, and the loop creating six ui.number inputs with make_handler.
  • A final update_scores() call at the very end of the function.

Secret mission

Add Custom Monster Creator

Your Monster Guide pulls all 334 SRD monsters from the API, but every D&D group has homebrew creatures. Can you build a form that lets you create custom monsters and add them to the table alongside the official ones?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. The D&D 5e SRD API is free and requires no authentication, so there are no ongoing costs or cloud resources to worry about.

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

Resources you used:

  • Your project folder on the Desktop containing main.py.
  • Python packages: nicegui and requests.

Keep everything running

No action needed. Choose this if you want to keep experimenting with your D&D Companion App.

  • Your code stays in your project folder on the Desktop.
  • Start your app anytime by opening a terminal, navigating to your project folder, and running python main.py.
  • The nicegui and requests packages stay installed on your system and are available for other Python projects too.

Pause - I'll come back to this later

Shut down the running server to free up your terminal. Your code and packages stay in place for next time.

  • If your NiceGUI server is still running, switch to the terminal and press Ctrl+C to stop it.
  • Close the terminal window.
  • Your project folder and installed packages remain untouched. When you are ready to resume, open a terminal, navigate to your project folder, and run python main.py.

Delete - I don't want to use this again

Remove all project files and uninstall the packages to start completely fresh.

  • If your NiceGUI server is still running, switch to the terminal and press Ctrl+C to stop it.
  • Uninstall the Python packages by running:
pip uninstall nicegui requests -y
  • Delete your project folder. On macOS or Linux, navigate to the Desktop and remove it:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]

On Windows, run this in PowerShell instead:

cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]
  • Once both commands are done, your machine is back to its pre-project state.

Nice Work!

Nice Work!

Nice work! You just built a fully functional D&D Companion App that runs in your browser, powered entirely by Python.

You've learned how to:

  • Built a Monster Guide that fetches all 334 monsters from the D&D 5e SRD REST API, displays them in a searchable table, and shows full stat block cards with HP, AC, abilities, and actions.
  • Built a Character Builder with race and class selection pulled live from the API, plus an ability score calculator that computes totals with racial bonuses and modifiers.
  • Used NiceGUI to build a full browser-based UI entirely in Python, with dark mode toggling and tabbed navigation between sections.
  • Secret Mission: Created a custom monster creator to add your own homebrew creatures to the Monster Guide, complete with form validation and a Homebrew badge.

Ready to quiz yourself?