Scripting

1. Introduction

1.1 Folder structure

Before writing any scripts, let's have a look at the folder structure you'll be working with. There are quite a lot of places for various types of files, so just remember this is here and use it as a reference.

Path Role
assets/tilemaps the target folder for your Tiled tilemaps
assets/tilesets the target folder for your Tiled tilesets
assets/locales translations files are located there
assets/videos root folder for video files
assets/audio root folder for audio files
assets/sprites root folder for spritesheets
scripts/cmap/traits CMAP traits plugins folder
scripts/cmap/races CMAP races plugins folder
scripts/cmap/perks CMAP perks plugins folder
scripts/quests Quest folder
scripts/dialogs Dialog folder
scripts/levels Level-context scripts (each level can implement one)
scripts/pnjs Characters scripts
scripts/items InventoryItems scripts
scripts/behaviour All other DynamicObjects scripts

Let's also have a look at a series of useful files:

Path Role
assets/audio.json maps audio files to nicknames
assets/musics.json set of playlists prepared for certain contexts (specific level, worldmap, main menu)
assets/sprites.json library of animation and animations groups (should be edited using the Game Editor)
assets/objects.json library of available InventoryItems' item types.
assets/worldmap.png Worldmap source
scripts/main.mjs Game-context script
scripts/initialize.mjs A simple script that initializes the Game context once a new game starts.

1.2 Initializing a new game

The first thing you want to do is to take control of the entry point of the game. You want the player to start at a specific place in the WorldMap, and possibly to start directly inside a Level.

To achieve such results, we will use the scripts/initialize.mjs file. It gets triggered once the players finishes character creation, allowing you to insert any sort of transition and set up the beginning of the game.

The initialize.mjs is a very simple script, and must only export a single initialize function, as such:

scripts/initialize.mjs
export function initialize() {
  game.onCityEntered("stable-cavern", "demo-begin");
  game.worldmap.setPosition(150, 250);
  game.transitionRequired("intro.mp4", 1);
}

This scripts does three things:

  • The first line uses game.onCityEntered to load the stable-cavern level (corresponding to the tilemap stored in assets/tilemaps/stable-cavern.json). The second parameter demo-begin defines the zone in which to insert the player.
  • The second lines instantly move the character to a specific position on the worldmap.
  • The third lines starts a transition using game.transitionRequired. Transitions can be started at anytime during the game: the first paremeter specifies which video file to play (the path is relative to the assets/videos folder), and the second parameter defines how much game-world seconds should pass during the transition (in this case, only one second will pass in the game world while the player watches the video).

game is a global variable pointing to the main game object. It is available everywhere, and can be used at any time by scripts to move the player to another area, move ahead in time, or start playing a video.

1.3 Customizing the Game script

The Game script is located at scripts/main.mjs. It is bounded to the Game context, and as such, gets loaded whenever a game starts, and keeps running until the game is exited or over.

The following is the basic structure for all the Game, Level and DynamicObject scripts:

scripts/main.mjs
class Game {
   constructor(model) {
   }
   
   initialize() {
   }
}

export function create(model) {
  return Game(model);
}

All game-related scripts must export a create function. The create function takes a model parameter, representing the instance of the managed object. The function must return an instance to a JavaScript object.

All game-related scripts may implement an initialize method. While the constructor will be called everytime an object is instantiated, the initialize method will only be called once, after the first instantiation. Since game objects properties are automatically saved by the game engine, it is best to set their initial value here, if needed.

Note that the managed object (model) for the Game script is the main game object itself, meaning that in this context, model is equal to the game global variable.

1.4 Persisting data

Often, you will need to save data that will remain accessible as the player exits a level, save and load a game, and so on...
And the thing is, while properties on game objects* are always saved by the game engine, the properties that you set on your scripts aren't saved between multiple loads of your script.

Game objects are the native objects provided by the game engine itself. The parameter received by in the export function create(model) function is an example of game object.

To answer all your data saving needs, all game objects come with their own data store, meaning that you can save data globally, using the game global property. You can also save data on a specific level, using the level global property. And you can also save data on specific characters, items, objects of all kinds.

Using the data storage features is pretty easy: there are only three method names you need to memorize. We will present the three of those in the following snippet:

scripts/main.mjs
class Game {
   constructor(model) {
     this.model = model;
     if (this.model.hasVariable("loadCount")) {
       const loadCount = this.model.getVariable("loadCount");

       this.model.setVariable("loadCount", loadCount + 1);
       console.log(model, "load count is", loadCount);
     }
   }

   initialize() {
     this.model.setVariable("loadCount", 1);
   }
}

export function create(model) {
  return Game(model);
}
  • hasVariable allow you to check whether a variable has been saved before
  • getVariable returns the value stored in a variable
  • setVariable set the value of a variable

1.5 Scheduling a task

Let us add some action in that Game script: we will use the TaskManager to create a task that gets called every 5 seconds, and have the light zone we created earlier switch on and off:

export class Game {
  initialize() {
    game.tasks.addTask("myTask", 5000, 0);
  }

  myTask() {
    if (game.level) {
      const myLight = level.tilemap.getLightLayer("my-light");

      if (myLight) {
        myLight.visible = !myLight.visible;
        game.appendToConsole(myLight.visible ? "Lights on !" : "Lights off");
      }
    }
  }
}

export function create(model) {
  return new Game(model);
}

In this snippet, we use the game global object tasks property to access the object's TaskManager, and call addTask to schedule a new task.

  • The first parameter is the name of the game object script method I want to call in 5 seconds.
  • The second parameter is the time delay between each calls: it is in milliseconds, which means that for a 5 seconds delay, I need to pass a value of 5000
  • Finally, the third parameter is the amount of times the task should be triggered. A value of zero means the task will run indefinitely, or until it gets exlicitely removed.
In the myTask method, we then check that a level is currently running, we get a hold of our my-light light zone, created in section 1.5, and we toggle the light on and off.

Keep in mind that the TaskManager does not necessarily call a method from the script that calls addTask: it will try to call that method on the script of the object that owns the TaskManager.

Lastly, we call game.appendToConsole to display a message on the player's HUD console.

Now, here's the result of our new initialize.mjs and main.mjs scripts:

2. Cutie Mark Acquisition Program

2.1 Introduction to CMAP

CMAP is a sub-system of the game engine that handles character sheets. Each character includes a CMAP powered character sheet, accessible through the statistics property on Character game objects. It features a set of different values for:

  • Face, a set of informations used to draw the face of the character.
  • Characteristics, the strength, perception, endurance, charisma, intelligence, agility, luck values.
  • Statistics, a set of values computed from characteristics and other modifiers.
  • Skills, the level of mastery for each skills.
  • Perks, bonuses that can be gained sometimes after gaining a level.
  • Traits, which provide both bonuses and maluses, and can be selected during character creation
  • Race, which can be used to customize other values, and define which sprite group will be used to render the character.

All of these values can be consulted and modified by scripts. Additionally, traits, races and perks are script-based, and entirely customizable.

All the values can be directly read or overwritten from the character sheet object, accessing them using their full name in lower camelcase, such as:

character.statistics.agility
character.statistics.lockpick
character.statistics.armorClass
...

2.2 Experience and levels

Among other things, CMAP handles the experience and level of a character. You may manually grant experience to a character with the following code:

character.statistics.addExperience(25);

You can also read the current experience, and the experience left until the next level, using the following properties:

character.statistics.experience
character.statistics.xpNextLevel

When the next level is reached, the player gains skill points, and sometimes a perk, according to statistics.skillRate and statistics.perkRate, respectively, the amount of skill points gained by level, and the amount of level to pass before gaining a perk.

The current amount of skill points and perks available to a player can also be directly edited using the following properties:

character.statistics.skillPoints
character.statistics.availablePerks

2.3 Scripting traits

Traits scripts are located in scripts/cmap/traits. To add a new custom trait, let's create a new file in there:

scripts/cmap/traits/fast-learner.mjs
export function onToggled(characterSheet, toggled) {
  if (toggled)
    characterSheet.strength -= 3;
  else
    characterSheet.strength += 3;
}

export function modifyBaseStatistics(characterSheet, name, value) {
  if (name == "skillRate")
    return value + 10;
  return value;
}

export function modifyBaseSkill(characterSheet, name, value) {
  return value + 25;
}

In this script, we exported all the functions available to traits:

  • onToggled is called when the trait is added or removed
  • modifyBaseStatistics is called for each statistic value when the base values are being re-computed
  • modifyBaseSkill is called for each skill value when the base values are being re-computed

Base values are computed using SPECIAL points, traits and race. Base values get updated everytime SPECIAL, traits or race are modified.

