How to Build a Text RPG in JavaScript

RedXIII | June 8, 2021, 12:16 p.m.

JavaScript might have been created to beautify the web, but it can do much more than animate buttons and web carousels. In this tutorial, we’ll take an in-depth look at building a text based RPG using only JavaScript and a web browser. 

We’ll cover a lot of ground in building this project, so it’s a good place for beginners and advanced students alike. If you’re new to web development, or just excited to learn more about what JavaScript can do, you’ll gain a lot of this exercise.


Project Overview

The text RPG in JavaScript will span multiple files. We’ll need to create a game world, a player, items, enemies, and more. This will take some time. But in the end, you’ll learn something about JavaScript and building interactive web apps.


Setting Up

For this project, I’ll be using the Atom Text Editor, but you can use any editor you'd like. I’ll be testing on Chrome and Edge, but our project will work using any modern browser. 


We'll be dealing with data structures and using JavaScript to react to user input. Before we can write any JavaScript code, we’ll need to create a project directory. My JavaScript RPG directory contains a folder for css, a folder for JavaScript (named js), and an HTML document named index.html


We’ll make several JavaScript files for this project. They should all go within the JavaScript folder in our project directory.


Part 1: Inventory


Our first course of action is to create the game’s inventory system. Our player will be able to find items in the game world and carry them around. Before we can pick up any items, we have to create a basic item class.


class Item { 

 constructor(name, description, value){

    this.name = name

    this.description = description

    this.value = value

    this.pickedUp = false

  }

}

If you’re familiar with Object Oriented Programming, you probably know what an abstract class is. Our base item class holds the basic information we need for every item in the game. For instance, each item will have a name and description.


The other items in our game will inherit from this base class. For example, most RPGs have a kind of currency. We can create a class to represent gold by inheriting from the Item class.


class Gold extends Item {

  constructor(name, description, amt){

    super("Gold", "Gold currency stamped with the seal of the kingdom.",amt)

    this.amt = amt

  }

}

Our player will be able to find and use multiple weapons. Knowing this, we should create a base Weapon class that each weapon item will inherit.

class Weapon extends Item {

  constructor(name, description, value, damage){

    super(name, description,value)

    this.damage = damage

  }

}

Inheriting from the Weapon class allows us to customize each item. For instance, we can create two weapons: a rock and a dagger.

class Rock extends Weapon {

  constructor(name, description, value, damage){

    super("Rock", "A fist-sized rock, suitable for smashing things.", 0, 5)

  }

}


class Dagger extends Weapon {

  constructor(name, description, value, damage){

    super("Dagger", "An old dagger with some rust on it.", 1, 10)

  }

}

Creating the Project Home Page

Earlier, we created a file for the home page of our project: index.html. Returning to this file, we can create most of the HTML we need for our game.

Index.html

<!DOCTYPE html>

<html lang="en" dir="ltr">

  <head>

    <meta charset="utf-8">

    <title>Escape the Dark Cave</title>

<script defer src="js/enemies.js" type="text/javascript"></script> <script defer src="js/items.js" type="text/javascript"></script> <script defer src="js/tiles.js" type="text/javascript"></script> <script defer src="js/world.js" type="text/javascript"></script> <script defer src="js/player.js" type="text/javascript"></script> <script defer src="js/actions.js" type="text/javascript"></script> <script defer src="js/game.js" type="text/javascript"></script>

  </head>

  <body>

    <h1>Escape the Dark Cave</h1>

    <div id="story-text">


    </div>

    <div id="game-btns">


    </div>

  </body>

</html>

As we add more scripts to our game, we can link to think within the header of index.html. Each script will be loaded in the order that they appear.

Part 2: The World


Now we’ll move on to building the map tiles our player can use to explore the game world.


Each tile will be represented by a JavaScript class. We’ll need a new file to hold these tiles. Within our js directory, we’ll create a new JavaScript file called tiles.js.

The game world tiles will inherit from a base MapTile class. If you’re familiar with Object Oriented Programming, you’ll know that we extend the MapTile class to create many different map tiles.

Making the MapTile Class

Each map tile will have a location in the game world. We’ll represent this location with x and y coordinates. The tiles will also have a description that we can display to the player. We’ll create a method called introText() to handle calling of this description.


The map tiles also need the ability to modify the player. For example, if the player finds a magic dagger in a room, we need a way to add it to the player inventory. We can do this using the modifyPlayer() method.


Each tile will also keep up with the rooms adjacent to it. This way the player will know which directions they can move from any given tile. We can find these adjacent tiles, as well as any other possible player actions, using the availableActions() method.

