Twine

Smarter Stat Changes in Twine/SugarCube Games

Changing a stat in a Twine/SugarCube game is simple on paper but often complicated in practice.

If your game is very simple, you can do something like this:

<<set $stat -= 5>>

If you want to get fancy, you can even prevent the stat from becoming less than 0 by doing this:

<<set $stat to Math.max($stat - 5, 0)>>

This works fine until you add something like an inventory passage that the player can visit at any time. When they come back to the passage with the stat change, the change will happen again.

One way to prevent this is to do the following:

<<if visited() is 1>>
   <<set $stat -= 5>> 
<</if>>

Again, this works if your game is structured in a straightforward way, but it stops working once you introduce some <> macros that reveal passage content after some interaction.

If this interaction happens after you’ve visited the inventory passage and come back, then the visited value won’t be 1 anymore… it will be 2 or more.

To solve this problem, I’ve developed a more robust system for handling stat changes in Twine/SugarCube games. This system ensures that stat changes occur only once, regardless of how many times a player visits a passage or interacts with certain elements. Here’s how it works:

We create a custom widget called statChange to manage these stat changes:

<<widget "statChange">>
    <<if ndef $statChanges>>
        <<set $statChanges to {}>>
    <</if>>
    <<if not $statChanges.hasOwnProperty(_args[0])>>
        <<set _tempStatValue to 0>>
        _args[1]
        <<set $statChanges[_args[0]] to _tempStatValue>>
    <</if>>
<</widget>>

Let’s break down the statChange widget line by line:

This checks if the $statChanges variable is not defined. If it’s not, it initializes it as an empty object. This object will store all the stat changes that have occurred:

<<if ndef $statChanges>>
    <<set $statChanges to {}>>
<</if>>

This checks if the $statChanges object doesn’t have a property with the name of the first argument passed to the widget (which should be the name of the stat being changed):

<<if not $statChanges.hasOwnProperty(_args[0])>>

If the stat hasn’t been changed before, this initializes a temporary variable _tempStatValue to 0:

<<set _tempStatValue to 0>>

This executes the macro:

_args[1]

This records the stat change in the $statChanges object, using the stat name as the key and the change value as the value:

<<set $statChanges[_args[0]] to _tempStatValue>>

We modify our stat-changing widgets to work with this system. For example, here’s one widget from MindWare:

<<widget "moreFeminine">>
    <<set _amount to _args[0]>>
    <<set _playerGender to State.variables.playerGender>>
    <<set _newGender to Math.max(_playerGender - _amount, 0)>>
    
    <<if _newGender == _playerGender>>
        <div class="gender-change"><span class="gender-decrease">♀️ Your feminine gender identity is already at its minimum.</span></div><br>
    <<else>>
        <<set State.variables.playerGender to _newGender>>
        <<set _pointText to _amount == 1 ? "1 point" : (_amount + " points")>>
        <div class="gender-change"><span class="gender-decrease">♀️ Your gender identity has become more feminine by <<print _pointText>>.</span></div><br>
    <</if>>
    <<set _tempStatValue to _amount>>
<</widget>>

To use this system, we simply call the statChange widget with a unique identifier and the stat-changing widget:

<<statChange "ugly_bastard_quest_2_AVA_submit" "<<moreFeminine 5>>">>

This system solves several problems:

  1. It ensures that each specific stat change occurs only once, even if the player revisits the passage multiple times.
  2. It works with <<replace>> macros and other dynamic content, as it’s not dependent on the visited() count.
  3. It allows for more complex stat-changing logic within the widgets, including checks for minimum/maximum values and appropriate feedback messages.
  4. It provides a way to track which specific stat changes have occurred, which can be useful for game logic or debugging.

By using this system, you can create more complex and dynamic games without worrying about unintended repeated stat changes.

MindWare’s New Loading Screen and Why It’s Important

I’ve recently implemented a loading screen for an upcoming version of MindWare, and I would like to take a few moments to explain why and share the solution with you.

The Problem

One of the biggest and most persistent issues I’ve been dealing with (responsible for most bug reports I’ve received so far) has been missing or improperly initialized variables when starting a new game or loading a save. This can cause all sorts of weird behavior and frustrating bugs for players.

When a person with limited understanding of how the Twine engine works and virtually no experience with the creation of larger interactive fiction games sets out to create a game like MindWare, they may start by placing important variables in the StoryInit passage. All variables placed in this passage are initialized when you start a new game.

The same inexperienced developer may then add additional variables to the StoryInit passage, such as variables for a new part of the UI, and release a new version of their game. You then load your old save and are greeted with an error message because your save is missing the newly added variables. To fix the error, you would have to start a new game—not desirable at all.

The Solution

