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.

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