Bardic: a Python-first Interactive Fiction engine for complex game state with visual graph-based story editing and live passage preview

The bigger issue is using names (mainly “list” and “thread”) that already mean a completely different thing in every other programming language.

2 Likes

Congratulations, I think this looks really promising! I like that it gives you a real programming language to do complex things but also custom syntax for when Python would feel clumsy.

Personally, I use gathers all the time when writing Ink. In my opinion they are the main strength of Ink (and other similar languages: I believe ChoiceScript has a similar feature) because you do not have to create tons of named knots. But it really depends on the type of game one is making. It’s really good for games in the Choice of Games style where the story always moves forward and there are lots of choices that don’t really do anything except printing a different flavor text and maybe changing a stat from time to time. Also the whole sub-choices thing that lets you create short branches without, again, having to create named knots. And Ink even allows you to jump into the middle of a text block with a named gather which is also really handy.

So basically it’s good for games where you have lots of choices and lots of short text passages but the flow between the passages is still hard-coded by the story author (not a more flexible, complex flow like with storylets).

What Ink is dearly missing, on the other hand, is some kind of “do this every turn“ functionality. Using Ink threads to simulate that is kind of a crutch, in my opinion.

Hi again! Just a quick update, in case you were interested. I’ve added in the ability (via local Pyiodide/WASM) to bundle Bardic games to HTML5 format, which a person could either leave unzipped for development testing or use the --zip flag on bardic bundle to zip up into a file that’s uploadable to itch.io’s Play in Browser mode. External hosting no longer required!

It’s still in v1, so the UI of the game is customizable in terms of styling (and has some built-in themes) but it’s a bit static. In v2 I’d like to allow users to BYO frontend (React, etc.) and have that included into the final file as well so it’s fully editable.

You can see the little example game I used to test out the distribution here: [Tester] The Coffee Shop by katehlouie

10 Likes

This is definitely exciting! The demo game loaded fine on my phone; what’s the download size like? I always think of bundling Python as a huge endeavour but I think the tech has got better in that regard since I last properly paid attention.

Great work on getting the HTML version working. It’s works fine on my browser and also on mobile. However, i would say, it’s a bit slow loading, which is something you should look at. On mobile it’s very slow obviously.

I read your docs, which are very well done. I would like to discuss a number of design concepts:

Python

I’m wondering if you’re carrying too much baggage with Python. Is it possible to make a cut-down python core? I’m assuming it’s the python that makes the HTML/WASM large and slow to load.

I’ve always found python a bit bloaty. Or perhaps I’m wrong about this in how you’re using it.

Quick Start didn’t work

1,2,3,4 compile ok. Run failed;

I:\tmp\my-game>python player.py
<frozen importlib._bootstrap>:488: Warning: Numpy built with MINGW-W64 on Windows 64 bits is experimental, and only available for
testing. You are advised not to use it for production.

CRASHES ARE TO BE EXPECTED - PLEASE REPORT THEM TO NUMPY DEVELOPERS
C:\python3\Lib\site-packages\numpy\core\getlimits.py:225: RuntimeWarning: invalid value encountered in exp2
  epsneg_f128 = exp2(ld(-113))
C:\python3\Lib\site-packages\numpy\core\getlimits.py:226: RuntimeWarning: invalid value encountered in exp2
  tiny_f128 = exp2(ld(-16382))
C:\python3\Lib\site-packages\numpy\core\getlimits.py:240: RuntimeWarning: invalid value encountered in exp2
  eps=exp2(ld(-112)),
