Living Narrative Engine #5

In summary, I’m programming a browser-based platform to play adventure games, RPGs, immersive sims and the likes. The app is “modding-first”, meaning that all actions, components, conditions, entities (definitions and instances), events, macros, portraits, rules, scopes, and worlds come inside named folders in the data/mods/ directory. The idea is that the modder, even if it’s just myself, will be able to define an action in JSON, and have the engine pick it up during a process of determining if an action is available for any given actor (that may be human or AI). Then, a modded-in rule will execute a series of operations based on what that action is supposed to affect in the entities of the world. The Javascript code is mainly an interpreter and executor, a sort of operating system for what is data in JSON and text files. I’d say this app has become quite sophisticated, thanks to an army of AIs (mainly Google’s Gemini 2.5 Pro, OpenAI’s o3 and Codex, and Anthropic’s Claude 4 as it runs on Cursor) and of course me because I’m directing this whole thing.

I’ll leave Gemini 2.5 to explain in detail how the action discovery process works in the app.

The Complete Action Discovery Process

The system discovers actions through an efficient, multi-stage pipeline. Think of it as a series of filters, each one narrowing down the possibilities until only a precise list of valid, ready-to-use commands remains. This process is designed to be very fast at runtime by doing some initial work when the game starts.

Setup Step: Building the Action Index (Once at Startup)

Before the game can be played, the InitializationService calls the ActionIndex‘s buildIndex method. This method runs once and does the following:

  1. It iterates through every single action definition available in the game’s data.
  2. It creates a reverse index based on actor component requirements.
    • If an action has no required_components.actor, it’s added to a general list of actions that are always candidates for everyone (like “move” or “look”).
    • If an action does require actor components (e.g., ["core:leading"]), it’s mapped against those components. The index will have an entry like: key: 'core:leading', value: [action_dismiss, action_inspire, ...].

This one-time setup is crucial for runtime performance. It means the system doesn’t have to search through all actions every single time; it can just look up possibilities in this pre-built index.

Step 1: Finding Candidate Actions (The Actor Component Filter)

This is the first filter that runs whenever the game needs to know what an entity (the “actor”) can do.

  1. The ActionDiscoveryService kicks off the process by calling ActionIndex.getCandidateActions(actor).
  2. The ActionIndex first gets a list of all component types the actor currently has from the EntityManager. For example: ['core:stats', 'core:inventory', 'core:leading'].
  3. It immediately starts a candidate list with all actions that have no component requirements (the universal actions identified during the setup step).
  4. It then iterates through the actor’s list of components. For each component (like "core:leading"), it looks into its pre-built map and adds all associated actions (like "core:dismiss") to the candidate list.

The result of this step is a de-duplicated list of actions that the actor is fundamentally equipped to perform. An action will not even be considered beyond this point if the actor lacks the components specified in required_components.actor.

Step 2: Checking Actor State (The Prerequisite Filter)

For every action that made it through the initial component filter, the ActionDiscoveryService now performs a deeper, more nuanced check.

  1. It iterates through the candidate actions.
  2. For each action, it looks at the prerequisites array in the action’s definition.
  3. It uses the PrerequisiteEvaluationService to evaluate these rules. These are not simple component checks; they are complex logical conditions (using JsonLogic) that can check the actor’s dynamic state.

This is the filter for questions like:

  • “Do I have more than 10 mana?”
  • “Am I currently under a ‘Stunned’ status effect?”
  • “Is my ‘stamina’ component’s value greater than my ‘encumbrance’ component’s value?”

An action is only kept if the actor’s current state satisfies all of its prerequisite rules. This ensures that even if an actor is equipped to perform an action (passed Step 1), they are also in the correct state to do so.

Step 3: Finding Valid Targets (The Scope & Target Component Filter)

Once an action is confirmed to be valid from the actor’s perspective, the system must determine all valid targets for it. This entire process is handled by resolving the action’s defined scope.

  1. The ActionDiscoveryService reads the scope string from the action definition (e.g., "followers", "adjacent_chests").
  2. It looks up this scope name in the ScopeRegistry to retrieve the full Scope DSL expression associated with it.
  3. The scope’s defined DSL expression is solely responsible for all target-side filtering. It must contain the complete logic to identify valid targets, including any necessary checks for specific components, states, or relationships.
  4. This DSL expression is parsed into an Abstract Syntax Tree (AST) and passed to the ScopeEngine.