class MapTile {

  constructor(x,y){

    this.x = x

    this.y = y

  }


  introText() {

    if (this.constructor === MapTile) {

            throw new TypeError('Abstract class "MapTile" cannot be instantiated directly.');

    }

  }


  modifyPlayer(player){

    if (this.constructor === MapTile) {

            throw new TypeError('Abstract class "MapTile" cannot be instantiated directly.');

    }

  }


  adjacentMoves(){

    let moves = []


    if (tileExists(this.x+1,this.y)){

      moves.push(new MoveEast())

    } if (tileExists(this.x-1,this.y)){

      moves.push(new MoveWest())

    } if (tileExists(this.x,this.y-1)){

      moves.push(new MoveNorth())

    } if (tileExists(this.x,this.y+1)){

      moves.push(new MoveSouth())

    }

    return moves

  }


  availableActions(){

    let moves = this.adjacentMoves()

    moves.push(new ViewInventory())


    return moves

  }

}


With the base MapTile class complete, we can create our first map tile: EmptyCavePath. This empty tile can be repeated in our world map.

class EmptyCavePath extends MapTile {

  constructor(x,y){

    super(x,y)

  }


  introText(){

    return "Another unremarkable part of the cave. You must keep moving."

  }


  modifyPlayer(player){

    // this room does nothing to the player

  }

}

Before any of this will work, we’ll need to create a game world. We’ll do this in a separate JavaScript file: world.js.

Creating the Game World

Our game world will consist of several map tiles. The player will be able to move from room to room looking for the exit to the cave. To create the game world, we’ll use an array. This array will hold the tile data for the map.


We’ll arrange the world map as an array. Each element in the array will either be the name of a tile to load, or it will be empty. An empty tile will be represented by a comma.


When we load the map, we can use JavaScript to loop through this array. If we come across a tile name we recognize, we’ll load the tile data contained in the tiles.js file.

map = [

  ['','','','',''],

  ['','','EmptyCavePath','LeaveCaveRoom',''],

  ['','','EmptyCavePath','',''],

  ['GiantSpiderRoom','EmptyCavePath','StartingRoom','EmptyCavePath','FindDaggerRoom'],

  ['','','EmptyCavePath','',''],

]


var world = {}

var startPosition = [0,0]


function loadTiles(){

  let row = map.length

  let col = map[0].length


  let y;

  for (y = 0; y < row; y++){

    let x;

    for (x = 0; x < col; x++){

      let tileName = map[y][x]


      let room

      if (tileName === "StartingRoom"){

        startPosition[0] = x

        startPosition[1] = y

        room = new StartingRoom(x,y)

      }else if (tileName === ''){

        room = null

      } else if (tileName === "EmptyCavePath") {

        room = new EmptyCavePath(x,y)

      } else if (tileName === "LeaveCaveRoom"){

        room = new LeaveCaveRoom(x,y)

      } else if (tileName === "FindDaggerRoom") {

        room = new FindDaggerRoom(x,y)

      } else if (tileName === "GiantSpiderRoom") {

        room = new GiantSpiderRoom(x,y)

        room.enemy = new GiantSpider()

      }

      world[[x,y]] = room

    }

  }

}


function tileExists(x,y){

  return world[[x,y]]

}


loadTiles()



Next, we'll create the LootRoom and the EnemyRoom.

class LootRoom extends MapTile {
  constructor(x,y,item){
    super(x,y)
    this.item = item
  }

  addLoot(player){
    player.inventory.push(this.item)
    this.item.pickedUp = true
  }

  modifyPlayer(player){
    if(!this.item.pickedUp){
      this.addLoot(player)
    }
  }
}

class EnemyRoom extends MapTile {
  constructor(x,y,enemy){
    super(x,y)
    this.enemy = enemy
  }

  modifyPlayer(player){
    if (this.enemy.isAlive()){
      player.hp = player.hp - this.enemy.damage
      let msg = `Enemy does ${this.enemy.damage} damage. You have ${player.hp} HP remaining.`
      addStoryText(msg)
    }
  }

  availableActions(){
    if (this.enemy.isAlive()){
      return [new Flee(), new Attack(this.enemy)]
    } else {
      return this.adjacentMoves()
    }
  }
}

The loadTiles() function does the heavy lifting of building the world. We also have a helper function called tileExists() that we can use to determine whether or not a tile exists at a certain location. We’ll call this function from tile.js in order to find a room's adjacent tiles.

Part 3: The Player


