Learn Python in 30 Days — Day 28: Final Project Planning (Part 1)

Day 28: Final Project Planning (Part 1)

Welcome to Day 28 of the Learn Python in 30 Days series!

For the final 3 days of the learn python in 30 days series, we’re creating something far bigger than a simple text adventure, you’re building the foundations of a full open-world RPG, complete with:

  • Multiple explorable regions
  • Villages and interiors
  • NPCs with races (human/elf/dwarf)
  • Enemy creatures
  • Gold, XP, magic, items
  • Ownership (houses, shops)
  • A tile-based world made of 5 separate 10×10 maps
  • Player movement across a grid-based overworld
  • Travel between maps via edges (north → new region, etc.)

A world you can continue expanding long after Day 30

This is the world-building day, your job is to create the engine, the data, and the structure that will support the next two days of map logic, combat, quests, shops, and saving/loading.

All example files for this series are available on my GitHub: Learn-Python-in-30-Days

By the end of today you will have:

  • A world system with multiple maps

  • A tile engine that tracks terrains & features

  • Villages placed on maps

  • Enterable interiors (shops, houses, farms)

  • Player stats & inventory system

  • NPC data structure for humans/elves/dwarfs/monsters

  • A modular folder structure for a growing RPG

The Vision: RetroRealm – A Console RPG Engine

Let’s give this project a name:

RetroRealm: A Python Open-World RPG Engine
Console-based, modular, expandable, retro-inspired.

You’re essentially creating a micro-scale version of early Final Fantasy, Ultima, or Zelda, but entirely text-driven.

The world consists of:

🌿 Main Region: Green Valley
10×10 overworld

Forests, rivers, mountains

1–2 villages (e.g. Stonebrook Village)

🔥 Region 2: Ashen Wastes
Volcanic desert

Creature-filled

One outpost village

Region 3: Frostlands
Snow fields

Dwarven settlement

🌙 Region 4: Moonshadow Woods
Magical forest

Elven village

Hidden ruins

Region 5: Ironlands
Mines, steampunk structures

Dwarf/merchant town

Each region is a grid-based overworld.
Certain tiles contain village entrances, which load a new small local map (5×5 or 8×8).

World Structure: Maps, Tiles & Regions

We’ll create a Python structure like this:

world = { "green_valley": { "name": "Green Valley", "map": [], # 10x10 list of terrain tiles "villages": [], # list of village positions "exits": { # where each edge leads "north": "frostlands", "south": "ashen_wastes", "east": "moonshadow", "west": None } }, "ashen_wastes": {...}, "frostlands": {...}, "moonshadow": {...}, "ironlands": {...} }

Internally, each tile on a map is something like:

{ "terrain": "grass" | "forest" | "water" | "mountain", "feature": None | "village" | "dungeon" | "farm", "danger_level": 05, "directions": { # movement allowed from THIS tile "north": True or False, "south": True or False, "east": True or False, "west": True or False, } }

This means the player can:

  • Walk tile to tile

  • See what’s on each tile

  • Enter villages/dungeons based on tile contents

  • Encounter creatures depending on danger level

  • Be blocked from moving into / out of tiles in certain directions

Villages & Interiors

Each village is its own mini-map:

village = { "name": "Stonebrook", "map": [ ["house", "shop", "path", "farm", "house"], ... ], "npcs": [ {"name": "Elda", "race": "elf", "role": "shopkeeper"}, {"name": "Torren", "race": "human", "role": "farmer"}, ] }

(Conceptually it’s like this; in the generators we’ll turn these into richer tile objects with movement rules.)

Entering a tile with "feature": "village" switches maps:

current_map = world["green_valley"] if tile["feature"] == "village": load_village("Stonebrook")

NPCs (Humans, Elves, Dwarfs, Creatures)

NPC template:

npc = { "name": "Aeloria", "race": "elf", "role": "healer", "dialogue": [ "The forest whispers warnings...", "Stay close to the light, traveler." ], "inventory": [], "stats": { "health": 20, "magic": 10, "level": 1 } }

Monsters:

creatures = { "wolf": {"health": 8, "attack": 2, "xp": 5}, "goblin": {"health": 10, "attack": 3, "xp": 8}, "troll": {"health": 20, "attack": 5, "xp": 20} }

Regional encounters depend on danger level.

Player System: Stats, XP, Gold, Magic

Your player needs an actual character sheet:

class Player: def __init__(self): self.name = "Hero" self.health = 30 self.magic = 15 self.gold = 10 self.xp = 0 self.level = 1 self.inventory = [] self.owned_properties = { "houses": [], "shops": [] } self.location = { "region": "green_valley", "x": 0, "y": 0 }

Owning things means later you can add:

  • Rent income

  • Shops generating profits

  • Storage chests

  • Extra inventory slots

Folder Structure (So We Don’t Create a Giant File)

We’re moving from a tiny game to a multi-file project, with different modules:

retrorealm/ │ ├── main.py ├── world_data.py ├── player.py ├── npc_data.py ├── creatures.py ├── map_engine.py ├── village_engine.py ├── combat.py ├── save_load.py └── utils.py

This is exactly what you’ve been learning on Days 20–25 real project structure.

So lets work on today’s goal, building the Generation engines.

World Generator

First, make retrorealm/generators/world_generator.py.

This script:

  • Defines terrains

  • Generates 10×10 maps of tiles

  • Places 1–2 villages per region

  • Adds per-tile movement rules (north/south/east/west only)

  • Saves the whole world to data/world.json

# generators/world_generator.py import json import os import random TERRAINS = ["grass", "forest", "water", "mountain"] REGIONS = { "green_valley": { "name": "Green Valley", "exits": { "north": "frostlands", "south": "ashen_wastes", "east": "moonshadow", "west": None } }, "ashen_wastes": { "name": "Ashen Wastes", "exits": {"north": None, "south": None, "east": None, "west": "green_valley"} }, "frostlands": { "name": "Frostlands", "exits": {"north": None, "south": "green_valley", "east": None, "west": None} }, "moonshadow": { "name": "Moonshadow Woods", "exits": {"north": None, "south": None, "east": None, "west": "green_valley"} }, "ironlands": { "name": "Ironlands", "exits": {"north": None, "south": None, "east": None, "west": None} } } def movement_for_terrain(terrain): """ Return a directions dict for this terrain. Movement is restricted to north/south/east/west only. """ # Start with all directions blocked directions = { "north": False, "south": False, "east": False, "west": False } # Grass and forest are walkable in all 4 directions if terrain in ("grass", "forest"): for key in directions: directions[key] = True # Water and mountains are fully blocked (impassable) # You can relax this later if you add boats, climbing, etc. return directions def generate_tile(): """Create a single world tile with terrain, feature, danger level, and movement rules.""" terrain = random.choice(TERRAINS) # Forests & mountains a bit more dangerous if terrain in ("forest", "mountain"): danger = random.randint(1, 3) else: danger = random.randint(0, 2) return { "terrain": terrain, "feature": None, # "village", "dungeon" etc (we only use "village" for now) "danger_level": danger, "directions": movement_for_terrain(terrain) } def generate_map(size=10): """Generate a size×size map of tiles.""" return [[generate_tile() for _ in range(size)] for _ in range(size)] def place_villages(region_id, region_data, count=2): """ Randomly place 'count' villages on a region map. Return a list of village entries {id, name, region, x, y}. """ size = len(region_data["map"]) villages = [] for i in range(count): x = random.randint(0, size - 1) y = random.randint(0, size - 1) tile = region_data["map"][y][x] tile["feature"] = "village" village_id = f"{region_id}_village_{i}" villages.append({ "id": village_id, "name": None, # filled by village generator later "region": region_id, "x": x, "y": y }) return villages def build_world(): """ Build all regions with maps and village positions. Returns a dict ready to be saved as JSON. """ world = {"regions": {}, "villages": []} for region_id, data in REGIONS.items(): region_map = generate_map() region_entry = { "id": region_id, "name": data["name"], "map": region_map, "exits": data["exits"] } world["regions"][region_id] = region_entry # Place 1–2 villages village_count = random.randint(1, 2) villages = place_villages(region_id, region_entry, count=village_count) world["villages"].extend(villages) return world def save_world(world, path="../data/world.json"): """Save the world dict as pretty-printed JSON.""" os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(world, f, indent=2) if __name__ == "__main__": random.seed() # or a fixed seed if you want reproducible worlds world_data = build_world() save_world(world_data) print("World generated and saved to data/world.json")

Run from the retrorealm/generators folder:

python world_generator.py

You’ve just built a standalone content tool that produces world.json.

You can also download this example from my GitHub here and run it yourself.

NPC Generator

Before we generate villages, we’ll make a generic NPC factory the village generator can use.

Create retrorealm/generators/npc_generator.py:

# generators/npc_generator.py import random HUMAN_NAMES = ["Torren", "Elda", "Marin", "Garron", "Lysa"] ELF_NAMES = ["Aeloria", "Thalan", "Seren", "Lirael"] DWARF_NAMES = ["Borin", "Durik", "Thrug", "Helga"] RACES = ["human", "elf", "dwarf"] ROLES = { "shopkeeper": { "dialogue": [ "Take a look at my wares.", "Gold talks, friend." ], "base_stats": {"health": 15, "magic": 5, "level": 1} }, "farmer": { "dialogue": [ "The land’s been harsh this year.", "Seen any wolves near the valley?" ], "base_stats": {"health": 18, "magic": 0, "level": 1} }, "guard": { "dialogue": [ "Stay out of trouble.", "Bandits have been bold lately." ], "base_stats": {"health": 25, "magic": 0, "level": 2} }, "healer": { "dialogue": [ "May your wounds mend swiftly.", "The forest whispers warnings..." ], "base_stats": {"health": 20, "magic": 15, "level": 2} } } def random_name_for_race(race): if race == "human": return random.choice(HUMAN_NAMES) if race == "elf": return random.choice(ELF_NAMES) if race == "dwarf": return random.choice(DWARF_NAMES) return "Nameless" def create_npc(race=None, role=None): """ Generate an NPC dict with race, role, dialogue, inventory, and stats. """ if race is None: race = random.choice(RACES) if role is None: role = random.choice(list(ROLES.keys())) template = ROLES[role] base_stats = template["base_stats"] npc = { "name": random_name_for_race(race), "race": race, "role": role, "dialogue": template["dialogue"], "inventory": [], "stats": { "health": base_stats["health"], "magic": base_stats["magic"], "level": base_stats["level"] } } return npc if __name__ == "__main__": # quick test for _ in range(3): print(create_npc())

This module doesn’t save JSON itself; it just builds NPC dicts for other generators to use.

You can also download this example from my GitHub here and run it yourself.

Village Generator: interiors + NPCs

Now create retrorealm/generators/village_generator.py.

This script will:

  • Read world.json (so it knows where villages are)

  • Generate a small interior map for each village

  • Populate each village with NPCs using npc_generator

  • Give each village a name

  • Add per-tile movement rules (inside villages)

  • Save everything to villages.json

# generators/village_generator.py import json import os import random from npc_generator import create_npc VILLAGE_NAMES = [ "Stonebrook", "Riverford", "Ashgate", "Frostwatch", "Moonhollow", "Ironhelm", "Windrest" ] BUILDING_TYPES = ["house", "shop", "inn", "farm", "empty", "empty"] def movement_for_village_tile(tile_type): """ Movement rules inside a village: - 'path' tiles: walkable in all directions - building tiles (house/shop/inn/farm): blocked in all directions - 'empty' can be walkable (like open ground) """ directions = { "north": False, "south": False, "east": False, "west": False } if tile_type in ("path", "empty"): for key in directions: directions[key] = True return directions def generate_village_map(width=5, height=5): """ Create a simple village layout: - central 'path' down the middle - random buildings on either side - each tile includes movement directions (N/S/E/W only) """ village_map = [] for y in range(height): row = [] for x in range(width): if x == width // 2: tile_type = "path" else: tile_type = random.choice(BUILDING_TYPES) tile = { "type": tile_type, "directions": movement_for_village_tile(tile_type) } row.append(tile) village_map.append(row) return village_map def build_village_entry(village_info): """ Take a village position from world.json and build a full village entry. village_info looks like: { "id": "...", "name": None, "region": "green_valley", "x": 3, "y": 7 } """ name = random.choice(VILLAGE_NAMES) npc_count = random.randint(2, 5) npcs = [create_npc() for _ in range(npc_count)] return { "id": village_info["id"], "name": name, "region": village_info["region"], "world_x": village_info["x"], "world_y": village_info["y"], "map": generate_village_map(), "npcs": npcs } def load_world(path="../data/world.json"): with open(path, "r", encoding="utf-8") as f: return json.load(f) def save_villages(villages, path="../data/villages.json"): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(villages, f, indent=2) if __name__ == "__main__": world = load_world() villages = [] for village_info in world["villages"]: v = build_village_entry(village_info) villages.append(v) save_villages(villages) print(f"Generated {len(villages)} villages and saved to data/villages.json")

Run in this order:

python world_generator.py python village_generator.py

Now you have:

  • world.json → overworld tiles + village coordinates (with movement rules)

  • villages.json → each village’s interior + NPCs (also with movement rules)

You can also download this example from my GitHub here and run it yourself.

Creature Generator: region encounter data

Finally, define your creature data per region and save to JSON.

Create retrorealm/generators/creature_generator.py:

# generators/creature_generator.py import json import os CREATURES = { "green_valley": [ {"id": "wolf", "name": "Wolf", "health": 8, "attack": 2, "xp": 5, "min_danger": 1}, {"id": "goblin", "name": "Goblin", "health": 10, "attack": 3, "xp": 8, "min_danger": 2}, ], "ashen_wastes": [ {"id": "scarab", "name": "Fire Scarab", "health": 9, "attack": 3, "xp": 7, "min_danger": 1}, {"id": "imp", "name": "Ash Imp", "health": 14, "attack": 4, "xp": 12, "min_danger": 2}, {"id": "troll", "name": "Lava Troll", "health": 20, "attack": 5, "xp": 20, "min_danger": 3}, ], "frostlands": [ {"id": "wolf_ice", "name": "Ice Wolf", "health": 10, "attack": 3, "xp": 9, "min_danger": 1}, {"id": "yeti", "name": "Young Yeti", "health": 18, "attack": 4, "xp": 16, "min_danger": 2}, ], # You can expand moonshadow / ironlands later } def save_creatures(path="../data/creatures.json"): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(CREATURES, f, indent=2) if __name__ == "__main__": save_creatures() print("Creature data saved to data/creatures.json")

You can either:

  • Hand-edit this file as you design monsters, or

  • Later write a more advanced generator if you want randomised creatures.

You can also download this example from my GitHub here and run it yourself.

Quick Preview: Loading JSON into the Game

The actual game loop will live in main.py and be built in Days 29–30, but here’s a tiny preview to show how it all connects.

# main.py (preview – will expand in Day 29/30) import json from player import Player # you'll create this class like before def load_world(): with open("data/world.json", "r", encoding="utf-8") as f: return json.load(f) def load_villages(): with open("data/villages.json", "r", encoding="utf-8") as f: return json.load(f) def load_creatures(): with open("data/creatures.json", "r", encoding="utf-8") as f: return json.load(f) def main(): world = load_world() villages = load_villages() creatures = load_creatures() player = Player() print("RetroRealm data loaded.") print(f"Regions: {list(world['regions'].keys())}") print(f"Villages: {[v['name'] for v in villages]}") print(f"Creature regions: {list(creatures.keys())}") if __name__ == "__main__": main()

What You’ve Achieved on Day 28

now that your at the end of this post you have built:

  • A world generator that builds regions and tile maps → world.json

  • A village generator that reads the world and outputs interiors + NPCs → villages.json

  • An NPC generator module used by other tools

  • A creature data generator → creatures.json

  • Per-tile movement rules (north/south/east/west) baked into your JSON data

  • A clean separation between content generation and game logic

Next up (Day 29):

  • Load these JSON files in main.py

  • Implement player movement on the overworld using each tile’s directions

  • Trigger creature encounters using the danger level

  • Add the first version of combat

All example files for this series are available on my GitHub: Learn-Python-in-30-Days
You can see the full series here Learn Python in 30 Days series!

Hope you have enjoyed this post, thanks Matty

Comments

Popular posts from this blog

6502 - Part 2 Reset and Clock Circuit

Math Behind Logic Gates

Building a 6502 NOP Test