I suppose I have always been a little bit of a nerd. It was my 12th birthday and my family had just moved to a house that was almost but not quite in the middle of nowhere. Cable television would not even be an option at that house for at least five or six more years. It was the dark ages.

Things changed on that birthday though when my dad came home from work. He walked in my room, told me happy birthday and tossed me a bag from the local comic book shop. Inside the bag was a whole new world, shrink wrapped in a red box adorned with a warrior fighting a huge red dragon. The original Dungeons & Dragons Basic Set.

I still remember sitting in my room that night playing and replaying that first solo adventure.

That moment was life changing for me as it kicked off my love of fantasy, drawing and gaming. Some of my best friends were made sitting around a table top going on adventures as kids.

Brief history lesson is out of the way, I have been looking to start a new Vue.js learning project. Thinking back to what first got me in to gaming, that first solo adventure I played when I was 12, I thought that might be the perfect place to start.

Armed with the 5th Edition SRD rules I got to work.

Character

The player character will be a human warrior with some heavy armor and a battle axe. You have the ability to set the character's name and primary stats. Right now you really have no choice of gear or weapons, you just get what you get. Once stats are set you need to save your character which will apply racial bonuses to your stats, apply your constitution bonus if any, and "save" your character in your browser's local storage. It will remain there until you purge it.

Once saved, it is time to go kill some things.

Combat

I originally started off with a handful of monsters, but I found a JSON file that had the listing of monsters from 5th Edition SRD . I did not want to modify their file so the first thing I do is filter the array to ensure only objects with a name key will be returned.

import { mapState } from 'vuex'
import mobData from '../5e-SRD-Monsters.json'
import gameData from '../gameData.json'