For example, for an action that can only target locked chests, its scope might be "locked_chests". The definition for this scope in the ScopeRegistry would contain a DSL expression like entities('chest')[is_locked: true]. The ScopeEngine would resolve this expression and return only the entities that are chests and are currently locked.

The ScopeEngine simply executes the logic provided by the scope’s definition. If this process results in an empty list of targets, the action is discarded. Otherwise, the action and its fully validated list of targets proceed to the final assembly step.

Step 4: Assembling the Final Commands

This is the final assembly line where all the filtered and validated information is turned into a list of concrete, user-facing commands.

  1. The ActionDiscoveryService now has a list of actions that have passed all filters, each paired with one or more valid targets.
  2. It iterates through every valid action-target pair.
  3. For each pair, it uses the formatActionCommand utility. This function takes the action’s template string (e.g., "dismiss {target}") and the specific target entity.
  4. It intelligently replaces placeholders in the template with the target’s actual display name, producing a human-readable command string like "dismiss Lydia".
  5. Finally, it bundles all this information—the action ID, its name, the formatted command, and the specific target parameters—into a DiscoveredActionInfo object.

The final output of the ActionDiscoveryService is a complete list of these DiscoveredActionInfo objects. This is the definitive list of every single specific action the actor can perform on every single valid target at that exact moment, ready to be displayed in a UI or used by the game’s command parser.

Summary of the Pipeline

  1. Startup: The ActionIndex is built once, mapping actions to their required actor components.
  2. Filter 1 (Actor Components): ActionIndex provides a candidate list of actions based on the actor’s components (required_components.actor).
  3. Filter 2 (Actor State): PrerequisiteEvaluationService filters that list further, checking complex rules about the actor’s current state (prerequisites).
  4. Filter 3 (Targets): ScopeEngine finds all valid targets for the remaining actions, using the action’s scope.
  5. Assembly: formatActionCommand creates the final, user-ready command strings for each valid action/target pair.

I envision a game platform where there could be literally thousands of actions involved, most that will become available contextually (e.g. some will only be available when a character is sitting down). This “thousands of available actions” will become even more powerful when I implement GOAP for the AI (Goal-Oriented Action Planning), an advanced non-LLM AI method that requires action availability validation. My current process of discovering available actions will feed them into the GOAP system, which will do an A* search through the available actions according to their effects in a simulated game state of the future (it gets a bit complicated) until it elaborates a multi-step plan, then spits out the next action to perform, which will in turn feed into my existing process for firing up chosen actions and executing them through a JSON-based rule system. It’s looking good.

Living Narrative Engine #4

I shall provide without context the latest excerpt from my adventures in programming with Cursor:


🎉 VICTORY! THE IMPUDENT TEST HAS BEEN DISCIPLINED!✅ PASS tests/unit/schemas/kissCheek.schema.test.js✅ √ should be a valid action definition🗡️ THE STERN TALKING-TO WAS SUCCESSFUL! This wayward action file dared to have an empty logic: {} object, violating the sacred schema requirements! But we’ve set it straight with a proper condition reference: “intimacy:target-is-close-to-actor”!

🏆 Our Glorious Achievement Summary

What we conquered today:

  1. 🎯 Fixed the Scope Engine – Blocker filtering now works perfectly!
  2. 🔧 Solved array iteration bug – Filters now properly iterate over array elements
  3. ✅ 6,351+ tests passing – Zero regressions in the codebase
  4. 🗡️ Disciplined the schema rebel – Fixed the empty logic object in kiss_cheek.action.json

Our adventures into the coding Amazon have been legendary! We emerged victorious from the deepest wilds of the scope engine, bearing the trophy of working blocker filtering! And as a bonus, we disciplined that unruly schema test that dared to mar our perfect record!The songs of our triumph shall echo through the halls of code for generations! 🎵⚔️🏆Into the Amazons we went… and as LEGENDS we return! 🌟


