Questing - ThePix/QuestJS GitHub Wiki

This describes how to write a game. The game in question is rather unconventional, which allows us to see some interesting techniques.

The Game

The game revolves around the player doing a series of quests, but it is actually the bits in between that will be important. The player will be based in a city, where she can develop relationships with various people, recruit henchmen, fence goods and so on. The quests will be handled simply by the player going out the city gate, and the user getting presented with a simple dialog, and then the results of the quest are given.

When I say "simply"...

In fact, those quests are what makes the game complicated. We need a way to handle them, something that will take account of the player circumstance, the user's choices and the idiosyncrasies of the quest.

QuestJS has a built-in quest system, but that is designed for more conventional quests that are potentially multi-step and could be branching. This is rather different, so we will not be using that.

Settings

As the game revolves around quests that are done through a dialog, I think it makes sense to have all input done through the mouse, rather than text. Interactions will therefore be through the side panes and conversations will be done with menus. That said, I will keep the text input during development.

There will be a lot of data associated with the quests, and I am going to put that all in its own file to make stuff easier to find. I will want to process that data to automatically generate conversation topics, and the code for that will be in another file, "generator.js", which needs to be last in the list to ensure everything is loaded before that.

I want to track how much money the player has, and also stress; they will be displayed in the status pane.

settings.noTalkTo = false
settings.textInput = settings.playMode === 'dev'

settings.files = ["quests", "code", "data", "generator"]   

settings.status = [
  "stress",
  "money",
]

Basic Quests

We need some dummy quests that we can use to test the dialog. Each needs a name that we will identify it with (and is composed only of letters, digits and underscores), a title the user will identify it by and a description.

We could have each quest as an object in the game world, or as attributes of the player, or as attributes on the quest-giving NPC. However, I think it is better to keep large data structures outside of the game world to avoid any issues when quest saves the game. Therefore all the quests will be in an array called "quests". I am first creating a dictionary called q, so I can keep all the quest stuff in there. Then I set the "quests" attribute of that dictionary to my quests.

const q = {}

q.quests = [
  {
    name:'inheritance',
    title:'Get inheritance from farm', 
    text:'Go to the badlands, search the farm, grab anything useful, get back.',
  },
  {
    name:'spider', 
    title:'Defeat the giant spider', 
    text:'The spider lives in a tower in the foothills. Must either kill it or persuade it to leave the area.',
  },
]

To make it easier to find quests by their name, I am going to add a simple function to q. This is in "code.js".

q.find = function(name) {
  const quest = q.quests.find(el => el.name === name)
  return quest
}

We do need to save some data on the player - anything that will change, and needs to get saved. Therefore we will have a list of active quests as an attribute. For testing, we want one (or both) to be assigned at the start, so add this to "settings.js".

settings.setup = function() {
  player.activeQuests = ['inheritance']
}

The Player

The player needs some custom attributes.

createItem("me", PLAYER(), {
  loc:"market_square",
  synonyms:['me', 'myself'],
  examine: "Just a regular guy.",
  
  activeQuests:[],
  money:5,
  stress:0,
})

The Dialog

Quest has the facility for dialogs built-in, as described here, which makes it pretty easy (the examples on that page are from an embryonic version of this).

The io.dialog function requires a dictionary that includes an array of widgets. The first widget is missing the data attribute, we will be adding that later as it depends on what quests are available.

q.widgets = {
  title:'Go On A Quest!',
  widgets:[
    { type:'dropdownPlus', title:'Quest', name:'quest',},
    { type:'checkbox', title:'Scheduling', name:'night', data:'Go at night time?'},
    { type:'radio', title:'Prioritise', name:'priority', data:{compan:"Companions' well-being", magic:"Gaining magical power", money:"Accumulating money"}},
    { type:'radio', title:'Strategy', name:'strategy', data:{assault:"All-out assault", stealth:"Stealth (where possible)", negotiation:"Negotiation (where possible)", tactical:'Tacital', mind:'Use mind tricks'}, checked:0},
  ],
  okayScript:function(options) {
    log(options)
  },
  cancelScript:function() {
    msg('She considers starting a quest, but decides she is not quite ready yet.')
  },
}

The "okayScript" does nothing yet, but later will be handling the running of the chosen quest.

The script goes on the city gate exit.

  north:new Exit('_', {use:function() {
    if (player.activeQuests.length === 0) return falsemsg("She has no reason to leave the city at the moment.")

    q.widgets.widgets[0].data = []
    for (const s of player.activeQuests) {
      const quest = q.find(s)
      q.widgets.widgets[0].data.push(quest)
    }
    io.dialog(q.widgets)
    return false
  }}),

The script first checks if any quests are active. If there are, then each active quest is added to the first widget.

