After frustration at modern TV and film, I have returned to the ever reliable written word, by which I mean crude text-based games in Python.
At its core the text based game is just conditional logic.
If this, then print this, otherwise, print something else.
choice1 = input("What's your name?")
if choice1 == 'Eggbert':
print ("Hello!")
else:
print("Invalid name")
All fun and games. But in a text based game we want 1 of 2 or more specific options. For example:
choice1 = input("Are you better at ENGLISH or FRENCH?")
if choice1 == 'ENGLISH':
print ("Hello!")
elif choice1 == 'FRENCH':
print("Bonjour!")
else:
print("Invalid entry")
This is fine enough but two weaknesses we can see are:
Case sensitivity. Writing ‘french’ or ‘French’ seems an equally valid entry, but it would be rejected as it is not == (equal to) ‘FRENCH’
The else: statement doesn’t actually stop us, which is to say, we can get _past _choice1 by writing anything. In the case of text based games, there are right and wrong answers, so why let the player get past a stage by writing nonsense?
To solve this, we do the following:
choice1 = input("Are you better at ENGLISH or FRENCH?").upper()
while choice1 != 'ENGLISH' and choice1 != 'FRENCH':
choice1 = input('Invalid entry. Are you better at ENGLISH or FRENCH?: ')
if choice1 == 'ENGLISH':
print ("Hello!")
elif choice1 == 'FRENCH':
print("Bonjour!")
The upper() function converts the user input into all caps (even if the user writes in all caps). Doing this at the end of the input() function means all subsequent conditionals will work, no need to put upper on anything else.
Using a while loop with negative conditions keeps the user stuck until they have written 1 of these choices. What prevents this while loop from running indefinitely is that they have the chance to redefine the input.
It is with this logic that we make a common starting feature of games: a player choosing their name.
player_name = input("What is your name?: ")
print(f"Hello, {player_name}")
Use of an f string i.e. f” allows the variable player_name to be invoked in a string, in this case a greeting. But let me show you a flaw of this simple approach:
What is your name?: aaaaaaaaaaaaaaaaaaaaaaaaaa1111111111111111111111111111111111111111
Hello, aaaaaaaaaaaaaaaaaaaaaaaaaa1111111111111111111111111111111111111111
Not much good this; if your cat walked across your keyboard and pressed Enter, the name would be stuck like this and printed in all subsequent f strings.
We should therefore insert some rules. I’ve decided 1) the name should not be more than 12 characters, and 2) made up of only letters (no numbers, punctuation or spaces).
player_name = input("What is your name?: ")
while not player_name.isalpha() or len(player_name) > 12:
player_name = input("Invalid entry. Must be <12 characters and only letters A-Z.\nTry again: ")
I use a while not loop because it works well with isalpha(), a method of checking if a string is all letters (e.g. A-Z) or not.
len() > 12 is True when the string is more than 12 characters
Putting them together in an or loop, checks if 1, or both of these conditions is true.
We’ll test it now:
What is your name?: john1
Invalid entry. Must be <12 characters and only letters A-Z.
Try again: johnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn
Invalid entry. Must be <12 characters and only letters A-Z.
Try again: john11111111111111111111111111111111111
Invalid entry. Must be <12 characters and only letters A-Z.
Try again: john
Hello, john
You’ll note that the logic governing this is similar to the logic governing what constitutes a ‘strong’ password, valid email addresses and so on.
You could probably make a decent text based game with just these features, but one thing I’ve learnt is that writing lines of text is the easy thing. Adding functionality, that’s the hard part.
*Note - this is the first time I use the \n new line character. It is very helpful for f strings, inputs and so on, ensuring you can keep lines printed out in a user-friendly way. Basically, write \n, and everything after will appear on a new line.
My final game (link below) uses 2 extra features:
1) writing out the script via the sys.stdout.writeout() method, the speed of which can be user-defined, and 2) Dungeons and Dragons style combat using the random.randint() method.
The combat is quite simple. Anyone who’s made a dice roll will have used it.
import random
dice_roll = random.randint(1,6)
print(f"You rolled a {dice_roll}!")
Import the random module. Random.randint(1,6) means, give me a random number between 1 and 6. Then print it.
This can also be done without the variable assignment (printed straight away), and it makes for a big difference. I’ll show you with a more combat based example.
import random
player_attack = random.randint(1,6)
enemy_health = 2
enemy_name = "Jeff"
print(f"{enemy_name} is on {enemy_health}health. Time to attack!")
print(f"You hit {enemy_name} for {player_attack} damage.")
Looks fine right? Well, a problem is that the random.randint() will not run again and again and again, when we loop through a fight sequence. It will run one time, i.e. to assign the variable player_attack with the value of a random integer between 1 and 6. This means if you wanted a combat sequence, you could be hitting the same amount of damage every time you attack. This is not the point at all.
That being said, smart code is lazy code, and you don’t want to write random.randint(1,6) every time. You may even want to modify the attack range if your player finds a brand new fancy weapon or something. This is where I came up with a ‘max damage’ for characters.
player_max_damage = 6
print(
f"You hit {enemy_name} for {random.randint(1, player_max_damage)} damage.")
The key difference is that the random number is generated within each attack (between 1 and the max damage), not before and throughout.
The next concern should be how to win or lose a battle? How do we make sure it continues until one or both of us is dead?
Well first, we need to reassign values to health, such as:
enemy_health -= 2
This will subtract 2 from the current enemy health and then reassign that value to the variable.
Then, we need to end the fight if the enemy’s or our health reaches 0 or less.
Here’s how I achieved this:
while True:
print('--⚔--⚔--⚔--⚔--ATTACK--⚔--⚔--⚔--⚔--')
input(f"You have {player_health} health. {mob_name} has {mob_healths[mob_name]} health")
player_attack = random.randint(1, player_damage)
mob_healths[mob_name] -= player_attack
if mob_healths[mob_name] > 0:
print(f"You hit the {mob_name} for {player_attack} damage.")
print(f"{mob_name} is now on {mob_healths[mob_name]} health.\n")
else:
print(f"You hit the {mob_name} for {player_attack} damage and defeat it!\n")
break
Putting this in a while loop ensures the conditions are checked, and that the only way we can leave is DEATH…
Jokes aside, most of these values are predefined, will show these later - they are easy to change.
In short, we have an if statement for if the enemy has more than 0 health, and an else if they don’t (we defeated it!)
Now within that same loop, we can put our defense (the enemy should get a turn to attack as well).
print('--🛡--🛡--🛡--🛡--DEFEND--🛡--🛡--🛡--🛡--')
input(f"You have {player_health} health. {mob_name} has {mob_healths[mob_name]} health")
mob_attack = random.randint(1, mob_max_damages[mob_name])
player_health -= mob_attack
if player_health > 0:
print(f"The {mob_name} hits you for {mob_attack} damage.")
print(f"You are now on {player_health} health.\n")
else:
print(f"The {mob_name} hits you for {mob_attack} damage.")
print("💀 You died! GAME OVER 💀\n")
break
I wrap this all together, within a combat() function, needing only the variables:(mob_name, player_health). Player_health is not so much a concern, when we run future combat() functions we can just put the {player_health} curly braced inside. I wanted the option to ‘level up’ the character at some point and therefore increase += their health.
Anyway, what I think makes this combat function powerful is that the mob_name will reference an indexed position in 1 list and 2 dictionaries i.e. [0] or [2] in mob_name, mob_healths, and mob_damages:
mob_names = ["Bug 🐛", "Wolf 🐺", "Goblin 👺"]
mob_healths = {
mob_names[0]: 6,
mob_names[1]: 8,
mob_names[2]: 10
}
mob_max_damages = { # a random.randint() will run between 1 and these numbers
mob_names[0]: 3,
mob_names[1]: 5,
mob_names[2]: 6
}
These run independent of the combat() function, and if you want to change the theme of the game then all you have to do is change the name. Nothing else needs to change, everything will print out and run for you. Of course you can change the healths and damages but be sure to consider balance in your game.
Now for the text speed, which relates to parts of the sys and time module.
I did this all in one place at the top, along with the random module:
import time # for time.sleep() printing text
import sys # for text speed (optional)
import random # for 'rolling' a dice etc.
Write an introduction, or scene description. Give it a name and try and keep it all in one string. This can be done by avoiding a long line as so:
intro = (f"Welcome to {game_name}, a text-based adventure game.\n"
f"{game_name} is set in {world}, in {time_or_setting}.\n"
"Scenes will be read out for you, like this.\n"
"Choices will be printed out in full and require you to type an answer.\n"
"They will follow a ':' semicolon. You can only do 1 thing at a time. \n"
"Let's begin with the first choice, your name.\n"
)
Using \n line breaks and keeping the quotation marks at the same indentation as the previous, ‘intro’ can be several lines long while still belonging to the same variable. That is all we need to have it written out:
for char in intro:
sys.stdout.write(char)
sys.stdout.flush()
time.sleep(text_speed)`
This works for with a for loop. We say that for every char in intro (for every character in intro)
Note that instead of char we could write anything, like ‘letter’, ‘thing’ and so on, as long as we correctly refer to it again in the sys.stdout.write() function. Anyway, char makes sense, and readability is important.
sys.stdout.write() is very similar to print(), only different insofar as it prints every character, and doesn’t add spaces. It’s basically a precise printing tool.
sys.stdout.flush() is very important here; it ensures that rerunning this loop still keeps the characters on the same line. Without it, the for loop would make each character print underneath itself.
time.sleep() is what we use as the time gap between each character being printed. I reference text_speed, which I put at the top of my program, allowing the user to define speed as they like (they may read faster or slower than me).
Wrapping up - I put the bulk of my game inside a main() function and indented the lot. This is a good idea because putting underneath the lot:
if __name__ == "__main__":
main()
Will ensure that the program can only be run from the program directly; it’s best practice. I also like to hide the script this way and look at more ‘master’ items, such as:
import time # for time.sleep() printing text
import sys # for text speed (optional)
import random # for 'rolling' a dice etc.
# User-defined items
game_name = "The Project" # *changeable - will be reflected everywhere else
title = (f"-💾-⌨-💻---{game_name}---💻-⌨-💾-") # modify emojis as you wish
world = "Procrastinationland"
# be conscious of grammar when printed
time_or_setting = "2025"
# get a feel for how fast you like the text speed when you run the code
text_speed = 0.05
Voila. With those building blocks, more scenes, enemies, stories etc. can be fleshed out to make it more of a story.
Additional improvements I would make would be:
1) ‘dice rolls’ outside of combat to determine the possibility of doing things. I have made this before and it’s pretty easy - ‘if < 10, fail. If >10, pass. If == 1, spectacularly fail, etc.
2) consequences for decisions by storing a boolean that can be referred to later. For example, if you did choose to help the hungry villagers, then help_village = True. In your character’s return weeks later , the villagers could either be gone, or alive and willing to repay you, simply based on that boolean.
3) animations. While this technically contradicts the text based nature of the game, I am already using text read out, and a similar principle could do something like showing the character and an enemy get closer until they meet at crossed swords. There are lots of possibilities.
I plan to return to make other games in Python and I look forward to publishing here.
The full code for this project is on github for others to copy and change: https://gist.github.com/JBeresford94/285ec3dc58f694dca51a482cffc6a5fa
Top comments (0)