Object-oriented programming is one of those concepts that makes perfect sense in a textbook and then feels slippery when you try to use it in a real project. What exactly is self? When do you use inheritance? What is the difference between class and object?
The best way to make OOP clickable is to create something with it. In this project, we are going to create a text-based garden simulator game completely from scratch using Python classes. You’ll be able to forage for seeds, plant them, tend to your garden and harvest your crops, all from the command line. Along the way, every OOP concept we use will have an immediate, concrete reason for its existence.
What will you learn?
By the end of this tutorial, you will know how to:
- Design a class hierarchy using inheritance.
- Define attributes and methods to give your objects properties and behaviors.
- Override inherited properties in child classes.
- Write a helper function that works across multiple class types.
- Create an interactive game loop with input validation and error handling
- use
randomModules to add unpredictability to gameplay
Before you begin
You will need Python 3.8+ and a Jupyter Notebook environment. No additional libraries are required beyond Python’s built-in ones. random Module
Some familiarity with Python loops, functions, and dictionaries will help. If you want to brush up first, Python Basics for Data Analysis Path covers everything you’ll need. You can access the complete project. Data Coast Appand Solution Notebook is on. GitHub.
The game plan
Before writing a single line of code, let’s outline what we’re building:
- fodder: Find a random seed to add to your inventory.
- Planting: Select a seed from your inventory and plant it.
- tending: Take care of your plants to advance them through the growth stages.
- harvest: Collect produce from fully grown plants.
We will use two classes to model this: Plant (and its subclasses) to represent things grown, and Gardener Representing the player. A helper function will handle item selection, and a central game loop will tie everything together.
Let’s start with our only import.
import randomThat’s it. A library, to add unexpected additions to the foraging mechanic.
Part 1: The Plant Class
Defining attributes
In OOP, a Class There is a blueprint. It doesn’t do anything by itself unless we use it to create an actual object. Our Plant Each plant in the class game has a blueprint. It explains what the plant has and what the 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 = Falsegave __init__ A method is a special method that runs automatically whenever new. Plant Objection arises. This initializes all the attributes for that particular plant instance.
self If it’s new to you, it’s worth stopping by. think self Referring to the specific plant we are creating. When we finally make a tomato plant, self Is it a tomato? When we make carrots, self Is that a carrot? Each object gets its own copy of these attributes, independent of every other object.
Our plant has five attributes: a name, a crop yield (how much we get when we pick it), a list of growth stages it goes through, its current growth stage (starting with “seed”), and a boolean flag for whether it’s currently harvestable. False).
Learning Insights: Attributes are the “what” of a class and methods are the “how”. What is the plant’s name, growth stage, and harvestable status? is. Growing and reaping is the same. does. Keeping this distinction clear makes it much easier to design your classes before you write code.
Adding methods
Now let’s give our plant some behavior.
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 Nonegrow() Finds the current position in the list of growth stages and advances the plant one step. If it is already at the last step, it prints a message and stops. If the advance puts it at “ready to harvest”, it flips. harvestable To True.
harvest() Checks if the plant is harvestable, resets this flag. Falseand returns the output. return None If the plant is not ready, let us handle the matter gracefully when the gardener calls this method.
Part 2: Plant subclasses
Our general Plant Class is the basis, but not every plant grows the same way. Tomatoes flower and fruit before they are ready for harvest. Lettuce and carrots do not. 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) It means “inherited from the tomato plant.” gave super().__init__() The call pulls everything up from the parent class, so we get all five attributes and both methods without rewriting anything. We just fill in the name and get specific prices for tomatoes.
Lettuce And Carrot Do the same thing, but then immediately reassign. self.growth_stages in a short list. This is Overriding attribute: The child class starts with the development stages of the parent, then replaces them. The plant has no memory of what was there before.
Think of it like variable reassignment. If x = 1 And then x = 2Python doesn’t remember this. x Once upon a time 1. Same rule here.
With three plant types defined, you can easily add more by creating new subclasses and adjusting names, production and growth stages as needed.
Part 3: select_item() Helper function
Before we build. Gardener In the class, we need a helper function that displays a list or dictionary of options and lets the player select one by number. The same function will be used during planting (selecting from inventory) and harvesting (selecting from planted plants), so it makes sense to keep it separate and reusable.
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 a dictionary or a list. When a dictionary is passed (such as a player’s inventory), it converts the keys into a list. When a list is passed (such as planted plants), it uses it directly.
Attempts to access the display loop. .name First on everything. If it works, that means we’re looking at one. Plant object if it fails (because we’re looking at a simple string like "tomato"), it only uses the item itself. Here’s a small example of how to share an attribute name across multiple classes (both Plant And Gardener There is one name attribute) lets us write flexible code that doesn’t need to check the specific type of object we’re working with.
gave while True The bottom loop continues to prompt until the player provides a valid number. If they type something that cannot be converted to a number, except Block grabs it and asks again.
Part 4: The Gardener’s Class
Now for the player. gave Gardener A class represents everything a person playing a 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 = {}Notes plant_dict described earlier. __init__out of any way. This makes it a class-level attribute, shared by all. Gardener objects rather than specific to a single instance. It maps string names to class blueprints themselves, not objects. The absence of parentheses means that there is no individual plant made here, just a reference to the recipe.
For example, three 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.
gave 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 is called. select_item() On their inventory to get the selection, decrements the inventory count by 1, deletes the entry if it hits zero, and then creates a new Plant object. plant_dict.
He self.plant_dict(selected_plant)() The line is the most difficult in the whole project. Let’s break it down. self.plant_dict(selected_plant) looks up the blueprint in our dictionary (say, Tomato class). gave () The end then instantiates it, creating a real one. Tomato The object parentheses are what turn the blueprint into an object.
gave 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 each planted plant and either reminds the player that it is ready to harvest or calls. plant.grow() To advance it by one stage. This is OOP composition at work: Gardener The method assigns the actual incrementing logic. Plant class method. gave Gardener No need to know how development steps work, it just makes calls. grow() And sure Plant The object knows what to do.
gave 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 chooses a plant from their plant list. If it is cultivable, we call. plant.harvest() which returns the yield and resets the harvestable flag, then add that number to the inventory. is removed from the plant planted_plants Since it has been raised. If the plant is not ready, we tell the player.
gave 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 the place. random comes in random.choice() Picks an item from the list at random, giving a different result to each foraging attempt. If the found seed is already in inventory, we increment the count. If not, we create a new entry. gave all_plant_types The list is described in the Game Setup section below.
Part 5: The main game loop
With both classes and a 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 That is the list forage_for_seeds() pulls from valid_commands There is a complete set of things that the player can type. We collect the player name as input and pass it. Gardener() To create our specific game instance.
Now 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.")gave while True The loop continues indefinitely until the player types “quit”, which triggers a. break. Every other valid command calls the corresponding method on us. gardener Objected .lower() Calling input means players can type “plant”, “plant” or “plant” and the game will handle it correctly.
Notice how compact this loop is despite everything. All planting logic remains. Gardener.plant()all development logic remains. Plant.grow()All item selection logic remains. select_item(). The main loop is just a dispatcher: it reads the input and calls the correct method. This is building payment with OOP.
Watch it go
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: Plant blueprint, Gardener methods, select_item() Helper, and game loop. Each piece has a responsibility, and they communicate with each other through method calls and return values.
Next Steps
There’s a lot of room to grow this game, and growing it is one of the best ways to deepen your OOP skills.
add get_inventory() method The game currently has no way to see what’s in your inventory without planting or harvesting. Adding this is a good starter challenge because it follows the same patterns we’ve used before.
Introduce currency system. add value Attribute from each plant class, a currency Attributed to Gardenerand buy() And sell() Modes Players could earn currency by harvesting crops and spend it on seeds instead of foraging.
Add bugs and random events. At this point, each trend process succeeds. Try adding a small chance (using random) that the plant does not grow, or that an insect overtakes its growth stage. Games become more interesting when something is at stake.
Add a timing system. Currently a player can spam and harvest in seconds. You can use Python’s time The module requires a delay between tended actions, or allows each plant to grow only once per game turn.
Add success messages. Reward players for milestones like harvesting 10 plants, planting all three plant types, or discovering rare seeds. A simple counter and a few conditional print statements are all you need.
Resources
If you improve the game, share it in the community and tag @Anna_strahl. This project has so many directions it could go, and it’s really interesting to see what others create.