
The WarriorJS project brings a simple and fun interface into learning about JavaScript scripting and the basics of artificial intelligence. By changing a set Player.js
script, the WarriorJS system will run your script in set levels to battle enemies and climb towers in a 2d ASCII art way.
There are two ways of playing WarriorJS; online or via the NPM CLI. This article will cover using the NPM cli but the JavaScript is the same either way.
Prerequisites
Local Setup
First, create a new directory to hold the project with mkdir warriorjs-example
. Then install the CLI via NPM with npm install -g @warriorjs/cli
. To start up a new session run warriorjs
and then follow the prompts. After it is started it will generate a directory like this:
warriorjs
| the-first-steps
| - .profile #tracks user progress
| - Player.js # file that adds action to the player
| - README.md # directions for the current level
To get the directions for what to do next read the README.md
file inside the the-first-steps
directory that was created. Every time that you solve a level the CLI will update the README.md
file with the next instructions for that level. This article will not be duplicating the instructions but will be explaining some other concepts.
Note: This article will show an example of how to solve the tutorial levels. I would recommend that you try and solve the levels on your own before reading farther to get the whole experience.
Level 1 - The Basics
class Player {
playTurn(warrior) {
warrior.walk();
}
}
Updating the Player.js
file with the following, and then running warriorjs
again will run the player through the first level. This is the most simple script for this game as it walks forward until the character hits the exit.
Level 2 - Learn to Fight
class Player {
playTurn(warrior) {
if(!warrior.feel().isEmpty()){
warrior.attack();
} else {
warrior.walk();
}
}
}
In the second level, we encounter our first enemy that the character will have to fight. At this point, our character needs to be able to make a decision. A basic JavaScript if else
statement is the simplest way to get started. While this technique can get out of hand (as we will see later in this article) for a basic level such as this its a good starting place.
Level 3 - Time to Heal
class Player {
playTurn(warrior) {
if(!warrior.feel().isEmpty()){ // fight
warrior.attack();
}else if(warrior.health() < warrior.maxHealth()){ // heal
warrior.rest();
} else { // default to walking
warrior.walk();
}
}
}
The next ability our entrepot explorer gains is the ability to heal himself. In adding to the if else
statement that was started in Level 2, we can add another if else in the middle with a condition for when to start healing with the rest()
command. Order now matters and putting the rest()
command above the attack()
command may mean that an enemy will be attacking while you are healing, which is an issue.
Level 4 - Archers Attack
class Player {
constructor(){
this.health = 20; // Health class variable
}
playTurn(warrior) {
var isTakingDamage = warrior.health() < this.health;
if(!warrior.feel().isEmpty()){ // fight
warrior.attack();
}else if(!isTakingDamage &&
(warrior.health() < warrior.maxHealth())){ // heal
warrior.rest();
} else { // default to walking
warrior.walk();
}
this.health = warrior.health();
}
}
Focus on movement if taking damage to rush archers. To determine if the character is taking damage we can set up a class variable this.health
and update it at the end of every turn. If this.health
is greater than warrior.health()
(which is the current health of the character on that turn) then the character is taking damage. At this stage, the reaction to taking damage is to rush the archer and start attacking. Later on, this strategy will stop working so it will have to be updated but in this early level its enough.
Level 5 - Free the Captives!
class Player {
constructor(){
this.health = 20;
}
playTurn(warrior) {
var isTakingDamage = warrior.health() < this.health;
if(warrior.feel().getUnit() !== undefined
&& warrior.feel().getUnit().isBound()){ // free captive
warrior.rescue();
} else if(!warrior.feel().isEmpty()){ // fight
warrior.attack();
} else if(!isTakingDamage
&& (warrior.health() < warrior.maxHealth())){ // heal
warrior.rest();
} else { // default to walking
warrior.walk();
}
this.health = warrior.health();
}
}
At this level the if else
statement strategy is starting to get out of control. Now we have to not only check for enemies to attack, but also make sure that the character does not attack any friendly captives but instead frees them. The rescue()
command has to come before the attack()
command then so in this example it is the first check-in the logic check.
Level 6 - To the Back
Now direction matters which throws a wrench in our current implementation. Most of the actions can take in an option string parameter of forward
or backward
to denote a direction. While we could type out this string all over the code this is a good opportunity to introduce a constant object for these static values. Using constant objects is useful so that you, or other developers if on a team, do not have to remember the capitalization or exact terms.
Introduce constant objects for static values
const Directions = {
forward: 'forward',
backward: 'backward'
};
class Player {
...
At this level, the enemies are too spread out to rush the archers when taking damage. To resolve this we can move the istakingdamage
up.
// Direction Constants
const Directions = {
forward: 'forward',
backward: 'backward'
};
class Player {
constructor(){
this.health = 20;
this.currentDirection = Directions.backward;
}
playTurn(warrior) {
// Current state
var isTakingDamage = warrior.health() < this.health;
var isFacingWall = warrior.feel(this.currentDirection).isWall();
// if facing wall reverse
if(isFacingWall){
this.currentDirection = this.currentDirection == Directions.forward ? Directions.backward : Directions.forward;
}
// Main Actions
if(isTakingDamage &&
(warrior.health() < (warrior.maxHealth() * 0.5))) { // run away
warrior.walk(Directions.backward);
} else if(warrior.feel(this.currentDirection).getUnit() !== undefined
&& warrior.feel(this.currentDirection).getUnit().isBound()){ // free captive
warrior.rescue(this.currentDirection);
} else if(!warrior.feel(this.currentDirection).isEmpty()){ // fight
warrior.attack(this.currentDirection);
} else if(!isTakingDamage && warrior.health() < warrior.maxHealth()){ // heal
warrior.rest();
} else { // default to walking
warrior.walk(this.currentDirection);
}
this.health = warrior.health();
}
}
Level 7 - Pivot
// Direction Constants
const Directions = {
forward: 'forward',
backward: 'backward'
};
class Player {
constructor(){
this.health = 20;
this.currentDirection = Directions.forward;
}
playTurn(warrior) {
// Current state
var isTakingDamage = warrior.health() < this.health;
var isFacingWall = warrior.feel(this.currentDirection).isWall();
// Main Actions
if(isFacingWall){ // if facing wall reverse
warrior.pivot();
} else if(isTakingDamage && (warrior.health() < (warrior.maxHealth() * 0.5))) { // run away
warrior.walk(Directions.backward);
} else if(warrior.feel(this.currentDirection).getUnit() !== undefined
&& warrior.feel(this.currentDirection).getUnit().isBound()){ // free captive
warrior.rescue(this.currentDirection);
} else if(!warrior.feel(this.currentDirection).isEmpty()){ // fight
warrior.attack(this.currentDirection);
} else if(!isTakingDamage && warrior.health() < warrior.maxHealth()){ // heal
warrior.rest();
} else { // default to walking
warrior.walk(this.currentDirection);
}
this.health = warrior.health();
}
}
While the character can indeed attack in the backward
direction, there is a penalty in doing so. One of the actions feel()
can tell if a character is facing a wall. So at this point, we can use the pivot()
action if the character is facing a wall to turn around making backward
forward
and vice versa.
Level 8 - Bow and Arrows
// Direction Constants
const Directions = {
forward: 'forward',
backward: 'backward'
};
class Player {
constructor(){
this.health = 20;
this.currentDirection = Directions.backward;
}
playTurn(warrior) {
// Current state
var isTakingDamage = warrior.health() < this.health;
var isFacingWall = warrior.feel(this.currentDirection).isWall();
var isCaptiveInRange = this.isEnemyInSight(warrior.look(this.currentDirection));
// Main Actions
if(isFacingWall){ // if facing wall reverse
warrior.pivot();
} else if(isTakingDamage && (warrior.health() < (warrior.maxHealth() * 0.5))) { // run away
warrior.walk(Directions.backward);
} else if(warrior.feel(this.currentDirection).getUnit() !== undefined
&& warrior.feel(this.currentDirection).getUnit().isBound()){ // free captive
warrior.rescue(this.currentDirection);
} else if(isCaptiveInRange) {
warrior.shoot(this.currentDirection);
} else if(!warrior.feel(this.currentDirection).isEmpty()){ // fight
warrior.attack(this.currentDirection);
} else if(!isTakingDamage && warrior.health() < warrior.maxHealth()){ // heal
warrior.rest();
} else { // default to walking
warrior.walk(this.currentDirection);
}
this.health = warrior.health();
}
isEnemyInSight(lookArray) {
const spaceWithUnit = lookArray.find(space => space.isUnit());
return spaceWithUnit && spaceWithUnit.getUnit().isEnemy();
}
}
Seeing as how WarriorJS is using plain JavaScript, you can write custom methods outside of the playTurn(warrior)
method that is provided. In level 8 we can add an isEnemyInSight()
method to check if an enemy is anywhere within a 3 space array that the main character can see using the look()
action.
Because the look()
action returns a plain JavaScript array we can use all the built-in JS array methods. In this case, we can use the find(space => space.isUnit())
method to search through the array for an object that is a unit.
Check out this article for a rundown of available array methods: https://www.geeksforgeeks.org/javascript-basic-array-methods/
Level 9 - Put it Together
The previous level example works for Level 9 but is not ideal as it leaves one of the two captives on the map. This means that while the level is beaten the character does not achieve the highest score possible. In a future article, we will cover "AI" methods for improving level 9.
Summary
The WarriorJS project is a great way to learn, or brush up on, JavaScript programming skills. Going forward it is also a great platform for learning the basics of AI game programming. In a future article, we will cover more of these methods building off this super basic script developed in this article.