C:\python3\Lib\site-packages\numpy\core\getlimits.py:41: RuntimeWarning: invalid value encountered in nextafter
  self._smallest_subnormal = nextafter(
C:\python3\Lib\site-packages\numpy\core\getlimits.py:52: RuntimeWarning: invalid value encountered in log10
  self.precision = int(-log10(self.eps))

This is the bane of python unfortunately. Most python projects do not work for me because it likes to throw everything into the same global space. And if something does actually work, it often gets broken when i install something else.

backends and frontends

I think your concept of “backend” is my concept of “frontend”.

So I have a “frontend” and a “backend”. For me, the backend is the actual game, which runs in an isolated bubble and the frontend is the UI.

As i understand it, your “render” directives send information to a “backend” which renders additional stuff. I don’t know if your non-backend also does UI presentation, eg text?

For me, the “backend” does not present anything, has no access to; files, UI, network, etc. All it does is talk to the frontend. My “frontend” does all display of text, graphics, sounds etc. It collects input and sends it to the backend. my game backend has no clue what machine it is running on.

render directives

I’m thinking your @render directives have not been sufficiently thought through?

@render character_portrait(client, emotion='worried')

The idea that the game can issue high level character info such as “who” and “emotion” to a backend (frontend?) means this other end has to know a lot about the game. At least what pictures to show and how to do it.

I’ve seen that you have to write these handlers in your backend (my frontend). But i think that means you basically have to write a new backend for every game with different render directives. And these will get complicated.

So in my separation, my “frontend” (your backend) has no clue whatsoever about the game it is running. none at all. Only the game knows about the game. I have the same code for all games.

Instead of custom “render” type instructions, my game end sends the UI end, a description of a 2D visual scene-graph. And then modifies it as the game is played. The game knows canonical IDs of various media resources and the UI maps these to physical files which it fetches and renders.

Undo

How will this work in your system?

Are changes folded into the “state” object, which can perhaps track differences?

Repeated Choices

In your example;

:: Start
You wake up in a mysterious room.

+ [Look around] -> Examine
+ [Try the door] -> Door

:: Examine
The room is bare except for a small window.

+ [Go to window] -> Window
+ [Try the door] -> Door

:: Door
The door is locked.

+ [Look around] -> Examine

:: Window
Through the window, you see freedom.

It is annoying that you have to repeat both the “look around” choice and the “try the door” choice. Not only is this extra work, but it’s error prone.

Perhaps this limitation comes from Ink?

What i do is allow choices to be attached to objects like the “mysterious room”. So the room would have both “look around” and “try the door”. That way, you only need to code them once.

General

I think your system looks really good, and i don’t mean to be critical but constructive.

Bardic Name

I assume this is something else?


Good luck and great work.

3 Likes

So I’ve actually updated the itch.io file to be a lot smaller – originally and by default, the system bundles a bunch of external library support out of the box which made the first file about 17MB. Now with just pyiodide-core (as a command flag), it’s limited to stdlib and micropip and loads much faster, at about 5.4MB. It all depends on what the writer wants to utilize in their code, I suppose.

It was actually a lot more straightforward to implement that I had been expecting/dreading – once I decided to go with pyiodide local instead of pyscript (due to pyscript having loading time problems I couldn’t figure out), it’s mostly just bundling up the local pyiodide files + the converted engine file, and calling the pyiodide.js from my generated index.html for the project. Here’s an example of a generated bundle with all the required code: bardic/stories/samples/coffee_shop at main · katelouie/bardic · GitHub

6 Likes

A good step forward to reduce the size to ~5MB compressed. However, this is still rather fat. Ideally, you want to try getting the initial download <2MB. Looking inside Pyodide, there’s a 8MB WASM plus a 2.4MB python code zip. Taking the zip out, the rest compresses to ~3.1MB, which means the python code is taking 44%.

Most of that won’t actually be needed. But you never know what exactly. Or maybe you do? Presumably these things have to be referenced from the core code, and if they’re not, they can be omitted from the package?

Thank you for all of your comments – I definitely view it as constructive criticism! :smiley:

  • Yes, the “Bardic Tools” documentation is an unrelated project. As of right now, all of my documentation lives in the github repo, though I’m planning to establish a Readthedocs at…some point.
  • Regarding the python env issue – it would seem that an unrelated numpy install is causing a problem? At least for me, using something like pixi on my windows machine has made python env handling pretty clean and discrete with minimal setup woes. But that’s just me!
  • That’s a good point about the @render directives and “backend” vs. “frontend”. My docs talk about the system in a more web-app framework mindset (where python logic=backend and react/svelte/etc=frontend). I’ll keep this in mind as I’m revisiting @render directives and see if the approach you described would work well with my ideas for the design of the engine. I do really like the idea of a boilerplate set of fully generalized “frontend” functions that simply handle and render whatever the “backend” throws at it.
  • Regarding undo – I’m actually working on this right now! My basic approach is to establish a stack of states (with some max length), and implementing a undo-type method in my engine that pops the last state off of the stack. Everything game-relevant should already live in the state (well, unless maybe the user does something with side effects like calls to a external API or something…but other than that.) But it’s still a work in progress.
  • That’s a good point about the repeated choices – I’ve been thinking about implementing something similar to twines “repeated choice set” macro (?) as a new @-directive that allows for reusable choices without re-typing them constantly. Adding in objects that interface directly with story navigation is an interesting idea…I’ll have to think about it, and if it fits in with the passage-based design I’m going for!
  • Re: reducing the filesize further, I’m loathe to start digging into the guts of the pyiodide python distro and throwing things out, especially because there’s so much interdependence in the python stdlib. I want to preserve the integrity and stability of the full python standard library. I think I’ll settle on ~5MB file size for now, and possibly iterate in the future with pyscript or other approaches if distribution size becomes a limiting factor.

Thank you again for all your questions and comments about the technical details, I really appreciate it!

1 Like

Oh, that’s actually really helpful, thank you! That style of writing is very different from my own, so I didn’t even think of how useful gathers are when a writer likes to use lots of mini “node-less” nodes that are largely flavorful. I’ll have to think a bit about how I’d best implement that kind of syntax and change to the model structure…

1 Like

Thank you for considering my points. There are several people here who are or have written their own systems and various design issues come up. I encountered several design problems when building a system and it would be nice to help others or at least discuss alternatives.

Actually, there’s a thread on IF system checklists, but IFAIK no canonical list of IF system design problems or putative solutions?

For example “undo” is often one that designers leave until later. But i think it’s important to fettle this in the early days. Save game is another, but related.

I like your “stacks” idea. You’ll have to synchronise this with the turn-based nature, so you get a new stack top each turn. Since you want to pop whole turns and not just the last change.

I had a problem with this, because some turns make no changes. Should “undo” undo the last material turn or not? Should “undo” do nothing if the last command was > look at the sword ? Or should it undo > get sword + > look at the sword? I know you have choices, but the idea is the same.

Also, if your stack is the save game, then you can undo after a “restore”, which is a nice feature. For example suppose the player is poisoned there are not enough turns to get the antidote after the save. Otherwise the save is a “dead save”.

I was thinking about the repeated choices. Perhaps there’s a way. You can loop, but it gets messy.

:: Start
{ !Start ? You wake up in a mysterious room. | You're in a mysterious room. } // secondary text, whatever the syntax is for this.

+ [Look around] -> Examine
* [Try the door] -> Door
+ {Examine}  [Go to window] -> Window

:: Examine
The room is bare except for a small window.
-> Start

:: Door
The door is locked.
-> Start

:: Window
Through the window, you see freedom.
-> Start

or maybe, a way to have short inline reponses to neaten things up;

:: Start
{ !Start ? You wake up in a mysterious room. | You're in a mysterious room. } // or something

+ [Look around] -> Examine
* [Try the door] -> "The door is locked." -> Start
+ {Examine}  [Go to window] -> "Through the window, you see freedom." ->Start

:: Examine
The room is bare except for a small window. -> Start
1 Like

For my own system in development, I’ve addressed undo and save/restore with a common mechanism. The entire world state at the time of initialization is saved as a baseline, and then every turn saves a diff against the baseline to an array of diffs, up to an arbitrary number of turns, but set to 10 by default to keep it from eating too much memory.

My system counts verb usage and whether objects have been seen and/or known by each character, so even a turn such as > look at the sword would make changes to the world state. Then, undo and restore both do the same thing - merge a saved diff back into the world state. Merging a diff creates / destroys / updates objects and properties as necessary, recursively.

1 Like

I’ve been thinking about your comment re: the “do this every turn” lack of functionality in Ink – it seems like it would be a big pain point in simulation-like games? I was thinking, since bardic exposes the python event loop, I may be actually able to support this natively using a listener system. I’m considering a syntax somewhat like this, where you can “hook” a specific passage (with code and/or text) to run automatically at specific event triggers, like the end of a “turn”. The idea is that you could also “unhook” these systems dynamically, like turning off a poisoned effect when a player is cured.

:: Start
@hook turn_end hook_time_system

:: hook_time_system
~ minutes += 1
@if minutes > 60:
    ~ hour += 1
    ~minutes = 0
@endif
@if hour == 12:
    The church bell tolls.
@endif

:: leave_timestream
@unhook turn_end hook_time_system

Do you think something like that would solve the kind of friction you were talking about? Does this kind of model feel relatively intuitive to you?

1 Like

Looks good to me!

1 Like

That’s a great way to do it. Basically using diffs. I do something similar. Your point about the start state is what I do too. This means you can load a save against an updated version of the game. Say a new object was introduced, or a new location. An old version save would correctly apply, keeping the new version objects unchanged.

An update: I’ve added in a few different features in 0.6.0 based on feedback I’ve gotten here!

  • Undo/Redo: Currently this is implemented as stacks of game state (up to 50), with an additional “redo” stack for going back and forth in a given “timeline.” Once a player makes a new choice, the “redo” stack is cleared since they enter a new “branch.” UI elements (forward and back arrows) have been implemented in the default bardic bundle template for packaging up into HTML5. Still have to implement them in the NiceGUI/Reflex/React templates.
  • Hooks: An event listener system is implemented – currently it only listens for “end of turn.” You can dynamically set a passage’s worth of actions/content to be rendered or executed at the end of every turn, as well as removing the hook at will. It looks like:
:: HookPoison
~ health -= 10

:: Start
health = 100
Starting content
@hook turn_end HookPoison

:: explore
# Health should decrement

:: take_antidote
@unhook turn_end HookPoison
  • Joins: Similar to how Ink can “gather” up choices into a downstream point and re-converges them, you can now set choice targets to be @join and include content (including python one-liners) indented underneath the choice text, which will be executed/rendered if chosen. The flow then re-converges at a later point after the choices you indicate with a bare @join. Passages can have more than one @join point (choices are gathered up sequentially according to the next upcoming join marker) and you can mix join-choices with normal “go to another passage” choices. Currently “join-choices” don’t include python blocks (except for one-liners with ~), if-blocks, or for-blocks. Example syntax:
:: Start
You have two fruits.

+ [Take the pear] -> @join
    You choose the green pear.
    ~ fruit = "pear"
+ [Take the apple] -> @join
    You choose the red apple.
    ~ fruit = "apple"

@join
The {fruit} is delicious!
3 Likes