After creating the game world and inventory, it’s time to create the star of our adventure: the player. We won’t have much of a game without a player class, so let’s hop over to our editor and write one.


Creating the Player

Our player class will have several responsibilities. For starters, it will need to track the player’s current location on the world map. The player also has an inventory and hit point stats. And we’ll need several methods to complete the player’s behavior. Let’s take a look at them.


The player needs to be able to move around the map, so we’ll need a move() method to handle this behavior. We want to have some combat abilities too, so we’ll need an attack() method. It would be nice if the player could run from a fight, so we’ll create a flee() method for that.


Because the player will be moving often, we’ll create some extra methods to handle each direction of movement: north, east, south, and west. These directional movement methods will be called depending on which tile the player chooses to move to.

class Player {

  constructor(inventory,hp,location_x,location_y,victory){

    this.inventory = [new Gold(5),new Rock()]

    this.hp = 100

    this.location_x = startPosition[0]

    this.location_y = startPosition[1]

    this.victory = false

  }


  isAlive(){

    return this.hp > 0

  }


  do_action(action, ...others){


  }


  printInventory(){

    let text = "Inventory:"

    this.inventory.forEach(item =>{

      text += `<p>${item.description}</p>`

      console.log(item.description)

    });

    render(text)

  }


  move(dx,dy){

    this.location_x += dx

    this.location_y += dy

    console.log(tileExists(this.location_x,this.location_y))

  }


  moveNorth(){

    this.move(0,-1)

  }


  moveSouth(){

    this.move(0,1)

  }


  moveEast(){

    this.move(1,0)

  }


  moveWest(){

    this.move(-1,0)

  }


  flee(){

    let room = world[[player.location_x,player.location_y]]

    let availableMoves = room.adjacentMoves()

    let r = Math.floor((Math.random() * availableMoves.length))

    console.log(r)


    if (availableMoves[r].name == "Move south"){

      this.moveSouth()

    } else if (availableMoves[r] == "Move north"){

      this.moveNorth()

    } else if (availableMoves[r] == "Move east"){

    this.moveEast()

    } else if (availableMoves[r] == "Move west"){

    this.moveWest()

    }

  }


  attack(enemy){

    let bestWeapon = null

    let maxDmg = 0


    this.inventory.forEach(item =>{

      if (item instanceof Weapon ){

        if (item.damage > maxDmg) {

          maxDmg = item.damage

          bestWeapon = item

          console.log(bestWeapon.name)

        }

      }

    });


    let text = "";

    console.log(`You use ${bestWeapon.name} against ${enemy.name}!`)


    text += `You use a ${bestWeapon.name} against the ${enemy.name}!`

    enemy.hp -= bestWeapon.damage

    if (!enemy.isAlive()) {

      console.log(`You killed the ${enemy.name}!`)

      text += `<p>You killed the ${enemy.name}!</p>`

    } else {

      console.log(`${enemy.name} has ${enemy.hp} HP left.`)

      text += `<p>${enemy.name} has ${enemy.hp} HP left.</p>`

    }


    addStoryText(text)

  }

}


player = new Player()


The attack() is probably the most complicated part of the player class. It’s responsible for choosing the best weapon from the player’s inventory and using it to attack the enemy. Even though we haven’t created any enemies yet, we can be certain they will have names and hit points.


Creating Enemies

This seems like a good time to make our enemy class. We’ll create a new file called enemies.js to hold our enemies. Fortunately, the enemies are much less complicated than our player is. We extend our base Enemy class to create a GiantSpiderEnemy. In this way, we could create as many enemies as we wanted.

class Enemy {

  constructor(name, hp, damage) {

    this.name = name

    this.hp = hp

    this.damage = damage

  }


  isAlive(){

    return this.hp > 0

  }

}


class GiantSpider extends Enemy {

  constructor(name, hp, damage){

    super(name="Giant Spider", hp=10,damage=2)

  }

}


Now that we have a giant spider enemy, we need to create a tile to hold the monster. Add the following to tiles.js.

class GiantSpiderRoom extends EnemyRoom {

  constructor(x,y){

    super(x,y,new GiantSpider())

  }


  introText(){

    if (this.enemy.isAlive()){

      return "A giant spider jumps down from its web in front of you."

    } else{

      return "The corpse of a dead spider rots on the ground."

    }

  }

}


While we’re at it, let’s create a few more room tiles. The player starts with a rock, but we want the player to find the dagger we created somewhere. Let’s create a room for the player to find the dagger.

class FindDaggerRoom extends LootRoom {

  constructor(x,y){

    super(x,y,new Dagger())

  }


