Learn Python in 30 Days — Day 30 – Final Project Build (Part 3): Finishing the Game

Day 30 – Finishing the Game

Welcome to Day30 of the Learn Python in 30 Days series!
This is the final day of the series, and the last build step of your open-world text RPG: RetroRealm.
All example files for this series are available on my GitHub: Learn-Python-in-30-Days

By now your project has:

  • A full world made of multiple 10×10 regions
  • Blocking movement rules
  • Region-to-region travel
  • Danger-level encounters
  • A working turn-based combat loop
  • A modular Python engine

Today, we finish everything else, the pieces that turn RetroRealm from a simple overworld explorer into a real, living RPG world.

By the end of Day 30 you’ll have:

  • Fully explorable villages
  • NPCs you can talk to
  • Shops to buy and sell items
  • Magic, spells, and usable items in combat
  • A levelling system
  • Saving & loading your progress
  • A complete, fully playable RPG engine

Let’s complete this game.

Village Engine

Villages came from villages.json on Day 28, as part of the village generator.
Up to now, they’ve just been data: names, maps, NPCs, and shops, all stored in JSON.

Today, we add a village navigation system with:

  • Walking inside the village
  • NPC tiles
  • Shop tiles
  • Exit tiles
  • Conversations and shopping
  • A robust loader that works with different villages.json formats (dict or list, world_x/world_y, older x/y, or coords)

First create the file: village_engine.py

# village_engine.py from utils import direction_from_input

We reuse the same direction helper that was wrote earlier so villages and the overworld share a consistent input style (n/s/e/w → north/south/east/west).

The village lookup function

This function is the “glue” between the overworld and the village data. It takes the player’s current region and coordinates, and finds the matching village.

def find_village_at(villages, region, x, y): """Find a village for the given region + (x, y) no matter whether villages is a dict-of-dicts or a list of villages. Supports world_x/world_y (current format) as well as older x/y/coords.""" coords = f"{x},{y}"

Here we construct a "x,y" string so we can support older formats that stored coordinates as "0,0" keys.

# Case 1: dict structure, e.g. villages[region]["x,y"] if isinstance(villages, dict): region_block = villages.get(region) if isinstance(region_block, dict): v = region_block.get(coords) if v is not None: return v

This handles the case where villages is a dictionary of regions, and each region contains a dictionary keyed by "x,y".

# Case 2: list structure, e.g. [ # {"region": ..., "world_x": 0, "world_y": 0, ...}, # ... # ] if isinstance(villages, list): for v in villages: if v.get("region") != region: continue # NEW: match against world_x/world_y from villages.json if v.get("world_x") == x and v.get("world_y") == y: return v # Backwards compatibility: some older formats might use x/y or coords if v.get("x") == x and v.get("y") == y: return v if v.get("coords") == coords: return v return None

This part supports your current format (world_x/world_y).

The big win here: if you tweak your village generator later, this function is forgiving and your game doesn’t suddenly break.

Entering and navigating a village

def enter_village(player, villages): region = player.location["region"] x = player.location["x"] y = player.location["y"] village = find_village_at(villages, region, x, y) if not village: print("There is no village here.") return print(f"\n=== Entering {village['name']} ===")

This pulls the player’s overworld position and uses find_village_at() to fetch the matching village. If no village exists at that location, the command safely does nothing.

px, py = 0, 0 size_y = len(village["map"]) size_x = len(village["map"][0])

Inside the village we track a separate position: px, py.
This is the player’s location inside the village’s own mini-map grid.

while True: tile = village["map"][py][px] print(f"\nVillage Tile: {tile['type']} (x={px}, y={py})")

Each tile has a type such as "floor", "npc", "shop", or "exit". Printing this helps the player understand what they’re standing on.

if tile["type"] == "npc": print("An NPC is here. Type 'talk'.") if tile["type"] == "shop": print("A shop is here. Type 'shop'.") if tile["type"] == "exit": print("Exit to overworld: type 'exit'.")

Depending on the tile type, we show available actions.

cmd = input("village> ").strip().lower()

Village commands are handled in a little REPL, separate from the overworld loop.

Movement inside the village

if cmd in ("n", "s", "e", "w"): d = direction_from_input(cmd) nx, ny = px, py if d == "north": ny -= 1 elif d == "south": ny += 1 elif d == "east": nx += 1 elif d == "west": nx -= 1 if 0 <= nx < size_x and 0 <= ny < size_y: px, py = nx, ny else: print("You hit a wall.") continue

Village movement mirrors overworld movement:
arrow-style commands get converted to directions, then to tile indices, with bounds checking.

Exiting the village and interacting with tiles

if cmd == "exit": print("You leave the village.") return

This cleanly returns to the overworld loop in main.py.

if cmd == "talk" and tile["type"] == "npc": handle_npc(tile["npc"]) continue

If you’re standing on an NPC tile and you type talk, we delegate to handle_npc().

if cmd == "shop" and tile["type"] == "shop": handle_shop(player, tile["shop"]) continue