In our onToggled function, we decrease the character's strength on activation, and increase it on deactivation.

Our modifyBaseStatistics function increase the character's skillRate by ten, and leave other statistics as is.

And our modifyBaseSkill function increases each of the character's skill by 25 points.

2.4 Scripting perks

Perk scripts are located in scripts/cmap/perks. We will now create a simple perk with the following code:

scripts/cmap/perks/too-cool-for-school.mjs
export function onToggled(characterSheet, toggled) {
  const modifier = toggled ? 1 : -1;

  characterSheet.charisma += (1 * modifier);
  characterSheet.speech   += (10 * modifier);
}

export function isAvailableFor(characterSheet) {
  return characterSheet.level > 2;
}

You already know the onToggled function, as it is the same as for Traits scripts. In this perk example, we use it to modify the character's charisma characteristic and speech skill. Note that modifyBaseSkill and modifyBaseStatistics are also available in perks.

The important part here is isAvailableFor. Perks can only be picked if certain conditions are met, and this function will be called everytime the perk picking screen appears to figure out if your perk will be part of the options available to the player. In this script, we ensured that the perk wouldn't be available until the player reaches level 3.

2.5 Scripting races

Races scripts are located in scripts/cmap/races. Let's create a new race script:

scripts/cmap/races/ghoul.mjs
export const isPlayable = false;

export const spriteSheet = "earthpony-green";

export function onToggled(characterSheet, toggled) {
  const modifier = toggled ? 1 : -1;

  characterSheet.endurance -= (1 * modifier);
  characterSheet.strength  -= (1 * modifier);
}

Let's go over the exported values from that script:

  • isPlayable defines whether the race can be selected by a player on character creation.
  • spriteSheet defines the sprite group containing the character's animation.
  • onToggled, as with other CMAP scripts, allows you to make changes to the character sheet when a character race changes.

What if a same race should use several sprite groups ? Neverfear, you can also export spriteSheet as a function, such as:

export function spriteSheet(characterSheet) {
  if (characterSheet.gender == "male")
    return "ghoul-male";
  return "ghoul-female";
}

3. Quests

3.1 Creating a quest

Quests are basically a set of objective that the player can achieve or fail to achieve. Quest scripts are located in the scripts/quests folder. Let's create our own quest:

scripts/quests/myQuest.mjs
class MyQuest {
  constructor(model) {
    this.model = model;
  }
  
  initialize() {
    game.appendToConsole("You picked up a new quest !");
  }
  
  getObjectives() {
    return [
      {label: "Objectif lune", success: false},
      {label: "Failed objective", failed: true },
      {label: "Completed objective", success: true }
    ];
  }
}

export function create(model) {
  return new MyQuest(model);
}

Quest scripts are similar to other game object scripts. We added the getObjectives method to return a list of objectives to be displayed to the user. Objectives have three possible state: pending, failure or success (presented in the same order in our method).

3.2 Managing player quests

Now that we can create our own quests, we should know how to add these quests to the player's quest log. We will see how to add a quest, using the initialize.mjs script as an example:

scripts/initialize.mjs
export function initialize() {
  game.quests.addQuest("myQuest"); // <-- here we go
  game.onCityEntered("stable-cavern", "demo-begin");
  game.worldmap.setPosition(150, 250);
  game.transitionRequired("intro.mp4", 1);
}

Here, we use the QuestManager, accessbile using game.quests to add our myQuest quest to the player's active quests. The quest manager can also be used to fetch a specific quest object, or iterate over all active quests, using respectively game.quests.getQuest("myQuest") and game.quests.list.

9.3 Completing objectives

Now, how would we go around completing our objectives ? Well, this is mainly left to the script to decide. First, we must implement the completeObjective method:

scripts/quests/myQuest.mjs
class MyQuest {
  constructor(model) {
    this.model = model;
  }
  
  completeObjective(objectiveName) {
    this.model.setVariable(objectiveName, 1);
    this.model.completed = true;
  }
  
  getObjectives() {
    return [
      {label: "Objectif lune", success: this.model.getVariable("myObjective") == 1}
    ]
  }
  
  onCompleted() {
    game.player.statistics.addExperience(1000);
    game.appendToConsole("You gained 1000 experience points for completing MyQuest");
  }
}