I’m not the first person in the world to have dealt with this problem (see here and here, for example), so my solution isn’t something groundbreaking, but I think that it’s polished and reusable enough that others might benefit from it. I also hope to receive feedback from those how are more knowledgable than me.

How it works:

  1. When a new game starts or a save is loaded, a function is called.
  2. This function navigates to a special variable initialization passage.
  3. The variable initialization passage displays a loading screen and includes passages with variables to be initialized (being able to divide variables in to different passages makes it easier to stay organized).
  4. A simulated loading process updates the progress bar.
  5. Once “loading” is complete, the player is returned to either the previous passage (for loaded games) or the Start passage (for fresh playthroughs).

With this solution, I’m able to load variables (most of them anyway) that I have been very generously collected by a Discord user and MindWare supporter UnwrappedGodiva (thank you again!). These variables provide a solid starting point on which I can expand. More importantly, I now have a way to declare future global variables in a way that won’t break old saves.

The Code

This script sets up the logic for when to run the initialization process:

:: Scripts-StartingVariables [script]
// Initialize a flag to track whether StartingVariables has run
setup.startingScriptsRun = false;

// Function to run StartingVariables
setup.runStartingVariables = function() {
    if (!setup.startingScriptsRun) {
        State.variables.previousPassage = State.passage;
        setup.startingScriptsRun = true;
    } else {
    }
};

// Run for new games
$(document).one(':passagestart', function (ev) {
    if (ev.passage.title === "Start") {
        setTimeout(setup.runStartingVariables, 0);
    } else {
    }
});
// Alternatively, you can delete the block of code above and call the runStartingVariables function directly from the Start passage like this:
// <<run setTimeout(setup.runStartingVariables, 0); >>

// Run for loaded games
Save.onLoad.add(function () {
    setup.startingScriptsRun = false;
    setTimeout(setup.runStartingVariables, 0);
});

This is the actual loading screen passage:

:: StartingVariables 
<<nobr>><div id="loading-screen">
  <div class="loading-content">
    <div class="loading-bar-container">
      <div id="loading-bar"></div>
    </div>
    <div class="loading-text">INITIALIZING...</div>
  </div>
</div><</nobr>>

// Include as many passages with variables as you want here
// You can also add your variables directly here 
<<include "UI Variables">>

<script>
(function() {
    // Simulate loading process
    var progress = 0;
    var loadingBar = document.getElementById('loading-bar');
    var loadingInterval = setInterval(function() {
        progress += Math.random() * 10;
        if (progress >= 100) {
            progress = 100;
            clearInterval(loadingInterval);
            setTimeout(function() {
                // Return to the previous passage
                SugarCube.Engine.play(SugarCube.State.variables.previousPassage || "Start");
            }, 500);
        }
        loadingBar.style.width = progress + '%';
    }, 100);
})();
</script>

A passage with variables to load:

:: UI Variables
<<if ndef $scrollToTopEnabled>>
    <<set $scrollToTopEnabled to true>>
<</if>>

The CSS code for the loading screen:

#loading-screen {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: #000;
    z-index: 9999;
    display: flex;
    justify-content: center;
    align-items: center;
}

.loading-content {
    width: 80%;
    max-width: 600px;
}

.loading-bar-container {
    width: 100%;
    height: 20px;
    background-color: #111;
    border: 2px solid #55BBBC;
    box-shadow: 0 0 10px #55BBBC;
}

#loading-bar {
    width: 0;
    height: 100%;
    background-color: #55BBBC;
    transition: width 0.3s ease;
}

.loading-text {
    color: #55BBBC;
    font-family: 'Orbitron', sans-serif;
    font-size: 24px;
    text-align: center;
    margin-top: 20px;
    text-shadow: 0 0 10px #55BBBC;
}

If you’ve noticed any mistakes or areas that could be improved, please share them with me here or on Discord. Any feedback I receive is greatly appreciated.

SugarCube Custom Video Macro

This custom macro will help you embed videos of various formats such as .mp4, .webm, .mov, .ogg, and .avi in your Twine stories using the SugarCube story format.

Macro.add('video', {
    handler: function () {
        if (this.args.length < 1) {
            return this.error("The video macro requires a path argument.");
        }

        const videoPath = this.args[0];
        const videoWidth = this.args.length > 1 ? this.args[1] : '100%';
        let videoType;

        // Determine the video type based on the file extension
        if (videoPath.endsWith('.mp4')) {
            videoType = 'video/mp4';
        } else if (videoPath.endsWith('.webm')) {
            videoType = 'video/webm';
        } else if (videoPath.endsWith('.mov')) {
            videoType = 'video/mp4';
        } else if (videoPath.endsWith('.ogg')) {
            videoType = 'video/ogg';
        } else if (videoPath.endsWith('.avi')) {
            videoType = 'video/x-msvideo';
        } else {
            return this.error("Unsupported video format.");
        }

        const videoHTML = `<video width="${videoWidth}" autoplay loop muted>
                              <source src="${videoPath}" type="${videoType}">
                              Your browser does not support the video tag.
                           </video>`;

        new Wikifier(this.output, videoHTML);
    }
});

