Learn Python in 30 Days — Day 29 – Final Project Build (Part 2): Movement, Encounters & Combat

Day 29 – Final Project Build (Part 2): Movement, Encounters & Combat

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

Yesterday, in Day 28, you built the world generators that produced the data:

  • world.json
  • villages.json
  • creatures.json

Today, RetroRealm stops being data and becomes a game.

By the end of this post you will have:

  • A fully modular project folder
  • A working player class
  • Movement that respects true blocking tiles (mountains/rivers = impassable, always)
  • Region-to-region transitions
  • Random encounters based on danger level
  • A working turn-based combat engine
All example files for this series are available on my GitHub: Learn-Python-in-30-Days

Project Structure

The project structure should look like this:

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

Today we fill in:

  • player.py
  • world_data.py
  • map_engine.py
  • combat.py
  • main.py

Day 30 will fill in:

  • NPCs
  • Villages
  • Shops
  • Saving & Loading

Creating the Player Class

Before we can move, fight, or gain XP, we need an actual player character.

Inside your retrorealm/ folder, create a new file called player.py and add the following code:

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

This class is used everywhere: movement, combat, shops, villages, saving/loading.

Loading Game Data

We need to load our world data and we don't want to repeat lots of code, so we create a helper function to do this. Create the file world_data.py and add the following code:
# world_data.py import json def load_json(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) def load_world(): return load_json("data/world.json") def load_villages(): return load_json("data/villages.json") def load_creatures(): return load_json("data/creatures.json")

Movement & Map Logic (NEW FILE)

Next create the file map_engine.py this will enforce blocking terrain and safe region transitions so that:

  • You cannot enter mountain, river, or water tiles
  • You can’t “slip” off the edge of the map into negative coordinates
  • Region-to-region travel only happens via defined exits

Helper Functions

First let create some basic functions to help find out which region and tile our player is located in.

# map_engine.py
from utils import direction_from_input # --- Basic Getters --- def get_current_region(world, player):
return world["regions"][player.location["region"]] def get_current_tile(world, player):
region = get_current_region(world, player)
x = player.location["x"]
y = player.location["y"]
return region["map"][y][x]

Movement Description

To make the game interactive we want to display information about the players location.

def describe_location(world, player): region = get_current_region(world, player) tile = get_current_tile(world, player) print("\n--- Location ---") print(f"Region: {region['name']} ({region['id']})") print(f"Position: x={player.location['x']}, y={player.location['y']}") print(f"Terrain: {tile['terrain']}") print(f"Danger level: {tile['danger_level']}") if tile.get("feature") == "village": print("A village is here. (Entering villages added on Day 30.)")

Strict Tile Blocking

We need to make sure the following tiles are always impassable:

  • "mountain"
  • "river"
  • "water" (for lakes, rivers, seas, etc.)

Even if the generator accidentally gives them movement directions, the map engine overrides that.

Add this helper function to map_engine.py:

def is_tile_blocking(tile): # Hard-block terrain types that should never be walked on if tile["terrain"] in ("mountain", "river", "water"): return True # Otherwise, rely on the Day 28 movement flags return False

World Movement

The movement handler checks for all the following conditions:

  • Respects each tile’s directions (north/south/east/west)
  • Handles region edge transitions via exits 
  • Checks the destination tile before committing movement 
  • Never lets you walk into water/mountain/river or off the map
  • Only north, south, east, west are valid directions.

We’ll accept single-letter commands: nsew.

def move_player_overworld(player, world, command): direction = direction_from_input(command) if direction is None: print("Unknown direction. Use n/s/e/w.") return current_region_id = player.location["region"] region = world["regions"][current_region_id] tile = get_current_tile(world, player) # 1. Check the tile's movement rules if not tile["directions"].get(direction, False): print("You can't go that way.") return # 2. Propose new coordinates inside the current region x, y = player.location["x"], player.location["y"] if direction == "north": y -= 1 elif direction == "south": y += 1 elif direction == "east": x += 1 elif direction == "west": x -= 1 size_y = len(region["map"]) size_x = len(region["map"][0]) target_region_id = current_region_id region_changed = False # 3. Handle region edge transitions if x < 0: exit_id = region.get("exits", {}).get("west") if exit_id: target_region_id = exit_id region = world["regions"][exit_id] size_y = len(region["map"]) size_x = len(region["map"][0]) x = size_x - 1 # rightmost column of new region region_changed = True else: print("The world ends in a sheer cliff. You cannot go further west.") return elif x >= size_x: exit_id = region.get("exits", {}).get("east") if exit_id: target_region_id = exit_id region = world["regions"][exit_id] size_y = len(region["map"]) size_x = len(region["map"][0]) x = 0 # leftmost column region_changed = True else: print("An impassable boundary stops your journey east.") return if y < 0: exit_id = region.get("exits", {}).get("north") if exit_id: target_region_id = exit_id region = world["regions"][exit_id] size_y = len(region["map"]) size_x = len(region["map"][0]) y = size_y - 1 # bottom row region_changed = True else: print("Snow-capped peaks block the way north.") return elif y >= size_y: exit_id = region.get("exits", {}).get("south") if exit_id: target_region_id = exit_id region = world["regions"][exit_id] size_y = len(region["map"]) size_x = len(region["map"][0]) y = 0 # top row region_changed = True else: print("A chasm or wasteland blocks the way south.") return # 4. At this point, (x, y) must be valid inside `region`. new_tile = region["map"][y][x] if is_tile_blocking(new_tile): print("That terrain is impassable.") return # 5. Commit new position & region player.location["region"] = target_region_id player.location["x"] = x player.location["y"] = y if region_changed: print(f"You travel {direction} into {region['name']}.") else: print(f"You move {direction}.")

