Creating a Strategy Game - ThePix/QuestJS GitHub Wiki
The purpose here is to show how the QuestJS system could be adapted to a turn-based strategy game. In this example, the user takes the role of a ruler of a small kingdom in a fantasy would. Each turn she can make policy decisions, and then click a button to advance time. A season (three months) will pass in the game world, and the game will report back anything of note that happened during that time.
The game will have no text input - the user will interact through the toolbar and the sidepane. We will keep meta-commands on the toolbar.
This is not a very efficient way to create such a game; button clicks will generate text commands that will then get parsed. But it will be fast enough. Any delay will be while calculating what happens each season, and will be as fast as any other system using JavaScript in the browser.
When developing a game it is a good idea to test it works at every step. If there is an issue, you can then be sure it is in the bit you just did. At each stage of development, you should check the game loads and looks and does as expected at that stage. It will tell you you are in the lounge until we get some way into it!
In the data.js file, we need a couple of objects to get us going. We will expand on these later, but creating simple versions will help while setting up. The first represents the player's nation, and will be used to store some values that can change and we want to keep updated on the screen.
createItem("home", {
progress:0,
population:100,
food:100,
money:100,
happiness:'contented',
})
const home = w.home
We will refer to this in code a lot, so we assign it to its own global variable in the last line.
The progress attribute will be used to count turns. So why not call it turn? Later on we will add science and other things that will have their own progress, and it will be useful if they all have the same name.
We will have the player interact via NPCs. To change the relationship with another country, the player will interact with its diplomat. To interact with a faction, the player will interact with its representative. To interact with a government department, the player will interact with an advisor. This means we can use much of the existing QuestJS functionality. It should also make it easier to expand the game - want to add a new faction? Just create a new NPC.
Here is one to get us going. This is our science advisor. His location is the player (called "me" by default) so the player can interact with him. He is flagged as a department, so he will get picked up by the inventory system.
createItem("science", NPC(), {
alias:'Science Minister',
dept:true,
loc:'me',
invShow:function() { return true},
examine:"Science is important to any nation; it can lead to more efficient agriculure, new industries and better weapons. And no good ruler wants anyone to think his kingdom is a technologcal backwater.",
})
With this two objects created, we will modify settings.js, mostly to modify the user interface (UI). These first few a very simple.
settings.compassPane = false
settings.textInput = false
settings.cmdEcho = settings.playMode === 'dev'
settings.suppressTitle = true
settings.roomTemplate = []
settings.noTalkTo = false
settings.funcForDynamicConv = 'showMenuDiag'
settings.seasons = ['winter', 'spring', 'summer', 'autumn']
settings.startYear = 327
First, turn the compass off, as it is not relevant, and also the text input. We will also turn off "cmdEcho" - that is displaying the commands in the output - but only when not in development mode. We also turn off the title appearing in the output. The roomTemplate is what is shown when the player enters a room, so just set that to an empty array so Quest does not say we are in a messy lounge.
The next activates TALK TO for NPCs, and the next has dynamic conversations work via menus in a dialog box.
The last two settings create some constants we can use later...
settings.roomTemplate = [
"{hereDesc}",
]
The toolbar will display the date on the left, and some buttons on the right.
settings.toolbar = [
{content:function() { return 'It is ' + settings.seasons[home.progress % 4] + ', ' + Math.floor(settings.startYear + home.progress / 4) }},
{title:true},
{buttons:[
{ title: "Dark mode", icon: "fa-moon", cmd: "dark" },
{ title: "Save", icon: "fa-upload", cmd: "save game ow" },
{ title: "Load", icon: "fa-download", cmd: "load game" },
{ title: "About", icon: "fa-info-circle", cmd: "about" },
]},
]
These all call existing commands, so will just work. More on the toolbar here.
Then set up the inventories to handle the NPCs.
settings.inventoryPane = [
{name:'Departments', alt:'depts', test:function(o) { return o.dept && o.invShow() } },
{name:'Factions', alt:'factions', test:function(o) { return o.faction && o.invShow() } },
{name:'Neigbours', alt:'nations', test:function(o) { return o.nation && o.invShow() } },
]
The "test" functions are all the same; they return true
if the object is of the right type and if the function "invShow" returns true
. The latter allows us to have a faction appear later in the game, or to disappear.
Usually inventories need a "getLoc" attribute to handle oddball items (like ropes), but as our inventories are very specific, we can be sure there will be none of them, so do not need to bother.
We need a status pane to show how the player is doing. This is the basics - we can add more later:
settings.statusPane = "Status"
settings.statusWidthLeft = 120
settings.statusWidthRight = 40
settings.status = [
function() { return "<td>Population</td><td>" + home.population + "</td>" },
function() { return "<td>Treasury</td><td>" + home.money + "</td>" },
function() { return "<td>Food</td><td>" + home.food + "</td>" },
function() { return "<td>Attitude</td><td>" + home.happiness + "</td>" },
]
And we also need a button the user can click to advance time. This needs its own pane.
settings.setup = function() {
createAdditionalPane(0, "Time", 'next-turn', function() {
let html = '<input type="button" onclick="runCmd(\'wait'')" value="Next turn" />'
return html
})
msg("Welcome...")
}
I am not going to spend long on the styling, it has a lot of potential and is very subjective. I will just make that button look more impressive, as the whole game kind of revolves around it. We called the pane "next-turn", and so that is used as the selector for each bit. The furst applies to the whole pane, which is an HTML division - we want the button centred inside that.
The second section is for the button, and makes the text red, bold and italic, and gives the button round corners, and a little higher.
The third section also sets the button style, but only in the condition "hover", i.e, when the mouse is over it. It changes the colours.
#next-turn {
text-align: center;
padding: 5px;
}
#next-turn [type="button"] {
color:red;
font-style: italic;
font-weight: bold;
border-radius: 6px;
height:24px;
}
#next-turn [type="button"]:hover {
background-color: yellow;
color: black;
}
There is only one command we need to deal with - the rest will be done via verbs. It is a biggy, however.
The "next turn" button is set to run the WAIT command. What exactly do we want to happen when the user clicks that button, and three months of game-time passes?
The first thing is to increment the "progress" attribute of the home object.
The next step is to develop this to actually make a game - we want the world to change, in part based on choices the player made. We already have three types of NPCs; "dept", "faction" and "nation". We can add a fourth, "world", which could represent the weather, population growth, or anything else the game controls rather than the player. Each of these could have any number of NPCs, and each NPC can impact the world. And the state of the world can impact each NPC.
The simplest way to handle this is for each to have a "worldImpact" function. We iterate through everything in the game; if it has a "worldImpact" function, we run it. We then need to go through the list again, but this time calling the "worldResponse" function, which must not change the world status, only the NPC status.
We will also give the home object an array called "output" that any of these functions can add to, and this will get output at the end. If there is nothing in it, we just note that nothing happened.
findCmd('Wait').script = function() {
home.progress += 1
home.output = []
for (const key in w) {
const o = w[key]
log(o.name)
if (o.worldImpact) o.worldImpact()
}
for (const key in w) {
const o = w[key]
if (o.worldResponse) o.worldResponse()
}
if (home.output) {
msg(home.output.join('|'))
}
else {
msg('Not much happens.')
}
return world.SUCCESS
}
To test that, update our NPC:
createItem("science", {
alias:'Science',
dept:true,
loc:'me',
invShow:function() { return true},
examine:"Science is important to any nation; it can lead to more efficient agriculure, new industries and better weapons. And no good ruler wants anyone to think his kingdom is a technologcal backwater.",
science:0,
worldImpact:function() {
this.science += 1
home.output.push('Science marches on!')
},
})
When you click the button, it should report that science marches on.
An important aspect of the game will be agriculture. This will be an object like science. For now we will just give it a "worldImpact" function. It is going to increase food, depending on the season.
Then it will work out how much food the population will consume. If there is enough food, the population will increase; too little and it will decrease.
There are a set of numbers here that will impact the gameplay, and these will all be attributes of the agriculture object. This makes them easier to find if they need adjusting.
createItem("agriculture", {
foodBySeason:[0, 15, 50, 135],
foodCommentBySeason:['No food produced in winter', 'Hunters have found limited food across the spring.', 'Hunters have found plenty of food across the summer.', 'Food has been harvested from the fields'],
foodFactor:0.4, // one person eats this much food each turn
populationGrowth:0.02, // pop increases by this each turn unless starving
starvationFactor:2, // how quickly people die when starving
efficiency:1, // how well farms do
worldImpact:function() {
const season = home.progress % 4
home.output.push(this.foodCommentBySeason[season])
home.food += this.foodBySeason[season] * this.efficiency
log(home.food)
const foodConsumption = this.foodFactor * home.population
if (foodConsumption > home.food) {
home.population -= Math.floor((foodConsumption - home.food) / this.starvationFactor)
home.food = 0
home.output.push('People are starving!')
}
else {
home.population += Math.floor(home.population * this.populationGrowth)
home.food -= foodConsumption
}
},
})
If you play the game, you will have no control over anything as yet, but you will see that the food and population will change as time passes. The population will slowly increase until it hits a point where there is not enough food, and drop down to a level where the population oscillates between starving over winter and growing at other times.
We already have a science object, but it does not do much. We will change that to allow for discoveries. This new version is going to go through every object, looking for one that is flagged as a discovery and has a science value, that has not yet been discovered, and that has a science value less than the current science. When it finds one, it adds the discovery's "examine" string to the output and marks it as discovered.
createItem("science", {
alias:'Science Minister',
dept:true,
loc:'me',
invShow:function() { return true},
examine:"Science is important to any nation; it can lead to more efficient agriculure, new industries and better weapons. And no good ruler wants anyone to think his kingdom is a technologcal backwater.",
progress:0,
worldImpact:function() {
this.progress += 1
home.output.push('Science marches on!')
for (const key in w) {
const o = w[key]
if (!o.discovery || !o.science) continue
if (o.discovered) continue
log(this.progress)
log(o.science)
if (o.science > this.progress) continue
o.discovered = true
home.output.push(o.examine)
}
},
})
Here is our discovery. It is flagged as such, and given a science value - the player will discover it when the nation's science gets to this. Plus an examine string.
createItem("crop_rotation", {
discovery:'"Your magesty, our neighbours are getting better yields from the land using a system called crop rotation, whereby a field is used for grain one year, legumes the next and left fallow for livesock the next. Perhaps you might discuss..."',
science:5,
})
If you try in game, you should get informed about the discovery at turn 5. Now we need to make it useful. The way this will work is through conversation topics. Talk to the science minister about crop rotation, and you get the bonus.
Here is out new science NPC. The only change is the convTopics attribute at the bottom. When the "crop rotation" topic is selected, agriculture efficiency goes up 10%.
createItem("science", NPC(), {
alias:'Science Minister',
dept:true,
loc:'me',
invShow:function() { return true},
examine:"Science is important to any nation; it can lead to more efficient agriculure, new industries and better weapons. And no good ruler wants anyone to think his kingdom is a technologcal backwater.",
science:0,
worldImpact:function() {
this.science += 1
home.output.push('Science marches on!')
for (const key in w) {
const o = w[key]
if (!o.discovery || !o.science) continue
if (o.discovered) continue
if (o.science > this.science) continue
o.discovered = true
home.output.push(o.discovered)
}
},
convTopics:[
{
showTopic:true,
isVisible:function(loc) { return w.crop_rotation.discovered },
alias:"Crop rotation",
script:function() {
msg("You tell the Science Minister to have crop rotation implemented.")
w.agriculture.efficiency *= 1.1
},
},
],
})
This is a fantasy world, so let us add a little magic. At turn seven a group of druids will show up, and you can ask them to use magic to help crps grow.
We will handle this like science, with discoveries becoming available as the game progresses. Later we might want to add other systems that do likewise, so it makes sense to make a general system to handle them all. This means moving the science progress out of science and into home. All the science has to do is increase science each turn.
worldImpact:function() {
this.progress += 1
home.output.push('Science marches on!')
},
It would be neat if we could move the rest into a worldImpact function in the home object, but we want this to fire after all the other objects have had their go, so we will create a special function called "nextTurn", and have it called in the WAIT command. We can use home.worldImpact to increment the turn and initialise the output.
Here is our revised WAIT command script.
findCmd('Wait').script = function() {
for (const key in w) {
const o = w[key]
if (o.worldImpact) o.worldImpact()
}
home.nextTurn()
for (const key in w) {
const o = w[key]
if (o.worldResponse) o.worldResponse()
}
if (home.output) {
msg(home.output.join('|'))
}
else {
msg('Not much happens.')
}
return world.SUCCESS
}
The complicated bit, then, is the new "nextTurn" function in the home object.
The "turners" attribute is a list of strings - names of objects that can have discoveries associated with them. We start with just home (which then tracks the turn number) and science, but we can add to it later.
createItem("home", {
progress:0,
population:100,
food:100,
money:100,
happiness:'contented',
turners:['home', 'science'],
worldImpact:function() {
this.progress += 1
this.output = []
},
nextTurn:function() {
for (const key in w) {
const o = w[key]
if (!o.discovery) continue
if (o.discovered) continue
log(key)
for (const s of this.turners) {
if (o['discover_' + s] && o['discover_' + s] <= w[s].progress) {
log('greater than ' + w[s].progress)
o.discovered = true
if ('progress' in o) this.turners.push(o.name)
home.output.push(o.discovery)
}
}
}
},
})
As before, it goes through every object, and if the object has no "discovery" attribute or is already flagged as discovered, skips to the next one.
Now we go through the turners. Let us take "science" as an example. It checks if the object has a "discover_science" attribute, and if it does it compares that to the "progress" attribute of the science object. if the progress is greater or equal, we do the bits in the block...
Specifically, flag the discover as discovered, if it has its own "progress" attribute, we add it to the turners, and then we add a message, the "discovery" attribute.
Note that it uses the in
functionality to test if "progress" is an attribute. If we did if (o.progress)
ir would fail as progress will be zero at the start, and zero is considered false
.
We also need to change our discovery so it has a "discover_science" attribute, rather than "discover".
createItem("crop_rotation", {
discovery:'"Your magesty, our neighbours are getting better yields from the land using a system called crop rotation, whereby a field is used for grain one year, legumes the next and left fallow for livesock the next. Perhaps you might discuss with the miniser for the land."',
discover_science:5,
})
So now we have a system that does the same as before but is ready to have the druids added.
Now we can add those druids, and because we have a general system, this becomes pretty easy.
It has a "discover_home" which means it will be discovered after seven turns, and a "discovery" message. It is also flagged as a department, so will appear in the inventory pane, but the "invShow" function means that will only happen once it is flagged as discovered.
It has a progress attribute so will get added to the turns, and we can add discoveries for it. Its "worldImpact" function increments progress, but only once it is discovered.
Finally it has a conversation topic, just like science.
createItem("druids", NPC(), {
alias:'Druid representative',
loc:'me',
discover_home:7,
discovery:'"Your magesty, a group of druids have found a sacred grove in the first, and wish to use it in their arcane rituals."',
dept:true,
invShow:function() { return this.discovered},
examine:"Druids worship the earth mother, at a sacred grove in the forest.",
progress:0,
worldImpact:function() {
if (!this.discovered) return
this.progress += 1
home.output.push('Magic marches on too!')
},
convTopics:[
{
showTopic:true,
alias:"Crop fertility",
script:function() {
msg("You tell the druids to perform fertility rituals.")
w.agriculture.efficiency *= 1.2
},
},
],
})
Currently bonuses to agriculture multiply. This can lead to them getting very big. Imagine we have four bonuses each giving 100% bonus... That means each one doubles the output, which gives an overall output of thirty-two times the original. It is better to have the bonuses add, and apply them in one go at the end. If this example, the four bonuses add up to 400%, s we end up with five times the original, which is far more reasonable. This is how most computer games will calculate bonuses.
It is a little more complicated, as we need another attribute for each of these. The maths is more complicated too; we will take the opportunity to have it uses percentages.
createItem("agriculture", {
foodBySeason:[0, 15, 50, 135],
foodCommentBySeason:['No food produced in winter', 'Hunters have found limited food across the spring.', 'Hunters have found plenty of food across the summer.', 'Food has been harvested from the fields'],
foodFactor:0.4, // one person eats this much food each turn
populationGrowth:0.02, // pop increases by this each turn unless starving
starvationFactor:2, // how quickly people die when starving
efficiency:1, // how well farms do
efficiencyBonus:0, // how well farms do
worldImpact:function() {
const season = home.progress % 4
home.output.push(this.foodCommentBySeason[season])
home.food += this.foodBySeason[season] * this.efficiency * (100 + this.efficiencyBonus) / 100
log(home.food)
const foodConsumption = this.foodFactor * home.population
if (foodConsumption > home.food) {
home.population -= Math.floor((foodConsumption - home.food) / this.starvationFactor)
home.food = 0
home.output.push('People are starving!')
}
else {
home.population += Math.floor(home.population * this.populationGrowth)
home.food -= foodConsumption
}
},
})
Where we were previously multiply the effeciency, we are now adding a percentage bonus.
w.agriculture.efficiencyBonus += 20
We far there is not much strategy to the game; we twice get the chance to boost agricultural output, but it is a no-brainer - there is no downside so just do it as soon as you can. We will add another discovery that will (eventually) also have a downside. The druids want to dance naked each full moon, but the local people object, sayingf it is indecent and not what children should see. Does the player let them dance for the increased food production or keep the population happy?
Here are the basics for the discovery.
createItem("moonlight_dancing", {
discovery:'"Your magesty, the earth goddess has indicated she wishes us to perform certain rituals in a more natural form under the light of the full moon, but the yokels object to our naked dancing. I can assure you that if you will permit us to perform these sacred duties properly, crops will be even more bountiful"."',
discover_druids:4,
})
We then need a conversation topic to allow the player to activate the effect. Would it not be better to haver the topic with the discovery? One day we might have dozens of discoveries and when you edit one, you are likely to want to edit its topic at the same time. Better, then, to keep them together.
This code needs to go at the end of the data.js file (if you end up with multiple files, it needs to be at the end of he last one loaded). It is going to create objects, so cannot go in settings.setup, but has to be run after all the other objects are created.
for (const key in w) {
const o = w[key]
if (o.conv) {
const discovery_type = Object.keys(o).find(el => el.startsWith('discover_'))
const owner_name = discovery_type.replace('discover_', '')
o.conv.loc = owner_name
o.conv.discovery_name = o.name
o.conv.alias ||= sentenceCase(o.alias)
o.conv.isVisible ||= function(loc) { return w[this.discovery_name].discovered }
const topic = createItem(o.conv.name ? o.conv.name : o.name + '_convTopic', TOPIC(), o.conv)
if (!'showTopic' in topic) topic.showTopic = true
delete o.conv
}
}
It goes through every object, and if it finds one with a "conv" attribute, it will build a corresponding topic object. It will work out the name of the associated discovery, and then set some default values.
Note that some assignments are done using ||=
- that means they will only be set if there is not already a value. The "showTopic" attribute is done differently as it could be set to false
but the effect is the same. These are all optional attributes.