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.
Pingback: Living Narrative Engine, #1 – The Domains of the Emperor Owl
Pingback: Living Narrative Engine #3 – The Domains of the Emperor Owl