Skip to content
wannacode

RPG 3: the whole game

Problem

From one fight to a game

The heroes and the monsters already fight. But a game is more than one fight: you create a hero, line up the monsters he will fight, and he goes through them one by one. Between the fights he rests or heals with magic.

If all of this is in the main program, that is where the hero will be, along with a list of monsters, the number of the next one, counters and checks. And who will stop the player from starting a fight without a hero, or asking a barbarian for magic?

The state of the game and its rules have to be in one place: in a class Game.

The idea

The Game class

The hero and the monsters are the state of the game. The methods of Game are the rules: what the player can do and what happens after that. The first one is creating a hero:

private Character hero;
private Monster[] monsters = new Monster[5];
private int count;
private int next;
private Random random;
public Game(Random random) {
this.random = random;
}
public void createHero(String name, String race) {
if (race.equals("paladin")) {
hero = new Paladin(name);
} else if (race.equals("barbarian")) {
hero = new Barbarian(name);
} else if (race.equals("witch")) {
hero = new Witch(name);
} else {
throw new GameException("No such race: " + race);
}
}
The random number generator comes from outside, through the constructor. In the real game it will be Random() without a seed, and in the examples it is Random(12), so the output is always the same.The fields start with _: they belong to the game and are not touched from outside. [None] * 5 is a list with 5 places, none of which holds anything yet (None).

An unknown race is no reason to create some random hero. The method throws GameError: our own exception for every move the rules do not allow. It gets its own card further on.

The idea

A lineup of monsters

The monsters wait in a queue: the one added first is the first fight. Here the queue is a list and two indexes:

public void addMonster(Monster monster) {
if (count == monsters.length) {
throw new GameException("The lineup is full: at most " + monsters.length + " monsters");
}
monsters[count] = monster;
count++;
}
public Monster createRandomMonster() {
int kind = random.nextInt(3);
if (kind == 0) {
return new FallenShaman();
}
if (kind == 1) {
return new SkeletonKing();
}
return new Butcher();
}
public boolean hasMonsters() {
return next < count;
}
  • _count is how many monsters have been added. The new one goes to self._monsters[self._count].
  • _next is which one fights next. After every fight it goes up by 1, and when it reaches _count, the lineup is over.
The list has 5 places. The sixth monster does not get in: add_monster throws GameError before it writes outside the list. A list in Python can also grow with append, but here it deliberately does not grow: the game's rule is "at most 5".

create_random_monster is the bonus from the old exercise: a monster picked at random. randrange(3) gives 0, 1 or 2.

More precisely, if you are curious
  • The old exercise kept the monsters in a real queue. In Python that is a deque: it remembers by itself which one is first, and it never fills up. It comes in the Collections module: there _count and _next go away, and the lineup grows as much as it needs.
  • The monsters before _next stay in the list. They do no harm: no one reads them anymore.

The idea

Levels and experience

After every win the hero gets experience equal to the monster's health. On level 1 it takes 100 experience to reach level 2, on level 2 it takes 200 to reach level 3, and so on. A new level gives 10 more health, 3 more power and full health:

public boolean gainExperience(int exp) {
experience += exp;
boolean up = false;
while (experience >= level * 100) {
experience -= level * 100;
levelUp();
up = true;
}
return up;
}
public void levelUp() {
level++;
maxHealth += 10;
power += 3;
health = maxHealth;
}

The methods are in Character. Experience above the threshold stays for the next level. The while loop is for the case where one win is enough for two levels.

The fields _max_health, _level and _experience are new in Character. With _max_health, healing no longer raises health above the maximum. From outside they are read through the properties max_health, level and experience, like the fields from the first part.

Try it

What level is the barbarian on?

Grok starts on level 1 with power 20. He wins against the shaman (60 experience) and against the king (90 experience).

What will the program print? Level, experience, power.

def gain_experience(self, exp):
self._experience += exp
up = False
while self._experience >= self._level * 100:
self._experience -= self._level * 100
self.level_up()
up = True
return up
def level_up(self):
self._level += 1
self._max_health += 10
self._power += 3
self._health = self._max_health
grok = Barbarian("Grok")
grok.gain_experience(60)
grok.gain_experience(90)
print(grok.level, grok.experience, grok.power)
Answer

See it

The battle and the rest

start_battle first checks whether the fight is possible. Then it takes the next monster and runs the fight to the death from the second part. It returns whether the hero survived:

public boolean startBattle() {
if (hero == null) {
throw new GameException("Create a hero first");
}
if (!hasMonsters()) {
throw new GameException("No monsters in the lineup");
}
Monster monster = monsters[next];
next++;
int rounds = 0;
while (hero.isAlive() && monster.isAlive()) {
rounds++;
hero.attack(monster);
if (monster.isAlive()) {
monster.attack(hero);
}
}
if (!hero.isAlive()) {
System.out.println(hero.getName() + " falls in round " + rounds + ". Winner: " + monster.getName());
return false;
}
System.out.println(monster.getName() + " is defeated in " + rounds + " rounds. " + hero.getName() + ": health " + hero.getHealth() + "/" + hero.getMaxHealth());
if (hero.gainExperience(monster.getReward())) {
System.out.println(hero.getName() + " reaches level " + hero.getLevel() + ": health " + hero.getMaxHealth() + ", power " + hero.getPower());
}
return true;
}

recover is the choice after the fight. Only a hero who inherits from Healable (isinstance checks it) can use magic. The rest is 5 turns of 10 health:

public void recover(String way) {
if (way.equals("rest")) {
for (int turn = 0; turn < 5; turn++) {
hero.restore(10);
}
System.out.println(hero.getName() + " rests for 5 turns: health " + hero.getHealth() + "/" + hero.getMaxHealth());
} else if (way.equals("magic")) {
if (!(hero instanceof IHealable)) {
throw new GameException(hero.getName() + " does not know healing magic");
}
System.out.println(hero.useAbility());
} else {
throw new GameException("Unknown choice '" + way + "': rest or magic?");
}
}

In the old exercise the hero waited 5 seconds. Here the rest is in turns, because in a turn-based game time is measured in turns, and the check of the game should not have to wait.

See it

The whole game

Grok the barbarian against the shaman and two random monsters. After every fight he rests. If he falls, the game is over:

Game game = new Game(new Random(12));
game.createHero("Grok", "barbarian");
game.addMonster(new FallenShaman());
game.addMonster(game.createRandomMonster());
game.addMonster(game.createRandomMonster());
while (game.hasMonsters()) {
if (!game.startBattle()) {
System.out.println("Game over");
break;
}
game.recover("rest");
}
Output
Fallen Shaman is defeated in 3 rounds. Grok: health 90/110
Grok rests for 5 turns: health 110/110
Fallen Shaman is defeated in 3 rounds. Grok: health 90/110
Grok reaches level 2: health 120, power 23
Grok rests for 5 turns: health 120/120
Butcher is defeated in 6 rounds. Grok: health 70/120
Grok rests for 5 turns: health 120/120
Compare the output in the tabs. The seed is 12 in every language, but the generators are different, and so are the random monsters. In Java Grok meets a second shaman and the Butcher, in C# the Butcher and the Skeleton King, and in Python and JavaScript the Skeleton King twice.

game = Game(Random(12))
game.create_hero("Grok", "barbarian")
game.add_monster(FallenShaman())
game.add_monster(game.create_random_monster())
game.add_monster(game.create_random_monster())
while game.has_monsters():
if not game.start_battle():
print("Game over")
break
game.recover("rest")
Step by step, in words
  1. The game gets a generator with seed 12. The hero is Grok, and the list has 5 places, still empty.
  2. Each add_monster() writes to self._monsters[self._count] and raises self._count. The second and the third are random: Skeleton King and Skeleton King.
  3. self._next is 0: out comes Fallen Shaman. It is defeated in 3 rounds, and Grok has 90/110 left.
  4. self._next becomes 1. The rest is 5 turns of 10 health: 110/110.
  5. self._next is 1: out comes Skeleton King. It is defeated in 6 rounds, and Grok has 45/110 left. The experience is enough for level 2: health 120, power 23.
  6. self._next becomes 2. The rest is 5 turns of 10 health: 120/120.
  7. self._next is 2: out comes Skeleton King. It is defeated in 5 rounds, and Grok has 68/120 left.
  8. self._next becomes 3. The rest is 5 turns of 10 health: 118/120.
  9. self._next is 3, the same as self._count: has_monsters() returns False, and the loop ends.

The idea

A wrong move: GameError

The player can ask for a fight without a hero, a race that does not exist, or magic from a barbarian. That is not a bug in the program. It is a move the rules do not allow. The methods of Game throw their own exception for it:

class GameException extends RuntimeException {
public GameException(String message) {
super(message);
}
}
In Python the name of your own exception ends in Error, like most built-in ones: ValueError, TypeError, IndexError.

The game catches only that one, tells the player why the move is not allowed and carries on with the next move:

Game game = new Game(new Random(12));
String[] commands = { "fight", "hero Grok dragon", "hero Grok barbarian", "magic", "sleep" };
for (String command : commands) {
String[] words = command.split(" ");
try {
if (words[0].equals("fight")) {
game.startBattle();
} else if (words[0].equals("hero")) {
game.createHero(words[1], words[2]);
System.out.println("The hero is " + words[1]);
} else {
game.recover(words[0]);
}
} catch (GameException e) {
System.out.println("Not allowed: " + e.getMessage());
}
}
Output
Not allowed: Create a hero first
Not allowed: No such race: dragon
The hero is Grok
Not allowed: Grok does not know healing magic
Not allowed: Unknown choice 'sleep': rest or magic?