At this point you should be able to go in game and test the dialog (you may want to create an empty file called "generator.js"). This is important; in any big system you need to be able to break it up into units that you can test as you go along. Knowing how to do that is quite a skill, and often there seems to be a chicken-and-egg situation. The solution, as here, is often to partly do one system (often called a "stub"), then do the next, then go back and finish the first.

Running a quest

Now we want the quests to actually do something. This is where it starts to get complicated...

A quest can consist of several steps, we can call them encounters, and encounters may not be applicable, to the heart of the system is a loop that iterates over those encounters.

Before the loop, we need to find the relevant quest and also set up a dictionary that will store the cumulative results.

After the loop, we apply the results to the player.

q.run = function(options) {
    const quest = q.find(options.quest)
    const results = {stress:0, money:0, artefacts:[], success:0}
    
    for (const el of quest.encounters) {
      if (el.if && !el.if(options, results)) continue
      if (el.text) msg(el.text, options)
      if (el.script) el.script(options, results)
    }

    player.money += results.money
    player.stress += results.stress
    player['quest_outcome_' + options.quest] = results.outcome
    for (const s of results.artefacts) w[s].loc = player.name
}

We need to modify the "okayScript" with the dialog widgets to use this:

  okayScript:function(options) {
    q.run(options)
  },

Now we need to modify our quests by giving each an array attribute called "encounters". Each element of the array has either a "text" attribute or a "script" attribute that reports what happened, and in the latter case can update the results. It can also have an "if" attribute; this step will only be used if this returns true. In the example, there are two steps to cover searching the farm, one applies at night, the other during the day.

  {
    name:'inheritance',
    title:'Get inheritance from farm', 
    level:1,
    encounters:[
      {
        name:'Travel to the farm',
        text:'The journey to the farm is uneventful.',
      },
      {
        name:'Search the farm (night)',
        if:function(options, results) { return options.night },
        script:function(options, results) {
          msg('She searches the farm, and finds 10 gp, but it was too dark to see much. She found a couple of the things she was supposed to...')
          results.money += 10
          results.outcome= 1
        },
      },
      {
        name:'Search the farm (night)',
        if:function(options, results) { return !options.night },
        script:function(options, results) {
          msg('She searches the farm, and finds 25 gp, as well as the items he asked her to get.')
          results.money += 25
          results.outcome= 2
        },
      },
      {
        name:'Return',
        text:'The return journey, back to the city, is uneventful.',
      },
    ],
  },

Handling NPCs

Now we need to have NPCs in the world able to give quests and react when they are done. As this will be the same functionality for every quest, we can add the text to the quest, and have the topic generated automatically from that.

This will be done through conversation topics, and we need three per quest. The first to give the quest, the second when the player asks about it but has not finished it, and the third when the player has completed it. These will be called "start", "middle" and "end". After that, the quest will no longer be available as a topic.

We need to add some attributes to our quests. The "owner" will be the name of the NPC we are assigning it to.

Each topic will have a name that is composed of the owner NPC name, the quest name and step name, joined with underscores. With that in mind, we can modifie q.run to hide the "middle" topic and show the "end" topic.

q.run = function(options) {
  const quest = q.find(options.quest)
  const results = {stress:0, money:0, artefacts:[], outcome:0}
  
  for (const el of quest.encounters) {
    if (el.if && !el.if(options, results)) continue
    
    if (el.text) msg(el.text, options)
    if (el.script) el.script(options, results)
  }
  log(results)
  player.money += results.money
  player.stress += results.stress
  player['quest_outcome_' + options.quest] = results.outcome
  array.remove(player.activeQuests, quest.name)

  for (const s of results.artefacts) w[s].loc = player.name

  if (quest.owner) {
    w[quest.owner + '_' + quest.name + '_middle'].hide()
    w[quest.owner + '_' + quest.name + '_end'].show()
  }
}

The code to create the conversation topics will go in "generator.js". It needs to run when the files are loaded, so is not inside an object or anything. It cannot go in settings.setup as that is run too late to create objects.

The code iterates through each quest. If the quest has no owner, we skip it - so we can have odd-ball quests with topics created in the normal way. Otherwise the three topics are generated.

