Project Tutorial: Build a Text-Based Garden Simulator Game with Python OOP
Object-oriented programming is one of those concepts that makes perfect sense in a textbook and then feels slippery the moment you try to use it in a real project. What exactly is self? When do you use inheritance? What's the difference between a class and an object?
The best way to make OOP click is to build something with it. In this project, we're going to build a text-based garden simulator game entirely from scratch using Python classes. You'll be able to forage for seeds, plant them, tend your garden, and harvest your crops, all from the command line. Along the way, every OOP concept we use will have an immediate, tangible reason to exist.
What You'll Learn
By the end of this tutorial, you'll know how to:
- Design a class hierarchy using inheritance
- Define attributes and methods to give your objects characteristics and behaviors
- Override inherited attributes in child classes
- Write a helper function that works across multiple class types
- Build an interactive game loop with input validation and error handling
- Use the
randommodule to add unpredictability to gameplay
Before You Start
You'll need Python 3.8+ and a Jupyter Notebook environment. No additional libraries are required beyond Python's built-in random module.
Some familiarity with Python loops, functions, and dictionaries will help. If you'd like to brush up first, the Python Basics for Data Analysis path covers everything you'll need. You can access the full project in the Dataquest app, and the solution notebook is on GitHub.
The Game Plan
Before writing a single line of code, let's sketch out what we're building:
- Foraging: Search for random seeds to add to your inventory
- Planting: Choose a seed from your inventory and plant it
- Tending: Care for your plants to advance them through growth stages
- Harvesting: Collect yield from fully grown plants
We'll use two classes to model this: Plant (and its subclasses) to represent the things being grown, and Gardener to represent the player. A helper function will handle item selection, and a main game loop will tie everything together.
Let's start with our only import.
import random
That's it. One library, for adding unpredictability to the foraging mechanic.
Part 1: The Plant Class
Defining Attributes
In OOP, a class is a blueprint. It doesn't do anything on its own until we use it to create an actual object. Our Plant class is the blueprint for every plant in the game. It defines what characteristics a plant has and what a plant can do.
class Plant:
def __init__(self, name, harvest_yield):
self.name = name
self.harvest_yield = harvest_yield
self.growth_stages = ["seed", "sprout", "mature", "flower", "fruit", "harvest-ready"]
self.current_growth_stage = self.growth_stages[0]
self.harvestable = False
The __init__ method is a special method that runs automatically whenever a new Plant object is created. It initializes all the attributes for that specific plant instance.
self is worth pausing on if it's new to you. Think of self as referring to the specific plant we're creating. When we eventually create a tomato plant, self is that tomato. When we create a carrot, self is that carrot. Each object gets its own copy of these attributes, independent of every other object.
Our plant has five attributes: a name, a harvest yield (how much we get when we pick it), a list of growth stages it goes through, its current growth stage (starting at "seed"), and a boolean flag for whether it's currently harvestable (starting as False).
Learning Insight: Attributes are the "what" of a class and methods are the "how." A plant's name, growth stage, and harvestable status are what it is. Growing and harvesting are what it does. Keeping that distinction clear makes it much easier to design your classes before you write any code.
Adding Methods
Now let's give our plant some behaviors.
def grow(self):
current_index = self.growth_stages.index(self.current_growth_stage)
if self.current_growth_stage == self.growth_stages[-1]:
print(f"{self.name} is already fully grown!")
elif current_index < len(self.growth_stages) - 1:
self.current_growth_stage = self.growth_stages[current_index + 1]
if self.current_growth_stage == "harvest-ready":
self.harvestable = True
def harvest(self):
if self.harvestable:
self.harvestable = False
return self.harvest_yield
else:
return None
grow() finds the current position in the growth stages list and advances the plant one step forward. If it's already at the last stage, it prints a message and stops. If advancing puts it at "harvest-ready", it flips harvestable to True.
harvest() checks whether the plant is harvestable, resets that flag to False, and returns the yield. Returning None if the plant isn't ready will let us handle that case gracefully when the Gardener calls this method.
Part 2: Plant Subclasses
Our general Plant class is the foundation, but not every plant grows the same way. Tomatoes flower and fruit before they're harvest-ready. Lettuce and carrots don't. This is where inheritance comes in.
class Tomato(Plant):
def __init__(self):
super().__init__("Tomato", 10)
class Lettuce(Plant):
def __init__(self):
super().__init__("Lettuce", 5)
self.growth_stages = ["seed", "sprout", "mature", "harvest-ready"]
class Carrot(Plant):
def __init__(self):
super().__init__("Carrot", 8)
self.growth_stages = ["seed", "sprout", "mature", "harvest-ready"]
Tomato(Plant) means "Tomato inherits from Plant." The super().__init__() call brings over everything from the parent class, so we get all five attributes and both methods without rewriting anything. We just fill in the name and yield values specific to a tomato.
Lettuce and Carrot do the same thing, but then immediately reassign self.growth_stages to a shorter list. This is attribute overriding: the child class starts with the parent's growth stages, then replaces them with its own. The plant has no memory of what was there before.
Think of it like variable reassignment. If x = 1 and then x = 2, Python doesn't remember that x was ever 1. Same principle here.
With three plant types defined, you could easily add more by creating new subclasses and adjusting the name, yield, and growth stages as needed.
Part 3: The select_item() Helper Function
Before we build the Gardener class, we need a helper function that will display a list or dictionary of options and let the player pick one by number. This same function will be used when planting (selecting from inventory) and harvesting (selecting from planted plants), so keeping it separate and reusable is the right call.
def select_item(items):
if type(items) == dict:
item_list = list(items.keys())
elif type(items) == list:
item_list = items
else:
print("Invalid items type.")
return None
for i in range(len(item_list)):
try:
item_name = item_list[i].name
except:
item_name = item_list[i]
print(f"{i + 1}. {item_name}")
while True:
user_input = input("Select an item: ")
try:
user_input = int(user_input)
if 0 < user_input <= len(item_list):
return item_list[user_input - 1]
else:
print("Invalid input.")
except:
print("Invalid input.")
The function accepts either a dictionary or a list. When passed a dictionary (like the player's inventory), it converts the keys to a list. When passed a list (like the planted plants), it uses it directly.
The display loop tries to access .name on each item first. If that works, it means we're looking at a Plant object. If it fails (because we're looking at a plain string like "tomato"), it just uses the item itself. This is a small example of how sharing an attribute name across multiple classes (both Plant and Gardener have a name attribute) lets us write flexible code that doesn't need to check the specific type of object it's working with.
The while True loop at the bottom keeps prompting until the player provides a valid number. If they type something that can't be converted to an integer, the except block catches it and asks again.
Part 4: The Gardener Class
Now for the player. The Gardener class represents everything the person playing the game can do.
class Gardener:
plant_dict = {"tomato": Tomato, "lettuce": Lettuce, "carrot": Carrot}
def __init__(self, name):
self.name = name
self.planted_plants = []
self.inventory = {}
Notice plant_dict is defined before __init__, outside of any method. This makes it a class-level attribute, shared by all Gardener objects rather than specific to any one instance. It maps string names to the class blueprints themselves, not to objects. No parentheses means no individual plant is created here, just a reference to the recipe.
The three instance attributes are: the gardener's name, an empty list of plants currently in the ground, and an empty inventory dictionary where keys are item names and values are quantities.
The plant() Method
def plant(self):
selected_plant = select_item(self.inventory)
if selected_plant in self.inventory and self.inventory[selected_plant] > 0:
self.inventory[selected_plant] -= 1
if self.inventory[selected_plant] == 0:
del self.inventory[selected_plant]
new_plant = self.plant_dict[selected_plant]()
self.planted_plants.append(new_plant)
print(f"{self.name} planted a {selected_plant}!")
else:
print(f"{self.name} doesn't have any {selected_plant} to plant!")
When the player types "plant", this method calls select_item() on their inventory to get a choice, decrements the inventory count by 1, deletes the entry if it hits zero, and then creates a new plant object using plant_dict.
That self.plant_dict[selected_plant]() line is the trickiest in the whole project. Let's break it down. self.plant_dict[selected_plant] looks up the blueprint in our dictionary (say, the Tomato class). The () at the end then instantiates it, creating an actual Tomato object. The parentheses are what turn a blueprint into an object.
The tend() Method
def tend(self):
for plant in self.planted_plants:
if plant.harvestable:
print(f"{plant.name} is ready to be harvested!")
else:
plant.grow()
print(f"{plant.name} is now a {plant.current_growth_stage}!")
Tending loops through every planted plant and either reminds the player it's ready to harvest or calls plant.grow() to advance it one stage. This is OOP composition at work: the Gardener method delegates the actual growing logic to the Plant class's method. The Gardener doesn't need to know how growth stages work, it just calls grow() and trusts that the Plant object knows what to do.
The harvest() Method
def harvest(self):
selected_plant = select_item(self.planted_plants)
if selected_plant.harvestable == True:
if selected_plant.name in self.inventory:
self.inventory[selected_plant.name] += selected_plant.harvest()
else:
self.inventory[selected_plant.name] = selected_plant.harvest()
print(f"You harvested a {selected_plant.name}!")
self.planted_plants.remove(selected_plant)
else:
print(f"You can't harvest a {selected_plant.name}!")
The player selects a plant from their planted list. If it's harvestable, we call plant.harvest() which returns the yield and resets the harvestable flag, then add that number to the inventory. The plant gets removed from planted_plants since it's been picked. If the plant isn't ready, we let the player know.
The forage_for_seeds() Method
def forage_for_seeds(self):
seed = random.choice(all_plant_types)
if seed in self.inventory:
self.inventory[seed] += 1
else:
self.inventory[seed] = 1
print(f"{self.name} found a {seed} seed!")
This is where random comes in. random.choice() picks one item from a list at random, giving each forage attempt a different result. If the found seed is already in inventory, we add to the count; if not, we create a new entry. The all_plant_types list gets defined in the game setup section below.
Part 5: The Main Game Loop
With both classes and the helper function defined, we can put the game together.
all_plant_types = ["tomato", "lettuce", "carrot"]
valid_commands = ["plant", "tend", "harvest", "forage", "help", "quit"]
print("Welcome to the garden! You will act as a virtual gardener.\nForage for new seeds, plant them, and then watch them grow!\nStart by entering your name.")
gardener_name = input("What is your name? ")
print(f"Welcome, {gardener_name}! Let's get gardening!\nType 'help' for a list of commands.")
gardener = Gardener(gardener_name)
all_plant_types is the list that forage_for_seeds() draws from. valid_commands is the full set of things the player can type. We collect the player's name as input and pass it to Gardener() to create our specific game instance.
Now the game loop:
while True:
player_action = input("What would you like to do? ")
player_action = player_action.lower()
if player_action in valid_commands:
if player_action == "plant":
gardener.plant()
elif player_action == "tend":
gardener.tend()
elif player_action == "harvest":
gardener.harvest()
elif player_action == "forage":
gardener.forage_for_seeds()
elif player_action == "help":
print("*** Commands ***")
for command in valid_commands:
print(command)
elif player_action == "quit":
print("Goodbye!")
break
else:
print("Invalid command.")
The while True loop runs indefinitely until the player types "quit", which triggers a break. Every other valid command calls the corresponding method on our gardener object. The .lower() call on the input means players can type "PLANT", "Plant", or "plant" and the game will handle it correctly.
Notice how compact this loop is despite everything it does. All the planting logic lives in Gardener.plant(), all the growth logic lives in Plant.grow(), all the item selection logic lives in select_item(). The main loop is just a dispatcher: it reads input and calls the right method. That's the payoff of building with OOP.
Watching It Run
Here's what a short game session looks like:
Welcome to the garden! You will act as a virtual gardener.
Forage for new seeds, plant them, and then watch them grow!
Start by entering your name.
What is your name? Jane Doe
Welcome, Jane Doe! Let's get gardening!
Type 'help' for a list of commands.
What would you like to do? help
*** Commands ***
plant
tend
harvest
forage
help
quit
What would you like to do? forage
Jane Doe found a lettuce seed!
What would you like to do? plant
1. lettuce
Select an item: 1
Jane Doe planted a lettuce!
What would you like to do? tend
Lettuce is now a sprout!
What would you like to do? tend
Lettuce is now a mature!
What would you like to do? tend
Lettuce is now a harvest-ready!
What would you like to do? harvest
1. Lettuce
Select an item: 1
You harvested a Lettuce!
What would you like to do? quit
Goodbye!
Everything works together: the Plant blueprint, the Gardener methods, the select_item() helper, and the game loop. Each piece has one responsibility, and they talk to each other cleanly through method calls and return values.
Next Steps
There's a lot of room to extend this game, and extending it is one of the best ways to deepen your OOP skills.
Add a get_inventory() method. The game currently doesn't have a way to view what's in your inventory without planting or harvesting. Adding this is a good starter challenge since it follows the same patterns we've already used.
Introduce a currency system. Add a value attribute to each plant class, a currency attribute to Gardener, and buy() and sell() methods. Players could earn currency by harvesting and spend it on seeds instead of foraging.
Add pests and random events. Right now, every tend action succeeds. Try adding a small chance (using random) that a plant doesn't grow, or that a pest causes it to regress a growth stage. Games become more interesting when there's something at stake.
Add a timing system. Currently a player can spam "tend" and harvest in seconds. You could use Python's time module to require a delay between tend actions, or allow each plant to only grow once per game turn.
Add achievement messages. Reward players for milestones like harvesting 10 plants, planting all three plant types, or discovering a rare seed. A simple counter and a few conditional print statements are all you need.
Resources
- Project in the Dataquest app
- Solution notebook on GitHub
- Python Basics for Data Analysis path
- Dataquest Community Forum
If you extend the game, share it in the community and tag @Anna_strahl. This project has a lot of directions it can go, and seeing what others build is genuinely interesting.