If you’re standing on a shop tile and type shop, we open the shop menu.

print("Unknown command.")

Any other command is rejected. However you can easily add other commands of your own, say to interact at Inns or Farms

NPC conversations

def handle_npc(npc): print(f"\nYou talk to {npc['name']}.") print(f"'{npc['dialog']}'")

NPCs are defined in your JSON data, and this function simply prints their name and dialogue.
It’s intentionally simple so you can later expand it into branching conversations or quests if you want.

Shop system

def handle_shop(player, shop): print(f"\nWelcome to {shop['name']}!")

Shops are the heart of the in-game economy. Players can buy items, inspect their inventory, and leave at any time.

while True: print("\nItems for sale:") for i, item in enumerate(shop["items"]): print(f"{i+1}. {item['name']} - {item['price']}g") print("\nCommands: number to buy, 'inv', 'leave'")

We list items with a number, so the player can buy via numeric input.

cmd = input("shop> ").strip().lower() if cmd == "leave": print("You leave the shop.") return

leave exits the shop and returns the player to the village loop.

if cmd == "inv": print("\nYour inventory:") for item in player.inventory: print(f"- {item}") continue

inv shows the player’s current inventory. This lets them see what they already own before buying more.

if cmd.isdigit(): idx = int(cmd)-1 if idx < 0 or idx >= len(shop["items"]): print("Invalid choice.") continue item = shop["items"][idx] price = item["price"] if player.gold < price: print("Not enough gold!") continue player.gold -= price player.inventory.append(item["name"]) print(f"You bought {item['name']}!") continue print("Unknown command.")

Numeric input means “buy item number X”:

  • Checks index validity
  • Checks if the player has enough gold
  • Deducts gold
  • Adds the item name to player.inventory

This shop system ties directly into combat, where those items become usable.

Magic, Items & Levelling in Combat

Now we expand combat to support:

  • Magic attacks
  • Consumable items
  • Levelling
  • Better damage scaling

This turns combat from “attack spam” into something much more RPG-like:
you’ll manage resources, choose between physical attacks, magic, and healing, and enjoy a sense of progression from levelling.

Update combat.py to the final version:

combat.py

# combat.py import random

We still use random for damage variation.

def combat(player, base_creature): creature = dict(base_creature) creature_hp = creature["health"] print(f"\n=== Combat: {creature['name']} ===")

We clone the creature so we don’t mutate the original template. Each battle gets its own HP pool.

The combat loop

while creature_hp > 0 and player.health > 0: print(f"\nYour HP: {player.health} | MP: {player.magic}") print(f"{creature['name']} HP: {creature_hp}") action = input("(a)ttack, (m)agic, (i)tem, (r)un? ").lower()

We display the current HP/MP and prompt the player to choose between:

  • a – physical attack
  • m – magic
  • i – item
  • r – run

Physical attack

if action == "a": dmg = random.randint(2, 6) + player.level creature_hp -= dmg print(f"You hit the {creature['name']} for {dmg}.")

Damage is a small random value plus the player’s level, so levelling up directly improves basic attacks.

Magic attack

elif action == "m": if player.magic < 3: print("Not enough MP!") continue dmg = random.randint(5, 10) + player.level player.magic -= 3 creature_hp -= dmg print(f"You cast a spell for {dmg} damage!")

Magic:

  • Costs 3 MP
  • Deals more damage than a regular attack
  • Also scales with level

This introduces a risk/reward decision: do you spend MP now for burst damage, or save it for later?

Items in combat

elif action == "i": if not player.inventory: print("You have no items.") continue print("\nChoose an item:") for i, item in enumerate(player.inventory): print(f"{i+1}. {item}") choice = input("item> ")

If the inventory is empty, the action is blocked.

If not, the player chooses an item by number.

if choice.isdigit(): idx = int(choice)-1 if 0 <= idx < len(player.inventory): item = player.inventory.pop(idx) print(f"You use {item}!") if "potion" in item.lower(): player.health += 10 print("You heal +10 HP.") continue print("Invalid item.") continue

We:

  • Remove the selected item from inventory
  • Apply a simple rule: if the word "potion" is in the item name, heal 10 HP

This is intentionally simple, but easy to extend. You could later add different item types or effects by checking other keywords.

Running away

elif action == "r": print("You escape!") return

Running away immediately ends combat and returns to the overworld. No XP, no gold.

Handling invalid actions

else: print("Invalid action.") continue

Just a guard for typos or unsupported input.

Enemy turn

if creature_hp > 0: dmg = creature["attack"] player.health -= dmg print(f"The {creature['name']} hits you for {dmg}!")

If the enemy is still alive after the player’s turn, it hits back using its attack value from your creature data. This keeps the fight turn-based and predictable.

Combat resolution & levelling

if player.health <= 0: print("\nYou died...") raise SystemExit

If the player reaches 0 HP, the game exits.

