Process an IF turn entirely Without Text!

As I develop Sharpee, one design decision has clarified significantly.

During the processing of a turn, the system won’t generate or “print” any text. None.

But wait Dave! This IS an interactive (text) fiction authoring thing you’re building, right?

Yes, but is streaming print statements the best way to emit templated text?

In some cases that might be optimal, and I certainly understand that writers might see it that way, but is it the best way overall?

I don’t think so.

During the execution of a “turn”, this is what Sharpee will do…

  1. parse player’s command into a well-known structure that identifies the action, the adjective+noun(s), articles, and prepositions.
  2. validate the command and its components with the grammar and world model, including if all the necessary bits are “in scope”
  3. execute the associated function with the given action (take/drop/throw, etc) which will update the world model which automatically fires events into an event source (a data store similar to a log but for IF events)
  4. when the turn is complete and all actions have completed, the game loop calls a text service
  5. the text service will query the event source and the world model for both changes and current state and from that information, create the text to emit to the client. All the formatting, combining, listing, and templating of the text is handled in this text service.
  6. The text service is likely to be a host to multiple templates (console, web, API) but at first it will just emit standard IF to the console (stdio).

This will completely eliminate the need to “manage” spaces, newlines, punctuation, quotations. types (bold/underline), colors, and other text-specific logic until the text service does its work.

The author will initially only care about execution of the story.

Now, we may be able to add things like emotions or surprises as properties of an action and the template can properly emit bold or caps text, but that’s going to be a contextual thing, not a technical thing. The author might tag an action as “important”, but the meaning (in terms of text emission) of that is not determined until the text service is building its template of output. It could mean bold or a color or caps or whatever the template is designed to emit for that property.

I’m actually excited to write an IF game in this system. I want to see where it all leads.

I asked Claude for a possible example:

public class StandardIFTextBuilder
{
    private readonly WorldModel _worldModel;
    private readonly List<WorldStateChange> _stateChanges;
    private Room _currentRoom;
    private Person _player;

    public StandardIFTextBuilder(WorldModel worldModel, Guid playerId)
    {
        _worldModel = worldModel;
        _stateChanges = new List<WorldStateChange>();
        _player = _worldModel.GetThingById(playerId) as Person;
        _currentRoom = _worldModel.GetPlayerLocation(playerId);

        // Subscribe to world model events
        _worldModel.ThingMoved += OnThingMoved;
        _worldModel.PropertyChanged += OnPropertyChanged;
        // Add more event subscriptions as needed
    }

    private void OnThingMoved(object sender, ThingMovedEventArgs e)
    {
        _stateChanges.Add(new WorldStateChange
        {
            ChangeType = WorldStateChangeType.ThingMoved,
            Thing = e.Thing,
            FromLocation = e.FromLocation,
            ToLocation = e.ToLocation
        });

        if (e.Thing == _player)
        {
            _currentRoom = e.ToLocation as Room;
        }
    }

    private void OnPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        _stateChanges.Add(new WorldStateChange
        {
            ChangeType = WorldStateChangeType.PropertyChanged,
            Thing = sender as Thing,
            PropertyName = e.PropertyName,
            OldValue = e.OldValue,
            NewValue = e.NewValue
        });
    }

    public string BuildTurnText(ParsedActionResult actionResult)
    {
        var textBuilder = new StringBuilder();

        // Add action result message
        textBuilder.AppendLine(actionResult.Results.FirstOrDefault()?.Parameters["message"] as string);

        // Process state changes
        foreach (var change in _stateChanges)
        {
            switch (change.ChangeType)
            {
                case WorldStateChangeType.ThingMoved:
                    if (change.Thing == _player)
                    {
                        textBuilder.AppendLine(BuildRoomDescription(_currentRoom));
                    }
                    else if (change.ToLocation == _currentRoom)
                    {
                        textBuilder.AppendLine($"{change.Thing.Name} arrives in the room.");
                    }
                    else if (change.FromLocation == _currentRoom)
                    {
                        textBuilder.AppendLine($"{change.Thing.Name} leaves the room.");
                    }
                    break;
                case WorldStateChangeType.PropertyChanged:
                    if (change.Thing.IsVisible() && (change.Thing == _player || _worldModel.GetRoomContents(_currentRoom).Contains(change.Thing)))
                    {
                        textBuilder.AppendLine($"{change.Thing.Name}'s {change.PropertyName} changed from {change.OldValue} to {change.NewValue}.");
                    }
                    break;
            }
        }

        // Clear state changes for the next turn
        _stateChanges.Clear();

        return textBuilder.ToString().Trim();
    }

    private string BuildRoomDescription(Room room)
    {
        return _worldModel.GetRoomDescription(room, _player.Id);
    }
}

public enum WorldStateChangeType
{
    ThingMoved,
    PropertyChanged
}

public class WorldStateChange
{
    public WorldStateChangeType ChangeType { get; set; }
    public Thing Thing { get; set; }
    public Thing FromLocation { get; set; }
    public Thing ToLocation { get; set; }
    public string PropertyName { get; set; }
    public object OldValue { get; set; }
    public object NewValue { get; set; }
}

Which would be executed in the Text Service like so:

public class TextService
{
    private readonly StandardIFTextBuilder _textBuilder;

    public TextService(WorldModel worldModel, Guid playerId)
    {
        _textBuilder = new StandardIFTextBuilder(worldModel, playerId);
    }

    public string GetTurnText(ParsedActionResult actionResult)
    {
        return _textBuilder.BuildTurnText(actionResult);
    }
}
3 Likes

You’ve got it exactly.

So if an action has various validation rules that check if it’s possible, and raise an error if it’s not, I’m guessing they’d set some errno-esque variable to indicate what happened, and then the text service queries errno if the action failed to figure out what to print (“that’s not portable”, “you already have that”, “you’re carrying too much already”, etc)?

The actual error would show up in the event source. As soon as that determination is made (that’s not portable), an event about an immovable object would be fired and the text service would log it. Every parsing decision would fire an event, but the final event would likely represent the “error”.

In your examples, the events would be something like:

object is scenery or object requires greater player strength or object is currently immovable (the variation is in how we’d construct puzzles)
player already holds object
player capacity prevents object acquisition

The Text Service would act accordingly.

Why is a ‘turn’ top level. Why can’t the ‘main loop’ be part of the story file.

Eventually the “top layer” will get refactored. We’ll see where everything lands later.

This is the way.

Always try to push a program’s IO functions as far as possible toward its outermost loop. If nothing else, it makes unit tests a lot easier to write. In most cases it also makes it easier to port it to new platforms, though since you’re working in C# which already lives inside a portable virtual machine this is not so applicable.

1 Like