export default {
  name: 'combat',
  data () {
    return {
      mobs: mobData.filter((mob) => { return ('name' in mob) }),
      alive: true,
      mobsAlive: false,
      rounds: 0,
      hasInitiative: false,
      opponents: [],
      combatLog: []
    }
  },
  methods: {
    pcAttack: function () {
      let pcAtk = this.roll('1d20')
      switch (pcAtk) {
        case 1:
          this.combatLog.push('You rolled a 1 for attack roll. You missed!')
          break
        case 20:
          this.combatLog.push('You rolled a 20 for attack roll. Critical Hit!')
          let baseDmg = this.roll('1d6', true) + this.modifier(this.character.stats.str)
          this.combatLog.push(`You hit the ${this.opponents[0].name} for ${baseDmg} points of damage.`)
          this.opponents[0]._hit_points -= baseDmg
          break
        default:
          let doesHit = pcAtk + this.modifier(this.character.stats.str) + this.byLevel.proficiency > this.opponents[0].armor_class
          if (doesHit) {
            let baseDmg = this.roll('1d6') + this.modifier(this.character.stats.str)
            this.combatLog.push(`You hit the ${this.opponents[0].name} for ${baseDmg} points of damage.`)
            this.opponents[0]._hit_points -= baseDmg
          } else {
            this.combatLog.push(`You attack the ${this.opponents[0].name}, but miss!`)
          }
          break
      }
    },
    npcAttack: function () {
      // calculate attack for each opponent
      for (let m = 0; m < this.opponents.length; m++) {
        if (this.character.current.hp > 0) { // if alive and opponents alive
          let npcAtk = this.roll('1d20')

          let validActions = this.opponents[m].actions.filter((action) => { return ('damage_dice' in action) }) || []
          let selectedAction = validActions[ Math.floor(Math.random() * validActions.length) ]

          let attackBonus = ('attack_bonus' in selectedAction) ? selectedAction.attack_bonus : 0
          let damageBonus = ('damage_bonus' in selectedAction) ? selectedAction.damage_bonus : 0

          let baseDmg = 0

          switch (npcAtk) {
            case 1:
              this.combatLog.push(this.opponents[m].name + ' rolled a 1 for attack roll. It missed!')
              break
            case 20:
              this.combatLog.push(this.opponents[m].name + ' rolled a 20 for attack roll. Critical Hit!')
              baseDmg = this.roll(selectedAction.damage_dice, true) + this.modifier(this.opponents[m].strength) + damageBonus
              this.combatLog.push(`${this.opponents[m].name}: ${selectedAction.name} for ${baseDmg} points of damage.`)
              this.character.current.hp -= baseDmg
              break
            default:
              baseDmg = this.roll(selectedAction.damage_dice) + damageBonus
              let doesHit = npcAtk + this.modifier(this.opponents[m].strength) + attackBonus > this.character.attr.ac
              if (doesHit) {
                this.combatLog.push(`${this.opponents[m].name}: ${selectedAction.name} for ${baseDmg} points of damage.`)
                this.character.current.hp -= baseDmg
              } else {
                this.combatLog.push(`${this.opponents[m].name}: ${selectedAction.name}, but misses!`)
              }
          }
        }
      }
    },
    eventLoop: function () {
      if (this.rounds === 0) {
        this.hasInitiative = this.character.stats.dex + this.modifier(this.character.stats.dex) > this.opponents[0].dexterity + this.modifier(this.opponents[0].dexterity)
        this.opponents[0]._hit_points = this.opponents[0].hit_points
      }
      this.rounds++
      this.mobsAlive = this.opponents.filter((op) => { return op._hit_points > 0 }).length > 0

      // if hasInit player goes first, otherwise opponents go first
      if (this.hasInitiative) {
        this.pcAttack()

        this.mobsAlive = this.opponents.filter((op) => { return op._hit_points > 0 }).length > 0

        if (this.mobsAlive) {
          this.npcAttack()
        }
      } else {
        // calculate attack for each opponent
        this.npcAttack()
        if (this.character.current.hp > 0) {
          this.pcAttack()
        }
      }

      this.alive = this.character.current.hp > 0
      this.mobsAlive = this.opponents.filter((op) => { return op._hit_points > 0 }).length > 0

      // All opponents are dead and pc is alive
      if (!this.mobsAlive && this.alive) {
        this.combatLog.push('You have won!!!!')

        for (let i = 0; i < this.opponents.length; i++) {
          this.character.attr.xp += gameData.experience.filter((cr) => { return cr.challenge_rating === this.opponents[i].challenge_rating })[0].xp
        }

        this.$store.commit('SET_CHARACTER', this.character)
        this.saveCharacter(this.character)
      }

      // DED
      if (this.mobsAlive && !this.alive) {
        this.combatLog.push('You have died!!!!')
      }
    },
    modifier: function (value) {
      return Math.floor((parseInt(value) - 10) / 2)
    },
    restart: function () {
      this.$store.dispatch('refreshCharacter')
      this.character.current.hp = this.character.attr.hp
      this.rounds = 0
      this.opponents = []
      this.combatLog = []

      let isValid = false
      let op

      // Not all monsters have damage rolls... this weeds out any bad data (i.e., Sprite)
      while (!isValid) {
        op = this.mobs[ Math.floor(Math.random() * this.mobs.length) ]
        isValid = op.actions.filter((action) => { return ('damage_dice' in action) }).length > 0
      }

      this.opponents.push(op)

      // this.opponents.push(this.mobs.filter((mob) => { return mob.name === 'Sprite' })[0])

      for (let i = 0; i < this.opponents.length; i++) {
        this.opponents[i]._hit_points = this.opponents[i].hit_points
      }

      this.alive = true
      this.mobsAlive = true
    },
    roll: function (dice, critical = false) {
      let rolls = dice.split('+')
      let result = 0

      for (let r = 0; r < rolls.length; r++) {
        // critical roles should roll double ie 1d6 becomes 2d6
        let count = critical ? rolls[r].split('d')[0] * 2 : rolls[r].split('d')[0]
        let die = rolls[r].split('d')[1]
        for (let i = 0; i < count; i++) {
          result += Math.floor(Math.random() * die) + 1
        }
      }

      return parseInt(result)
    },
    saveCharacter: function () {
      localStorage.setItem('character', JSON.stringify(this.character))
      this.$store.dispatch('refreshCharacter')
    }
  },
  filters: {
    plussed: function (value) {
      return parseInt(value) >= 0 ? '+' + value : value
    },
    modifier: function (value) {
      return Math.floor((parseInt(value) - 10) / 2)
    }
  },
  computed: {
    byLevel: function () {
      return gameData.advancement.filter((data) => { return data.level === this.character.attr.lvl })[0]
    },
    ...mapState(['character'])
  }
}

The restart method is responsible for grabbing a mob from the Monster Manual and starting combat, but before we start the fight we have to make sure the actions array objects for a given mob provide a damage_dice key. This provides the damage for a given attack (action). Not every action listed for a monster has this key, so those must be weeded out. If a monster has no actions with damage_dice, it is right out and will be removed.

The eventLoop method is the heart of the combat simulator. Initiative is checked at the start of combat and determines the order of combat for the entire encounter. If the player wins initiative he will attack first and then any mobs will attack. If the mobs will initiative they will attack first and then the player will attack. This process repeats until someone dies.

The pcAttack and npcAttack methods were broken out in to their own methods to maintain readability. These methods handle attack and damage rolls and applying damage to the intended target.

Once each combat turn is completed I see either the character or the monster has died. If the monster died the correct experience is awarded. Restarting combat begins the process again.

Monster Manual

I was lucky enough to find the 5th Edition SRD Monster listing as a JSON payload which rocketed my initial 3 mobs to over 300!

I added a mob viewer to the application as a way to help debug issues in the combat logic, but it was also like a walk down memory lane. I have some ideas of how I would like the page to work and I also thought it would be cool to have a button where you could jump into combat with a specific monster.

Wrapping it all up

I think I am off to a good start. A majority of the time was spent integrating and dealing with the nuances of the monster data. Combat is working fairly well next I will look in to getting leveling working and polish up the character screen a bit.

Feel free to check it out:
https://github.com/robertz/vue-rpg
https://vue-rpg.now.sh