I use this macro to include videos that play automatically in a continuous loop while muted (just like GIFs do). You can easily create other versions of this macro that behave slightly differently by editing the “videoHTML” constant. For example, you can remove the “muted” attribute to play videos with sound and save the modified macro as “video-sound” or something similar.

Alternatively, you could modify it so that it behaves more like the HTML <video> tag by default and accepts the same attributes as additional parameters:

Macro.add('video', {
    handler: function () {
        if (this.args.length < 1) {
            return this.error("The video macro requires a path argument.");
        }

        const videoPath = this.args[0];
        let videoWidth = '100%';
        let videoType;
        let additionalAttributes = [];

        // Check if the second argument is a width or a video attribute
        if (this.args[1] && !this.args[1].includes('=')) {
            if (!this.args[1].includes('autoplay') && !this.args[1].includes('loop') && !this.args[1].includes('muted') && !this.args[1].includes('controls')) {
                videoWidth = this.args[1];
                additionalAttributes = this.args.slice(2);
            } else {
                additionalAttributes = this.args.slice(1);
            }
        } else {
            additionalAttributes = this.args.slice(1);
        }

        // Determine the video type based on the file extension
        if (videoPath.endsWith('.mp4')) {
            videoType = 'video/mp4';
        } else if (videoPath.endsWith('.webm')) {
            videoType = 'video/webm';
        } else if (videoPath.endsWith('.mov')) {
            videoType = 'video/mp4';
        } else if (videoPath.endsWith('.ogg')) {
            videoType = 'video/ogg';
        } else if (videoPath.endsWith('.avi')) {
            videoType = 'video/x-msvideo';
        } else {
            return this.error("Unsupported video format.");
        }

        const additionalAttributesStr = additionalAttributes.join(' ');

        const videoHTML = `<video width="${videoWidth}" ${additionalAttributesStr}>
                              <source src="${videoPath}" type="${videoType}">
                              Your browser does not support the video tag.
                           </video>`;

        new Wikifier(this.output, videoHTML);
    }
});

How to Use the Macro

First, add the Macro to your Story JavaScript passage. You can then use the macro in any passage like this:

:: Start
Welcome to my interactive story!

<<video "path/to/your/video.mp4">>

The width of the video is set to 100% by default, but you can manually overwrite the default setting by entering the width as an additional argument.

<<video "path/to/your/video.mp4" "50%">>

If you’ve decided to go with the second version of the macro, the one that works more like the HTML <video> tag, then you can do something like this:

<<video "videos/intro.mp4" autoplay loop muted controls>>

or this:

<<video "videos/intro.mp4" "75%" autoplay loop muted controls>>

Supported Video Formats

This macro supports the following video formats:

  • .mp4 with MIME type video/mp4
  • .webm with MIME type video/webm
  • .mov with MIME type video/mp4
  • .ogg with MIME type video/ogg
  • .avi with MIME type video/x-msvideo

Alternative Notification Macro for SugarCube

To display notifications in MindWare, I have created an alternative version of ChapelR’s excellent Notify macro. The new macro differs from the original in two important ways:

  1. Close button: In my version, notifications don’t close automatically. Instead, the user has to click a close button. This is to prevent situations where the player doesn’t have enough time to read the information in the notification, especially in cases where the content is crucial to the gameplay or story.
  2. Multiple notifications: Unlike the original macro, which replaces the existing notification with a new one, my macro supports the display of multiple notifications at once. All displayed notifications can then be dismissed with just one click on the close button.

Here’s an example of the notification macro being used in MindWare (version 0.0.2):

Continue Reading

How to Avoid JavaScript Scope Issues With Twine 2 (and SugarCube)

You’re using Twine 2 with the SugarCube story format for your interactive fiction story, but JavaScript is throwing curveballs your way. Functions that work like a charm elsewhere mysteriously trigger cryptic error messages. The culprit? JavaScript scope.

Let Your Functions Mingle

The Story JavaScript area, any JavaScript file in the compile path of a Twee notation project, or code written within SugarCube’s <<script>> tags operates in a “Private Scope.”

Imagine a private scope in JavaScript like a private room in a house. It’s a space where everything you put inside (your functions and variables) is only available to those who are inside the room. This privacy sounds great for security, but not so much when you want your code to mingle with other parts of your Twine story.

Continue Reading
Scroll to top