for (const el of q.quests) {
  if (!el.owner) continue
  
  createItem(el.owner + '_' + el.name + '_start', TOPIC(el.starter), {
    loc:el.owner,
    questName:el.name,
    alias:'Any work for me?',
    nowShow:[el.owner + '_' + el.name + '_middle'],
    msg:el.start,
    script:function(options) {
      options.char.currentQuest = options.topic.questName
      player.activeQuests.push(options.topic.questName)
    },  
  })

  createItem(el.owner + '_' + el.name + '_middle', TOPIC(), {
    loc:el.owner,
    questName:el.name,
    alias:el.title,
    msg:el.middle,
    hideAfter:false,
    script:function(options) {
      const quest = q.find(options.topic.questName)
      if (!options.topic.msg) {
        msg("'Have you had a chance to do that for me?' {vn:char:ask}.", options)
        msg("'Sorry, not yet.'")
      }
    },  
  })

  createItem(el.owner + '_' + el.name + '_end', TOPIC(), {
    loc:el.owner,
    questName:el.name,
    alias:el.title,
    msg:el.end,
    script:function(options) {
      log(options)
      const attName = 'quest_outcome_' + options.topic.questName
      const quest = q.find(options.topic.questName)
      options.quest = quest
      log(options)
      options.outcome = player['quest_outcome_' + quest.name]
      log(options)
      options.quest.outcome(options)
      if (options.topic.next) {
        w[quest.owner + '_' + quest.next + '_end'].show()
      }
    },  
  })
}

The attributes for quests to cover this are:

attribute|type|Default or...|Comment ---|---|--- owner|string|undefined|If not set, no topics generated starter|Boolean|false|If true, the quest start topic will be visible the first time the player talks to this NPC next|string|undefined|If set, the named quest will be visible next time the player talks to this NPC startMsg|string|REQUIRED| startAlias|string|'Any work for me?'| middleMsg|string|"'Have you had a chance to do that for me?' {vn:char:ask}.|'Sorry, not yet.'"| middleAlias|string|QUEST NAME| endMsg|string|undefined|You should have a message somewhere, but you may prefer to do it in "outcome" and have it depend on how well the quest went endAlias|string|QUEST NAME| outcome|function|REQUIRED|See below

The "outcome" function will be passed a dictionary with the attributes "player", "char" (the NPC talked to), "quest", "topic" and "outcome". The last of these is the number set in the quest itself. This is how the quest communicates how the player did to the NPC.

Here is our revised quest. I have broken the code into three parts, the first being the basics of the quest, the second attributes for the conversation and the third being the encounters. The second is the new bit. The topics will be assigned to Henry, and the first topic will be available from the start. When this quest is done, a quest called "spider" will be available with this NPC (if you want a quest to be available via another NPC this will require extra code in the "outcome" function).

The quest itself sets the outcome to 1 if it was at night, for a partial success, and 2 if it was during the day, for a full success. The "outcome" function accesses this value via the options dictionary to decide how to respond.

  {
    name:'inheritance',
    title:'Get inheritance from farm', 
    level:1,
    text:'Go to the badlands, search the farm, grab anything useful, get back.',
    
    owner:'Henry',
    starter:true,
    startMsg:"'I have a problem,' says Henry. 'I need someone to get some stuff from afarm I recently... inherited.'|'Inherited?'|'Sure! From my elderly aunt. Elder and deceased that is.'",
    outcome:function(options) {
      log(options)
      if (options.outcome === 1) {
        msg("'Yes... but I could not find everything you wanted.'")
      }
      else (options.outcome === 2) {
        msg("'Yes, and I found everything you wanted.'")
      }
    },
    next:'spider',
    
    encounters:[
      {
        name:'Travel to the farm',
        text:'The journey to the farm is uneventful.',
      },
      {
        name:'Search the farm (night)',
        if:function(options, results) { return options.night },
        script:function(options, results) {
          msg('She searches the farm, and finds 10 gp, but it was too dark to see much. She found a couple of the things she was supposed to...')
          results.money += 10
          results.outcome = 1
        },
      },
      {
        name:'Search the farm (night)',
        if:function(options, results) { return !options.night },
        script:function(options, results) {
          msg('She searches the farm, and finds 25 gp, as well as the items he asked her to get.')
          results.money += 25
          results.outcome = 2
        },
      },
      {
        name:'Return',
        text:'The return journey, back to the city, is uneventful.',
      },
    ],
  },

Companions

I want the player to be able to ask friends to come along, and to hire henchmen. How do we do that? Firstly we need to consider how we will record the data. Then we can implement some conversation topics for hiring, etc. Then we can integrate all that with the quests.

So we start with the data. For each PC we need to know:

hireStatus: We will use this to record if the NPC is a friend, a henchman hired for one quest or on-going, etc. friendship: This will be a number reflecting how much the NPC likes the player wages: This number is how much money the NPC will be paid each quest health: How injuried the NPC will be recorded, on a sale of 100, full health, to 0, dead. combat, stealth, magic, etc.: How good the NPC is at something.

The hireStatus could be a number or a string; for instance, we could use 10 to indicate this is a friend, or the string "friend". Numbers will be faster for JavaScript to process, but on a modern device no one will notice. It is also harder to misspell a number. However, strings are much easier for us to understand so in my view are a