xp = creature.get("xp", 0) gold = creature.get("gold", 0) print(f"\nYou defeated the {creature['name']}!") print(f"+{xp} XP, +{gold} gold") player.xp += xp player.gold += gold check_level_up(player)

On a win, you:

  • Gain XP and gold from the creature
  • Update the player’s stats
  • Call check_level_up() to see if you levelled

def check_level_up(player): needed = player.level * 20 if player.xp >= needed: player.level += 1 player.health += 10 player.magic += 5 print(f"\n*** LEVEL UP! You are now Level {player.level}! ***")

Every level requires level * 20 XP.
When you cross that threshold, you:

  • Increase level
  • Add 10 to health
  • Add 5 to magic

This makes your character meaningfully stronger over time.

Save & Load System

RPGs are meant to be played over multiple sessions.
So now we add a simple but powerful save and load system.

Create: the file: save_load.py

# save_load.py import json def save_game(player): data = { "player": player.__dict__ } with open("save.json", "w") as f: json.dump(data, f, indent=4) print("Game saved!")

player.__dict__ contains all the attributes of the player object:

  • Location
  • Level
  • XP
  • HP / MP
  • Gold
  • Inventory

By dumping this dictionary, we capture the entire player state in one go.

def load_game(player): try: with open("save.json", "r") as f: data = json.load(f) for k, v in data["player"].items(): setattr(player, k, v) print("Game loaded!") except FileNotFoundError: print("No save file found.")

load_game reverses the process:

  • Opens save.json
  • Iterates over saved attributes
  • Sets them back on the existing player object

If no save file exists, it prints a friendly message instead of crashing.

This version saves the whole Player object by dumping player.__dict__ and restores it on load.

Updated main.py

Your game loop now supports:

  • Villages
  • Saving & loading
  • Correct imports
  • Working data paths via world_data.py (data/world.json, data/villages.json, data/creatures.json)

This file is the “director” of your game: it ties all the subsystems together.

We need to modify main.py as follows to add these new features: -
# main.py from player import Player from world_data import load_world, load_villages, load_creatures from map_engine import describe_location, move_player_overworld from creatures import maybe_trigger_encounter from combat import combat from village_engine import enter_village from save_load import save_game, load_game

We import:

  • Player for the main character
  • World, village, and creature loaders
  • Map description and movement
  • Encounter logic
  • Combat
  • Village engine
  • Save/load system

def start_game(): world = load_world() villages = load_villages() creatures = load_creatures() player = Player() print("=== RetroRealm ===") print("World loaded.") print(f"Starting region: {player.location['region']}") main_loop(player, world, villages, creatures)

start_game():

  1. Loads the data

  2. Creates the player

  3. Prints a splash screen

  4. Drops into the main game loop

The main game loop

def main_loop(player, world, villages, creatures): while True: describe_location(world, player) print("\nCommands: n/s/e/w, stats, village, save, load, q") cmd = input("> ").strip().lower()

Each turn:

  1. Describe the player’s current location
  2. Show available commands
  3. Ask what they want to do

if cmd in ("q", "quit"): print("Thanks for playing!") break

Allows the player to quit gracefully.

if cmd == "stats": print(f""" HP: {player.health} MP: {player.magic} XP: {player.xp} Level: {player.level} Gold: {player.gold} """) continue

stats prints a simple summary of the player’s current build.

if cmd == "village": enter_village(player, villages) continue

If the player is standing on a village tile, enter_village will handle the interior navigation.

if cmd == "save": save_game(player) continue if cmd == "load": load_game(player) continue

These delegate to the save/load system you just built.

if cmd in ("n", "s", "e", "w"): move_player_overworld(player, world, cmd) maybe_trigger_encounter(world, creatures, player, combat) continue

Movement:

  1. Moves the player according to overworld rules
  2. Checks the location’s danger level
  3. Possibly triggers combat via maybe_trigger_encounter

print("Unknown command.")

Fallback for invalid input.

if __name__ == "__main__": start_game()

Standard Python entry point, so running python main.py launches the game.

What You Built on Day 30

RetroRealm is now a complete open-world text RPG!

All example files for this game are available at Day 30 Final Game

Today you added:

Villages, Walkable mini-maps with NPCs, shops, and exits.

NPC Conversations, Simple, readable dialogue loaded from data.

Item Shops, Buy items, check your inventory, spend gold.

Magic, MP-based spellcasting usable in combat.

Usable Items, Potions and consumables that modify player stats.

Levelling System, XP thresholds, stat upgrades, and character progression.

Save/Load, Quit any time and continue where you left off.

Full Game Loop, Movement → encounters → villages → shopping → combat → progression.

The End

You’ve completed an enormous project.
You now have the skills to start build anything in Python, games, tools, utilities, apps, automation, web scripts, and more. From here you can pick up more advanced techniques and improve your programming skills further.

Really hope you have enjoyed this series of posts and the final game that has been built.
Thanks
Matty

Comments

Popular posts from this blog

6502 - Part 2 Reset and Clock Circuit

Math Behind Logic Gates

Building a 6502 NOP Test