  introText(){

    if(this.item.pickedUp){

      return "This part of the cave is empty."

    }else {

      return "You notice something shiny in the corner.<p>It's a dagger! You take it.</p>"

    }

  }

}


We also need a room to start in, as well as a way to exit the game and win.


class StartingRoom extends MapTile { 

 constructor(x,y){

    super(x,y)

  }


  introText(){

    return "You find yourself in a cave with a flickering torch on the wall. You can make out four paths, each equally dark and foreboding."

  }


  modifyPlayer(player){

    // nothing in this room

  }

}


class LeaveCaveRoom extends MapTile {

  constructor(x,y){

    super(x,y)

  }


  introText(){

    return "You see a bright light in the distance....\n...it grows as you get closer! It's sunlight!\n\nVictory is yours!"

  }


  modifyPlayer(player){

    player.victory = true

  }

}

Part 4: The Game


Our player can do quite a few things. They can move, they can check their inventory, and they can attack enemies. In addition, the player can flee from battle and pick up items.

To simplify the act of calling all these possible actions, we’ll create an Action class that we can extend. Each action will refer to a possible action the player may take in their quest. Later, we can call these actions from our game script.


Creating the Player Actions

Each action the player could take will be wrapped in it’s own class. These classes will point to their respective player methods.


class Action {

  constructor(method,name){

    this.method = method

    this.name = name

  }

}


class ViewInventory extends Action {

  constructor(method,name){

    super(method=player.printInventory,name="View Inventory")

  }

}


class MoveSouth extends Action {

  constructor(method,name){

    super(method=player.moveSouth,name="Move south")

  }

}


class MoveNorth extends Action {

  constructor(method,name){

    super(method=player.moveNorth,name="Move north")

  }

}


class MoveEast extends Action {

  constructor(method,name){

    super(method=player.moveEast,name="Move east")

  }

}


class MoveWest extends Action {

  constructor(method,name){

    super(method=player.moveWest,name="Move west")

  }

}


class Attack extends Action {

  constructor(method,name,enemy){

    super(method=player.attack,name="Attack")

    this.enemy = enemy

  }

}


class Flee extends Action {

  constructor(method,name,tile){

    super(method=player.flee,name="Flee")

    this.tile = tile

  }

}


With the player’s actions complete, we can finally create our game.js code. We’ll use this file to connect our game world to the HTML interface. Using the room data, we can generate a list of possible actions and populate a list of buttons. Each button will connect to a player method via the associated action.


When the game begins, we call the game() function and load the available actions as well as the room description text. We’ll also call each room’s modifyPlayer() method. 


let storyText = document.getElementById('story-text')


let gameBtns = document.getElementById('game-btns')


let availableActions

var room


var busy = false


function play(){

  room = world[[player.location_x,player.location_y]]


  let text = room.introText()

  if (player.isAlive() && !player.victory){

      text += "<p>Choose an actions</p>"

      availableActions = room.availableActions()


      let innerHTML = ""

      availableActions.forEach(action => {

        innerHTML += `

        <button type="button" name="button" onClick="clickGameBtn('${action.name}')">${action.name}</button>

        `

      });


      gameBtns.innerHTML = innerHTML

  }


  room.modifyPlayer(player)


  if (player.victory){

    alert("You Win!");

    gameBtns.innerHTML = ""

  }



  if (!busy){

      render(text)

  }

}


function clickGameBtn(val){

  busy=false

  if (val === "Move east"){

    player.moveEast()

  } else if (val === "Move west"){

    player.moveWest()

  }else if (val === "Move north"){

    player.moveNorth()

  } else if (val === "Move south"){

    player.moveSouth()

  } else if(val === "View Inventory"){

    player.printInventory()

    busy = true

  } else if (val === "Attack"){

    player.attack(room.enemy)

    busy = true

  } else if (val === "Flee"){

    player.flee()

  }


  play()

}


function render(text){

  storyText.innerHTML = text

}


function addStoryText(text) {

  storyText.innerHTML += text

}


play()


When a button is clicked, we’ll match it’s value with the correct player action. After each selection is made, we call the play() function again, completing the game loop.

Summary

That concludes the tutorial on creating a text based RPG in JavaScript. We covered a lot of ground in this short series. I hope you’ve learned something about JavaScript and creating web applications. 


If you’ve enjoyed this tutorial and would like to learn more about computer programming and web development, check out these related posts.


About Us

Learning at the speed of light.

We created Start Prism to help students learn programming. You can find exercises and recent tutorials below.

Topics Quizzes Tutorials

0 comments

Leave a comment