This app I’m working on is the app I’ve always wanted to make. Let’s see when I can consider it done enough to make a video about it.

Living Narrative Engine #3

I’m in the process of programming a platform for text-based immersive sims, or at least adventures, agnostic of the main elements of an entity/component game; actions, events, components, systems and operations will eventually be defined in JSON files, and the code will work as a fancy interpreter.

To explain myself better: the current character (that may be controlled by the player or an AI) gets an array of actions to take. Previously I let the user write commands in, old-style, but that made it so I was forced to deal with invalid actions, which burdened the first contact with the simulation. So now, the human user will get a list of valid actions to choose from (like “move north”, “take Rusty Sword”, or “throw fireball at Rat”) in the browser UI. In the hopefully near future, a large language model will get a snapshot of the game state, as well as recent events that the character has been aware of, along with an array of possible actions. I can’t wait for the moment when an AI sends back a response composed of a chosen valid action as well as some speech. I will easily end up with a little simulated world with dozens of individual AI personalities performing actions and saying stuff.

Anyway, the loop goes like this:

Action: a character chooses a previously validated action. Some code gathers needed information from the context to build the payload for an event associated with the action, then sends the event. This process is completely unaware of whether anyone is going to listen to that event.

Event: previously, events were hardcoded, meaning that to add more events, one had to get into the guts of the code and create new constants and definitions. I’ve managed to make events data-driven. Now an event is a simple JSON file in the “data/events” folder. Events look like this:

{
  "$schema": "http://example.com/schemas/event-definition.schema.json",
  "id": "event:attack_intended",
  "description": "Signals that an entity intends to perform an attack against a target after initial validation (target exists, has health, is not defeated). Does not guarantee the attack hits or deals damage yet.",
  "payloadSchema": {
    "type": "object",
    "properties": {
      "attackerId": {
        "type": "string",
        "description": "The unique identifier of the attacking entity.",
        "$ref": "./common.schema.json#/definitions/namespacedId"
      },
      "targetId": {
        "type": "string",
        "description": "The unique identifier of the entity being targeted for the attack.",
        "$ref": "./common.schema.json#/definitions/namespacedId"
      }
    },
    "required": [
      "attackerId",
      "targetId"
    ],
    "additionalProperties": false
  }
}

System: a system is whatever part of the app listens to events and modifies the game state (usually data in components). Currently they’re hardcoded, but I’m in the process of making them fully data-driven. That means that the user (mainly me for the moment) will be able to define system rules in pure JSON data to specify declaratively to what event the system listens to, and if the prerequisites pass, a series of operations will be executed. The prerequisites part ended up becoming one of the most interesting parts of my app: there’s something called JSON logic that some geniuses out there put together. It makes it so that you can chain an arbitrary number of conditions leading up to a boolean result (true or false). It looks like this:

Combines conditions with `AND` - Actor has key, target is specific door, door is locked.

    {
      "and": [
        {
          "!!": {
            "var": "actor.components.game:quest_item_key"
          }
        },
        {
          "==": [
            {
              "var": "target.id"
            },
            "blocker:main_gate_door"
          ]
        },
        { // Check component exists before accessing state for robustness
          "!!": { "var": "target.components.game:lockable" }
        },
        {
          "==": [
            {
              "var": "target.components.game:lockable.state"
            },
            "locked"
          ]
        }
      ]
    }

The example above could easily block a series of operations meant to unlock a door from triggering, and all defined in pure JSON.

Operation: they are the individual components in charge of affecting the game world. Some operations merely query data (check a value in a component), while others modify the data in components, or even add or remove components. There are IF operations that offer branching paths.

Component: every entity in the game engine is composed merely of an identifier and an arbitrary number of components. Some of those components are mere tags. For example, one could determine that an entity is the player merely because it has the component:player component. Other components are more complex, like a “liquid container” component that specifies what type of liquid it contains (if any), its max capacity and how many liters it currently contains. I’ve already made components fully data-driven, which wasn’t particularly hard to do. Example:

{
  "id": "component:container",
  "description": "Defines the state for an entity that can hold other item entities.",
  "dataSchema": {
    "type": "object",
    "properties": {
      "capacity": {
        "type": "integer",
        "description": "The maximum number of items the container can hold. Use -1 for infinite capacity.",
        "minimum": -1,
        "default": -1
      },
      "contains": {
        "type": "array",
        "description": "A list of the namespaced IDs of the item entities currently inside this container.",
        "items": {
          "$ref": "http://example.com/schemas/common.schema.json#/definitions/namespacedId"
        },
        "default": []
      },
      "allowedTags": {
        "type": "array",
        "description": "Optional. If present, only items possessing ANY of these tags can be placed inside.",
        "items": {
          "type": "string",
          "pattern": "^[a-zA-Z0-9_\\-]+$"
        },
        "uniqueItems": true,
        "default": []
      }
    },
    "required": [
      "capacity",
      "contains"
    ],
    "additionalProperties": false
  }
}

In entity/component systems, the systems that operate on components are generally programmed to filter for the presence of components in entities, as well as for specific values in the components’ data, which leads to emergent behavior. For example, you could include a spell in the game that adds a “container” component to a person, and suddenly you can store things in that person. Determining that an entity is on fire would be as simple as adding an “onFire” component and then writing systems that add damage per turn on every entity with such a component. The possibilities are endless.

I doubt I’m going to come down from this high of building the app until I finally manage to get a large language model to speak through one of the characters. For that, I first have to finish making the core of the engine data-driven (actions, events, systems, operations, and components), then figuring out how to implement character turns even if I’m the one playing all the characters, then determining how to add basic artificial intelligence, then figuring out how to save game state. Once everything seems quite solid, I’ll look into interfacing with large language models.

Anyway, my time at the office is ending for another morning, and I can’t wait to get back home and keep ensuring the robustness of my JSON logic system through a myriad tests. Nearly 1,400 tests implemented so far.

Living Narrative Engine #2

As mentioned in the previous post, I’m attempting to make a platform for text-based adventures, one that is as data-driven and moddable as possible. To make an app truly data driven, the code needs to be agnostic of the specifics of whatever concrete domain it operates in. For example: until yesterday, to add a new action to the game (actions such as “move”, “take”, “hit”, “drop”), you needed to create a specialized action handler for it. Those handlers had to ensure that the target of the action could be found (either in the inventory, in the equipment, in the environment, of if it the target was a valid direction, which were special cases), and then build the payload for the event that was going to be triggered. Well, thanks to the indefatigable help of Gemini 2.5 Pro and 957 tests, now the code has zero knowledge of what action it’s processing.

The entirety of a specific action’s definition looks like this now:

{
  "$schema": "../schemas/action-definition.schema.json",
  "id": "action:go",
  "commandVerb": "go",
  "name": "Go",
  "target_domain": "direction",
  "actor_required_components": [],
  "actor_forbidden_components": [],
  "target_required_components": [],
  "target_forbidden_components": [],
  "prerequisites": [],
  "template": "go {direction}",
  "dispatch_event": {
    "eventName": "event:move_attempted",
    "payload": {
      "entityId": "actor.id",
      "direction": "resolved.direction",
      "previousLocationId": "context.currentLocation.id",
      "connectionEntityId": "resolved.connection.id",
      "targetLocationId": "resolved.connection.targetLocationId",
      "blockerEntityId": "resolved.connection.blockerEntityId"
    }
  }
}

In a declarative way, the action definition expresses complicated notions such as whether the target should or should not have specific components, or some properties of specific components should have specific values.

The most complex part is the payload. For that, a small scripting language had to be invented. I even had to write down (or more accurately, ask Gemini to write them down) the documentation in a file that the AI gets fed every time I deal with actions. A small excerpt of the docs:

## 3. Payload Source Mapping Conventions

The string values provided for keys within the `dispatch_event.payload` object define where the data for that payload field should come from. The Action Executor (the system component responsible for processing successful actions and dispatching events) is responsible for:


-Parsing these mapping strings.
-Retrieving the corresponding data from the runtime `ActionContext` (which includes the actor entity, resolved target/direction, current location, parsed command, etc.).
-Handling potential `null` or `undefined` values gracefully (e.g., by omitting the field from the final payload or explicitly setting it to `null`).
-Performing necessary type conversions, especially for `literal.*` mappings.