In our completeObjective, we persist the state of our objective using setVariable on the quest object. Since our quest only includes one objective, we also set the completed property to true on the quest object.

We also added the onCompleted method, which gets called whenever this.model.completed goes from false to true.

We know how to react to an objective getting completed. Now, here's how you can trigger an objective completion from scripts:

game.quests.getQuest("myQuest").completeObjective("myObjective")

3.4 Watchers

We know how to complete a quest objective from another script. But sometimes, it's not most convenient way to monitor quest objectives. Quest scripts can also implement watchers, to be warned when some events happen in the game.

3.4.1 Item picking watcher

This watcher gets triggered whenever the player picks an item up. Here's an example:

class MyQuest {
  ...
  
  onItemPicked(item) {
    if (item.itemType == "someQuestItem") {
      this.model.setVariable("itemPicked", 1);
      this.model.completed = true;
    }
  }
}
3.4.2 Character killed watcher

This watcher gets triggered whenver a character gets killed, including if the killer wasn't the player, or the character didn't die in combat. Here's an example:

class MyQuest {
  ...
  
  onCharacterKilled(victim, killed) {
    if (victim.race == "mutated-rat") {
      const killCount = (this.model.getVariable("ratKilled") || 0) + 1;
      
      this.model.getVariable("ratKilled", killCount);
      if (killCount > 10)
        this.model.completed = true;
    }
  }
}

4. Buffs

4.1 Introduction

Buffs are bonuses or maluses that can be added to a Character. Buffs themselves are game objects, and the scripts are located at scripts/buffs. They feature a TaskManager, allowing for functions methods to be called regularly, and eventually remove the buff once it has expired.

Buffs on a character are visible in the status panel in the character sheet view, below the Hit points display:

4.2 Adding and removing buffs

Adding a buff on a character is fairly easy. The name of a buff is defined by the name of it scripts. To add the buff defined in scripts/buffs/bleeding.mjs, we would do the following:

characters.addBuff("bleeding");

The bleeding buff is a tick-based buff, meaning that once it runs off of ticks, the buff will remove itself. But what if we wanted to remove it, for another reason ? We will first need to fetch the buff game object, before removing it:

const buff = characters.getBuff("bleeding");

if (buff)
  buff.remove();

4.3 Creating a buff

Now that we know how to apply or remove buffs from a character, let's see how to create our own custom buffs. Let's make a blinded buff, reducing the character's perception:

scripts/buffs/blinded.mjs
class Blinded {
  constructor(model) {
    this.model = model;
  }
  
  initialize() {
    this.model.target.statistics.perception -= 5;
  }
  
  finalize() {
    this.model.target.statistics.perception += 5;
  }
}

export function create(model) {
  return new Blinded(model);
}

As you can see, like other game objects, buffs have an initialize method, whcih gets executed when the buff is applied on the player. But buffs also have a finalize method, which gets called as the buff gets removed from the character.

Currently, our buff will remain on the character indefinitely, until another script manually removes it. If we want our buff to expire, we can leverage the TaskManager:

scripts/buffs/blinded.mjs
class Blinded {
  constructor(model) {
    this.model = model;
  }
  
  initialize() {
    this.model.target.statistics.perception -= 5;
    this.tasks.addTask("expire", 86400000, 1);
  }
  
  finalize() {
    this.model.target.statistics.perception += 5;
  }
  
  expire() {
    this.model.remove();
  }
}

export function create(model) {
  return new Blinded(model);
}

We now schedule a call to our expire method, one day after the buff gets applied. Then, in the expire method, we call our game object's remove method, which will in turn call the finalize method and remove the buff from the character.

4.4 Cumulating buffs

There is one important fact to know about buffs: there cannot be several instance of a same buff on the same player at a given time. However, it is possible to cumulate buffs using a single buff game object. We will now see what happens when you add a buff to a character, when an instance of the same buff is already being applied on a character.

If you were to call character.addBuff("blinded") twice, it's initialize method will only be called once. However, if you provide a repeat method, it will get called instead. Let's see how this might be implemented:

scripts/buffs/blinded.mjs
  repeat() {
    this.tasks.removeTask("expire");
    this.tasks.addTask("expire", 86400000, 1);
  }

In our repeat method, we first unschedule the call to our expire method, and re-schedule it to one day in the future. This means applying the buff two times on a character doesn't change anything, but ensure that the buff won't expire until one day after the last addition, rather than one day after the first addition.