Why your own exception and not the built-in ValueError? Because except GameError catches only the refusals by the rules. If the game has a bug and throws AttributeError, it will not hide as "Not allowed". It will stop the program, and you will see it. For example, recover before there is a hero: self._hero is None, and hero.restore(10) gives AttributeError: 'NoneType' object has no attribute 'restore'.

Try it

Find the bug

The sixth monster should get "The lineup is full". Instead, the program stops with IndexError. Which line is the bug on?

Pick the line with the bug.

Try it

What is missing?

Which class does GameError inherit from?

class GameError():
    pass

Exercise

Play and extend

Solve these in your editor. Start from the classes in the lesson.

1. A menu. The commands come from input, one per line, until "quit":

  • "hero Name race" creates the hero;
  • "monster shaman" (or "king", "butcher") adds a ready-made monster, and "monster Name health power" adds your own: a class Beast of the race "beast";
  • "random" adds a random monster;
  • "fight" starts the next fight, and "rest" and "magic" are the choice after it.

Every refusal is a GameError that the menu catches, saying "Not allowed: …". The seed is 12. The lines are read with input().

Input
hero Morena elf
hero Morena witch
monster Wolf 50 9
monster dragon
monster shaman
random
fight
magic
fight
rest
fight
quit
Output
Not allowed: No such race: elf
The hero is Morena
In the lineup: Wolf
Not allowed: No such monster: dragon
In the lineup: Fallen Shaman
In the lineup: Skeleton King
Wolf is defeated in 4 rounds. Morena: health 68/95
Morena calls on the elements: health 83/95
Fallen Shaman is defeated in 4 rounds. Morena: health 47/95
Morena reaches level 2: health 105, power 19
Morena rests for 5 turns: health 105/105
Skeleton King is defeated in 7 rounds. Morena: health 9/105

2. Progress. Add a method show_progress() to Game that shows the hero's level and how much experience he has out of how much he needs for the next level. Ardan the paladin goes through two shamans and the Skeleton King. After every fight he shows his progress and heals with magic.

Output
Fallen Shaman is defeated in 5 rounds. Ardan: health 72/120
Level 1, experience 60/100
Ardan heals: health 92/120
Fallen Shaman is defeated in 5 rounds. Ardan: health 44/120
Ardan reaches level 2: health 130, power 17
Level 2, experience 20/200
Ardan heals: health 130/130
Skeleton King is defeated in 8 rounds. Ardan: health 18/130
Level 2, experience 110/200
Ardan heals: health 38/130

3. A boss. A class Diablo, a subclass of Monster: 160 health, 14 power. Every third hit of his is double. A method can_challenge(hero) says whether the hero is at least on level 2. If not, start_battle throws GameError, and Diablo stays in the lineup. Run him once against Grok on level 1, then in a new game after the shaman and the king.

Output
Not allowed: Diablo fights only heroes of level 2 and up
Fallen Shaman is defeated in 3 rounds. Grok: health 90/110
Grok rests for 5 turns: health 110/110
Skeleton King is defeated in 6 rounds. Grok: health 45/110
Grok reaches level 2: health 120, power 23
Grok rests for 5 turns: health 120/120
Diablo is defeated in 7 rounds. Grok: health 24/120
Grok reaches level 3: health 130, power 26
Grok rests for 5 turns: health 130/130

Keep going on your own

The old exercise suggested more ideas. Some of them need a tool that comes later:

  • Skills with levels. A class Skill with a name and a level, use_skill(target) and upgrade_skill(). You can do this now, with a list of skills in the hero.
  • Inventory. A class Item with a name and an effect, apply_effect(target), and an Inventory where items are added and removed. Adding works even now: a list grows with append. Removing an element from the middle comes in the Collections module.
  • Bosses with requirements. A boss that wants certain items and skills before it accepts the fight, and drops loot after a win. It needs the inventory above.
  • Screens. Menu, map, inventory: each screen is an object with show(), hide() and update(), and the open screens are on a stack, so that "back" works. The stack comes in the Collections module.
  • Save and load. The hero, the level and the best score are written to a file and read on the next run. Files come in the Text and files module.

Test

Check yourself

1. The lineup is a list of 5 places, _count is 3, _next is 1. What does that mean?
2. The hero is on level 1 with 80 experience and wins 130 experience. What happens?
3. Why does the game catch only GameError and not every exception?
4. Why is the rest 5 turns and not 5 seconds of waiting?
5. Why does class GameError(Exception): pass have no __init__?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·