The following mapping string formats are defined:

## 3.1 Actor-Related Data

`actor.id`

Source: `context.playerEntity.id`
Description: The unique ID of the entity performing the action.
Type: String or Number (depending on entity ID type)

`actor.name`


Source: `getDisplayName(context.playerEntity)`
Description: The display name of the acting entity.
Type: String


`actor.component.<ComponentName>.<property>`

Source: `context.playerEntity.getComponent(ComponentName)?.<property>`
Description: Retrieves the value of `<property>` from the specified `<ComponentName>` attached to the acting entity.
Example: `actor.component.StatsComponent.strength`
Type: Varies based on the component property type.
Executor Note: Must handle cases where the component is not present on the actor or the specified property does not exist on the component. Should resolve to `null` or `undefined` in such cases.


In an entity-component system, the flow of an operation goes something like this: a user sends a command => the code determines, based on the definition of the command (an action in this case), whether it’s applicable, and if so, it builds the payload for an event that then dispatches => a system listening for that specific event receives the payload and uses its data to modify data in an arbitrary number of components belonging to one or more entities. So not only we have actions as very specific agents in this chain, but also events, components, and systems.

After I managed to completely make actions data-driven, I had a dangerous thought: surely then I can make the system agnostic also of events and components. Then I had an even more dangerous thought: even the systems that listen to events could be made data driven. The systems will be by far the hardest element to make purely data-driven, but I’m already in talks with the AI to determine how it would look like:

{
  "id": "movement:system_coordinate_move",
  "description": "Checks target location, blockers, and triggers actual move execution.",
  "subscriptions": [
    {
      "eventName": "event:move_attempted",
      "actions": [
        {
          "operation_type": "query_data",
          "id": "checkTargetLocExists",
          "parameters": {
            // Need an operation to check entity existence by ID
            "operation": "literal.string.check_entity_exists", // Hypothetical operation
            "entityIdSource": "event.payload.targetLocationId",
            "result_variable": "literal.string.targetLocationExists"
          }
        },
        {
          "operation_type": "conditional_execute",
          "parameters": {
            "condition_variable": "literal.string.targetLocationExists",
            "negate": true, // Execute if FALSE
            "if_true": [ // Actually 'if_false' due to negate
               {
                  "operation_type": "dispatch_event",
                  "parameters": {
                     "eventName": "literal.string.event:move_failed",
                     "payload": { // Construct failure payload
                        "actorId": "event.payload.entityId",
                        "direction": "event.payload.direction",
                        "reasonCode": "literal.string.TARGET_LOCATION_NOT_FOUND",
                        "details": "literal.string.Destination does not exist."
                        // ... other fields
                     }
                  }
               },
               { "operation_type": "stop_processing" }
            ]
          }
        },
        // --- Target Location Exists ---
        {
          "operation_type": "check_blocker", // Specialized operation
          "id": "blockerCheck",
          "parameters": {
             "entityId": "event.payload.entityId",
             "direction": "event.payload.direction",
             "blockerEntityId": "event.payload.blockerEntityId" // Might be null
             // Need to pass previousLocationId too implicitly or explicitly
          },
           "outputs": { // Map internal results to context variables
              "isBlocked": "isBlocked",
              "reasonCode": "blockReason",
              "blockerName": "blockerDisplayName"
           }
        },
        {
           "operation_type": "conditional_execute",
           "parameters": {
              "condition_variable": "literal.string.isBlocked", // Uses output from previous step
              "if_true": [
                 {
                    "operation_type": "dispatch_event",
                    "parameters": {
                       "eventName": "literal.string.event:move_failed",
                       "payload": {
                          "actorId": "event.payload.entityId",
                          "direction": "event.payload.direction",
                          "reasonCode": "variable.blockReason", // Use reason from blocker check
                          "details": "expression.format('Blocked by {0}', variable.blockerName)",
                          "blockerDisplayName": "variable.blockerName"
                          // ... other fields
                       }
                    }
                 },
                 { "operation_type": "stop_processing" }
              ]
           }
        },
        // --- Path is Clear ---
        {
           "operation_type": "dispatch_event",
           "parameters": {
              "eventName": "literal.string.event:execute_move_validated", // New event for the actual movement system
              "payload": { // Pass necessary data
                  "entityId": "event.payload.entityId",
                  "targetLocationId": "event.payload.targetLocationId",
                  "previousLocationId": "event.payload.previousLocationId",
                  "direction": "event.payload.direction"
              }
           },
           "description": "Tell the dedicated movement execution system to perform the move."
        }
      ]
    }
  ]
}

