ChronicleHub: a QBN engine like StoryNexus

The docs are actually still available. Posting for general interest/posterity and not suggesting anyone needs to exactly replicate SN.

I created Final Girl in Storynexus for IFComp back in the day and it was designed as a shorter confined experience instead of an epic. Playthroughs could take 2-6 hours.

You can definitely do plot in QBN with qualities that dictate what options are available at any given point, but at some point if you want a strict linear flow a QBN might not be the right format. You can design open world gameplay with progressive narrative cutscenes that provide context. But as stated - a QBN is basically a database of all kinds of random stuff that presents itself piecemeal based on the player’s interaction with it.

CH definitely has stronger character creation utilities than Storynexus did - characters essentially break down to a type of quality and CH makes that distinction for categorization purposes and includes some extra options I haven’t investigated yet.

Everything in a QBN is a quality and what the interface helps with is “types” of quality - how each type behaves and how it is displayed.

One of my first cool impressions is CH allows “Storylets” which are always available if the player qualifies for them (such as for travel locations) and “opportunities” which are like cards that can be drawn from virtual decks and kept in the players “hand” for randomness in events and gameplay.

In my experience with SN and QBN systems, you kind of have to think in terms of “how would this narrative be accomplished as a boardgame with spaces, tokens, and text on cards that can be drawn?” The quality structure dictates rules and gameplay.

5 Likes

I’m glad you picked up on that! I still need to rework some logic for the character creator (like allowing the user to toggle whether a quality should be shown in the character creation menu or not, even if they can’t actually modify it, or implement modular character portraits), but it’s definitely been the intent to make as much of the system defaults as customisable as possible.

To that end, I have also tried to maintain a number of unique properties for storylets, allowing them to behave in non-conventional ways, or make it so all system default variables (can) resolve back to qualities, so they can be directly modified and affected by the parser (so for example, your action regeneration timer could be made dependent on how wounded you are: Regen Rate (Minutes) = 5 + $wounds)

Because I now also see questions about more linear narratives, I have made sure to visualise the redirect logic in the new Narrative Graph, which also allows you to add and directly link storylets:

Though of course, there is also still the logic for doing this with qualities:

Especially the latter one with qualities can be useful, as it allows you to automatically increment the next level of whatever progress quality you are using by 10, which mirrors the way long form stories in Fallen London are tracked.

2 Likes

I really appreciated your explanation how a QBN is a ‘bag of marbles’ - if the player’s health is below 5, the “hospital” marble (Storylet) is removed from the bag and available for interaction. If the player has drawn the “random stranger” card (opportunity) so the randomStranger quality is >0, the “visit the random stranger” marble is removed from the bag and available for interaction.

As I said, I love this separation between decks of opportunity cards and plain Storylets, which sort of mimic the “persistent” cards in SN. There are interactions always available, but a deck can be offered for a random draw to shake up the plot and also “stacked” to the author’s whim.

1 Like

It might please you to know I have since updated the documentation to be far more comprehensive, including design notes from Storychoices: Chronicle Hub

2 Likes

@Randozart hey I like to be whitelisted so I can join I sent you my email address in a dm

:open_mouth:

If the player hits that logic again, does the 4 hours reset, or is the timer static?

1 Like

I’m also rather confused by this part:

So $stat >= 50 doesn’t actually compare the stat to 50? How does the parser know if it’s a skill check or just a comparison like in the Logic chapter?

1 Like

I should probably clarify that! It’s a one time scheduled quality change. So, in that example, after 4 hours the scheduled change happens, and you’d need to schedule a new change if you want to follow up.

This is a good question, and I realised this makes complete sense in my head, but it’s not actually written on the page.

‘>=’ Means that increasing the operator is a thing that increases your chance at success. ‘<=’ Means you decrease the chance. Good if you want to make being better at something a liability, for example.

The fact it’s written in the challenge field ecaluates it as a skill check

Ah, okay. So what’s the actual formula for calculating chance of success? I’m afraid I find all the examples in that chapter rather confusing.

That aside, though, this looks like a great system; you’ve clearly thought through several different use cases and provided tools to work for them all.

1 Like

Thanks for telling me this, it does seem this needs further clarification. I’ll pull up the formula and edit it into this message once I’m at my PC.

The basic idea is that your operator (a quality level or qualities added together, subtracted, etc.) is compared to the target, which is the operandi.

If the operator is equal to the target, you have 60% chance to succeed (or whatever you overwrote this with).

In a standard >= check, you have 0% chance at target - margin, and 100% chance at target + margin.

If the operator is less than the target, but above target - margin, you get a value between these two. So for a default comparison of 25 >= 50, you would have a roughly 30% chance at success (half the pivot, because it sits halfway between target - margin, and the target)

Edit: The actual function:

private performSkillCheck(match: RegExpMatchArray): SkillCheckResult {
        const [, qualitiesPart, operator, targetStr, bracketContent] = match;
        
        const target = parseInt(this.evaluateBlock(targetStr), 10);

        let margin = target; // Default margin = target (Narrow difficulty)
        let minChance = 0;
        let maxChance = 100;

        if (bracketContent) {
            const args = bracketContent.split(',').map(s => s.trim());
            
            // Resolve Margin (Arg 0)
            if (args[0]) {
                const val = this.evaluateBlock(args[0]);
                margin = parseInt(val, 10);
            }
            
            // Resolve Min Chance (Arg 1)
            if (args[1]) {
                const val = this.evaluateBlock(args[1]);
                minChance = parseInt(val, 10);
            }

            // Resolve Max Chance (Arg 2)
            if (args[2]) {
                const val = this.evaluateBlock(args[2]);
                maxChance = parseInt(val, 10);
            }
        }

        const skillExpression = qualitiesPart.replace(/\$([a-zA-Z0-9_]+)/g, (_, qid) => this.getQualityValue(qid).toString());
        const skillLevelResult = evaluateSimpleExpression(skillExpression);
        const skillLevel = typeof skillLevelResult === 'number' ? skillLevelResult : 0;
        
        const lowerBound = target - margin;
        const upperBound = target + margin;

        let successChance = 0.0;
        if (skillLevel <= lowerBound) {
            successChance = 0.0;
        } else if (skillLevel >= upperBound) {
            successChance = 1.0;
        } else {
            if (skillLevel < target) {
                const denominator = target - lowerBound;
                if (denominator <= 0) successChance = 0.5;
                else {
                    const progress = (skillLevel - lowerBound) / denominator;
                    successChance = progress * 0.5;
                }
            } else { 
                const denominator = upperBound - target;
                if (denominator <= 0) successChance = 0.5;
                else {
                    const progress = (skillLevel - target) / denominator;
                    successChance = 0.5 + (progress * 0.5);
                }
            }
        }
        
        if (operator === '<=') {
            successChance = 1.0 - successChance;
        }

        let finalPercent = successChance * 100;
        
        finalPercent = Math.max(minChance, Math.min(maxChance, finalPercent));
        
        const targetPercent = Math.round(finalPercent);
        const rollPercent = Math.floor(Math.random() * 101); // 0-100
        const wasSuccess = rollPercent <= targetPercent;

        return {
            wasSuccess,
            roll: rollPercent,
            target: targetPercent,
            description: `Rolled ${rollPercent} vs ${targetPercent}% (Clamped: ${minChance}-${maxChance}%)`
        };
    }
1 Like

I might suggest a syntax that doesn’t look the same as the normal comparison operator, then—though I’m not sure what that syntax would be. Since you’re not using bitwise operations, perhaps >> and << are available?

I see the current logic, but I know I’m going to frequently mess up $stat >= $value meaning totally different things in the “unlock” field and the “challenge” field.

1 Like

Good point. I was also forced to reread the function just now, and realised it doesn’t work quite as I want it to or have just explained, so I’m going to need to make some adjustments. My current plan is to use >> for progressive, << for regressive and = for “get as close to the target number as possible for 100%”, which is another application I realised this could have.

I’ll also add some graphs to the documentation.

2 Likes

I made sure to fix the formula just now, updated the documentation, and added an interactive graph to help visualise the logic:

4 Likes

So a storylet “plant the seed” sets a variable to change 24 hours from when the storylet is navigated and that’s a hard timer - even if the author forgot to disqualify and the player chose “plant the seed” 12 hours later again - the variable timer will still count down 12 more hours instead of resetting to 24, correct?

The doc seemed to imply you could make a whole game out of living stories, so I assume there’s not just one “world variable” timer. Let me know if I’m inferring incorrectly!

1 Like

The way I’ve programmed this, is that the character has an array of scheduled quality changes in the database. Whenever the page is then refreshed in some way, it checks to see if any quality changes qualify and performs the change.

However, I had not thought through the specific use case you are describing, so there may be unintended logic triggering I had not thought about.

1 Like

Probably not worth sweating over since this is only the weird kind of thing I would do.

A Watched Pot Never Boils

You know it takes a while for water to boil, so you set the kettle and resolve to give it some space.

$schedule[$waterboiling = 1 : 8m]

Look at the kettle. $schedule[$waterboiling = 1 : 8m] again
Be a grown up and do other things.

There’s also always the Lost gambit.

Boiler Room

You must release the boiler pressure once per day or else the hotel will explode,” Stuart Ullman told you so long ago during the interview.

Dump the boiler pressure. $schedule [$fireydemise = 1 : 24h]

I realize there’s other ways to make this happen without a resetting chess-clock sort of thing of course.

3 Likes

This is absolutely worth sweating over, because your examples look like absolutely hilarious narrative mechanics!

I’ll investigate tomorrow, and also whether you could achieve the boiler example using a $cancel[…] followed immediately by a $schedule[…] command, or whether it would be safer to introduce a $reset[…] keyword.

Speaking of, maybe a $recur[…] keyword is worth considering, as well as some way to set quality change descriptions or inspect whether quality changes have been scheduled. Maybe also inspect what item is equipped in a specific category slot and access properties of that?

I have been developing this at such a breakneck speed I am admittedly a little foggy on logic I haven’t explicitly considered :joy:

2 Likes

The idea of real-timed variable changes is a cool one. Your options sound good, although I could suggest that either a timed variable might be something with a checkbox or a toggle switch in its creation panel to set it as “resettable/persistent” or maybe an inline argument?

$schedule [$waterboiling = 1 : 8m | reset]
$schedule [$seedgrows = 1 : 24h | static]

Maybe have “static” be the default as you seem to have planned and the “reset” option only applies if the author sets it.

But it would work just as well if you have a $cancel option for scheduled variables.

$waterboiling < 1 : $cancel $waterboiling, $schedule [$waterboiling = 1 : 8m]

I’ve likely goofed up the coding from memory but like that.

Man, I really shouldn’t start another new project until at least one of my WIPs is finished, but this is giving me so many ideas…

@Randozart so are you going to make an example game on it or are you waiting for others to do it .