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 an array 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: it is a function that returns a number from 0 to 1 each time you call it. In the real game it will be Math.random, and in the examples it is makeRandom(12) from the lesson "How to think about a game", because Math.random has no seed, and the output has to be always the same.The fields start with #: they are private and visible only inside the class Game. new Array(5) is an array with 5 empty places.

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 an array 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 this.#monsters[this.#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 array has 5 places. The sixth monster does not get in: addMonster throws GameError. Here the check is the only thing that keeps the rule "at most 5": an array in JavaScript grows by itself, and this.#monsters[5] = monster is not an error but a sixth place.

createRandomMonster is the bonus from the old exercise: a monster picked at random. Math.floor(this.#random() * 3) gives 0, 1 or 2: the number from 0 to 1 times 3 is from 0 to 3, not including 3, and Math.floor cuts off the fractional part.

More precisely, if you are curious
  • The old exercise kept the monsters in a real queue. JavaScript has no separate queue class: a queue is an array where push adds at the back and shift takes from the front. It comes in the Collections module: there #count and #next go away.
  • The monsters before #next stay in the array. 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 _maxHealth, _level and _experience are new in Character. With _maxHealth, healing no longer raises health above the maximum. From outside they are read through the properties maxHealth, 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.

gainExperience(exp) {
this._experience += exp;
let up = false;
while (this._experience >= this._level * 100) {
this._experience -= this._level * 100;
this.levelUp();
up = true;
}
return up;
}
levelUp() {
this._level++;
this._maxHealth += 10;
this._power += 3;
this._health = this._maxHealth;
}
const grok = new Barbarian("Grok");
grok.gainExperience(60);
grok.gainExperience(90);
console.log(grok.level, grok.experience, grok.power);
Answer

See it

The battle and the rest

startBattle 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 has a method heal (typeof hero.heal is "function") 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.

const game = new Game(makeRandom(12));
game.createHero("Grok", "barbarian");
game.addMonster(new FallenShaman());
game.addMonster(game.createRandomMonster());
game.addMonster(game.createRandomMonster());
while (game.hasMonsters()) {
if (!game.startBattle()) {
console.log("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 array has 5 places, still empty.
  2. Each addMonster() writes to this.#monsters[this.#count] and raises this.#count. The second and the third are random: Skeleton King and Skeleton King.
  3. this.#next is 0: out comes Fallen Shaman. It is defeated in 3 rounds, and Grok has 90/110 left.
  4. this.#next becomes 1. The rest is 5 turns of 10 health: 110/110.
  5. this.#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. this.#next becomes 2. The rest is 5 turns of 10 health: 120/120.
  7. this.#next is 2: out comes Skeleton King. It is defeated in 5 rounds, and Grok has 68/120 left.
  8. this.#next becomes 3. The rest is 5 turns of 10 health: 118/120.
  9. this.#next is 3, the same as this.#count: hasMonsters() 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 JavaScript the name of your own exception ends in Error, like the built-in ones: TypeError, RangeError.

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?
In JavaScript there is one catch, and it catches everything. That is why inside it e instanceof GameError asks whether this is a refusal by the rules, and throw e; throws everything else further up.

Why your own exception and not the built-in Error? Because the check e instanceof GameError lets through only the refusals by the rules. If the game has a bug and throws TypeError, 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: this.#hero is null, and hero.restore(10) gives TypeError: Cannot read properties of null (reading 'restore').

Try it

Find the bug

The sixth monster should get "The lineup is full". Instead, it gets in: the program prints "Monsters: 6" and says nothing. 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 extends  {
  constructor(message) {
    super(message);
    this.name = "GameError";
  }
}

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 await ask(""), and after "quit" rl.close() closes the 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 showProgress() 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 canChallenge(hero) says whether the hero is at least on level 2. If not, startBattle 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, useSkill(target) and upgradeSkill(). You can do this now, with an array of skills in the hero.
  • Inventory. A class Item with a name and an effect, applyEffect(target), and an Inventory where items are added and removed. Adding works even now: an array grows with push. 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 an array 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. catch checks e instanceof GameError, and otherwise does throw e. What happens if a TypeError comes?

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