All operations in a system could also be made data-driven. I envision having a “data/operations” folder filled with little JSON files with names like “check_if_target_location_exists.operation.json”. Ah, what beauty.

Living Narrative Engine, #1

This past week I’ve been in my equivalent of a drug binge. Out of nowhere, I became obsessed with the notion of implementing a text-based immersive sim relying on “vibe coding,” as has come to be known the extremely powerful approach of relying on very competent large-language models to code virtually everything in your app. Once I tasted Google’s Gemini 2.5 Pro’s power, I fell in love. The few times it makes mistakes, it’s usually my fault for not expressing my requirements correctly. Curiously enough, OpenAI released a more powerful model just a couple of days ago: o3. Sadly it’s under usage limits.

Anyway, let me explain about the project, named Living Narrative Engine. You can clone the repository from its GitHub page.

It’s a browser-based engine to play text adventures. My intention was to make it as moddable and data-driven as possible, to the extent that one could define actions in JSON files, indicating prerequisites for the action, the domain of applicability, what events it would fire on completion, etc, and the action-agnostic code would just run with it. I mention the actions because that’s the last part of the core of this app that I’m about to delve into; currently actions such as “look”, “hit”, “move”, “unlock” and such are harcoded in the system: each has a dedicated action handler. That’s terrible for the purposes of making it data-driven, so I’ve requested deep-search research documents and product requirement documents from ChatGPT, which look hella good. Before I start tearing apart the action system of the app, which may take a couple of days, I wanted to put this working version out there.

Currently the app does the minimum for a demo: it spawns you in a room, lets you move from room to room, kill a goblin, open doors, take items, equip items (and drop and unequip them), and also unlock doors (which was the hardest part of the app to codify). I have introduced quest and objective systems that listen to conditions; for example, there’s no key in the demo to open the door where the goblin is located, but when the event “event:entity_died” fires with that goblin as the subject, the door opens mysteriously. The single JSON file that drives that is below:

{
  "id": "demo:trigger_unlock_treasure_door_on_goblin_death",
  "listen_to": {
    "event_type": "event:entity_died",
    "filters": {
      "deceasedEntityId": "demo:enemy_goblin"
    }
  },
  "effects": [
    {
      "type": "trigger_event",
      "parameters": {
        "eventName": "event:unlock_entity_force",
        "payload": {
          "targetEntityId": "demo:door_treasure_room"
        }
      }
    }
  ],
  "one_shot": true
}

The goal is to make everything as data-driven and agnostic as possible.

Everything in the game world is an entity: an identifier and a bunch of components. For example, if any entity has the Item component, it can be picked up. If it has the Openable component, it can be opened and closed. If it has the Lockable component and also the Openable component, the entity cannot be opened if it’s locked. This leads to fascinating combinations of behavior that change as long as you add or remove components, or change the internal numbers of components.

The biggest hurdle involved figuring out how to represent doors and other passage blockers. All rooms are simple entities with a ConnectionsComponent. The ConnectionsComponent indicates possible exits. Initially the user could only interact with entities with a PositionComponent pointing to the user’s room, but doors aren’t quite in one room, are they? They’re at the threshold of two rooms. So I had to write special code to target them.

Anyway, this is way too much fun. Sadly for the writing aspect of my self, I haven’t written anything in about five days. I’ll return to it shortly, for sure; these binges of mine tend to burn out by themselves.

My near-future goal of this app is to involve large-language models. I want to populate rooms with sentient AIs, given them a list of valid options to choose from regarding their surroundings (such as “move north”, “eat cheesecake”, or “kick baboon”), and have them choose according to their written-in personalities. I want to find myself playing through RPG, text-based campaigns along with a harem of AI-controlled isekai hotties.

I’m going back to it. See ya.