With this the player should never be able to step onto mountain/river/water tiles, and only cross map edges where exits are defined.

Encounter System

Next we want to add some interaction to our world, so it’s time to create the file creatures.py.
In this file we are going to handle the random appearance of a creature based upon the danger level of the tile.
import random from map_engine import get_current_tile def choose_creature_for_tile(world, creatures, player): region_id = player.location["region"] region_creatures = creatures.get(region_id, []) tile = get_current_tile(world, player) danger = tile["danger_level"] if danger <= 0: return None valid = [ c for c in region_creatures if c.get("min_danger", 0) <= danger ] return random.choice(valid) if valid else None def maybe_trigger_encounter(world, creatures, player, combat_func): tile = get_current_tile(world, player) danger = tile["danger_level"] if danger <= 0: return if random.randint(1, 10) > danger: return creature = choose_creature_for_tile(world, creatures, player) if creature: print(f"\nA wild {creature['name']} appears!") combat_func(player, creature)

Combat Engine

We also want to be able to fight the different creatures we encounter in the world so we need to make the combat system for this. Create the file combat.py and add the following code:
# combat.py import random def show_player_stats(player): print("\n--- Player ---") print(f"HP: {player.health}") print(f"MP: {player.magic}") print(f"Level: {player.level}") print(f"XP: {player.xp}") print(f"Gold: {player.gold}") def combat(player, base_creature): creature = dict(base_creature) creature_hp = creature["health"] print(f"\n=== Combat: {creature['name']} ===") while creature_hp > 0 and player.health > 0: print(f"\nYour HP: {player.health}") print(f"{creature['name']} HP: {creature_hp}") action = input("(a)ttack or (r)un? ").lower().strip() if action == "a": dmg = random.randint(2, 6) creature_hp -= dmg print(f"You hit the {creature['name']} for {dmg} damage.") elif action == "r": print("You escape!") return else: print("You hesitate...") if creature_hp > 0: player.health -= creature["attack"] print(f"The {creature['name']} strikes you for {creature['attack']}!") if player.health <= 0: print("\nYou have died...") raise SystemExit print(f"\nYou defeated the {creature['name']}!") player.xp += creature.get("xp", 0) player.gold += creature.get("gold", 0)
This allows us to see the players stats and also fight or run away from creatures. For defeating creatures we also get a reward of experience points and gold.

Utility Helpers

To keep files clutter free we can create different utility functions to help our game run smoothly, lets place them in the file utils.py
# utils.py def direction_from_input(command): mapping = { "n": "north", "s": "south", "e": "east", "w": "west" } return mapping.get(command.lower())

Main Game Loop

Finally we want to be able to run the game, so its time to build the main loop. But before that we want to create a function that helps setup and load all the games data.
# 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 show_player_stats, combat def start_game(): world = load_world() villages = load_villages() creatures = load_creatures() player = Player() print("=== RetroRealm ===") print("World loaded successfully.") print(f"Starting region: {player.location['region']}") main_loop(player, world, villages, creatures) def main_loop(player, world, villages, creatures): while True: describe_location(world, player) print("\nCommands: n/s/e/w, stats, q") cmd = input("> ").strip().lower() if cmd in ("q", "quit"): print("Thanks for playing!") break if cmd == "stats": show_player_stats(player) continue if cmd in ("n", "s", "e", "w"): move_player_overworld(player, world, cmd) maybe_trigger_encounter(world, creatures, player, combat) else: print("Unknown command.") if __name__ == "__main__": start_game()

What You’ve Built on Day 29

In this post we have used alot of the different things we have learnt over the past 29 days and completed a large amount of the games behaviours:

  • Split the engine into modular Python files
  • Created a player system with stats and inventory
  • Implemented movement with strict blocking rules
  • Ensured mountains and rivers are completely impassable
  • Added region edge transitions
  • Added a danger-based random encounter system
  • Built a working turn-based combat loop

Whilst RetroRealm is not completely finished, it is now a playable text exploration RPG.

You can download ALL DAY 29 FILES

Next up (Day 30): – Final Project Build (Part 2)

In the final day of the series we will undertake the remaining parts to build the game:

🎒 Enter and explore villages

🗣 Talk to NPCs

🛒 Use shops

🧪 Improve combat with spells, items & levelling

💾 Add saving & loading

🏁 Final polish & completion of the series

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