My C# IF Platform

Snapshot of Visual Studio and the World class.
The world is a bidirectional graph in this system.

This is the beginning of my research and development of a .NET Core/C# parser-based interactive fiction authoring system.

I started investigating this idea about two years ago, though I only started truly digging into it after the announcement of OpenAI's ChatGPT-4. One of the things that inspired me is having a "companion" with whom to write and talk about code. I've since switched to Claude Opus 3 which seems to be noticeably better and faster. LLMs are by no means perfect, but they can punch out a lot of dreary repetitive code with iterative prompts and they are excellent at teaching programming concepts that you may have never used in your regular work life.

One of those concepts was a graph data structure. I had gotten familiar with enterprise graph databases while developing my second startup Wizely. I was using Neo4j as the backend and felt the power of its capabilities. I did look for pre-built GitHub graph data structures, but there's really nothing there worth getting tied to. When I got access to an LLM, I prompted it for data structure lessons and over time worked out how it all comes together.

This first post is about the results of that R&D and the designed, coded, and unit tested class library that contains the data store for my IF platform's "world model".

The basic features of this data structure include:

  • bidirectional edges
  • properties on nodes and edges
  • pub/sub state change event handlers for any change in the graph
  • nodes have object values to contain custom constructs

There are six classes and one interface:

World class (top level)


namespace DataStore
{
    public class World
    {
        public Dictionary<string, Node> Nodes { get; private set; }
        public Dictionary<string, EdgeType> EdgeTypes { get; private set; }

        private List<IGraphEventHandler> eventHandlers = new List<IGraphEventHandler>();


        public World()
        {
            Nodes = new Dictionary<string, Node>();
            EdgeTypes = new Dictionary<string, EdgeType>();
        }

        public void AddEdgeType(string name, string reverseName)
        {
            EdgeTypes[name] = new EdgeType(name, reverseName);
        }

        public EdgeType GetEdgeType(string name)
        {
            return EdgeTypes[name];
        }

        public void AddNode(string id, object data)
        {
            Nodes[id] = new Node(id, data);
            PublishNodeAdded(Nodes[id]);
        }

        public void RemoveNode(string id)
        {
            if (Nodes.ContainsKey(id))
            {
                var node = Nodes[id];
                Nodes.Remove(id);
                PublishNodeRemoved(node);
            }
        }

        public void ConnectNodes(string id1, string id2, string edgeType, string reverseEdgeType)
        {
            Edge edge1 = new Edge(id1, id2, edgeType);
            Nodes[id1].Edges.Add(edge1);
            PublishEdgeAdded(edge1);

            if (reverseEdgeType != null)
            {
                Edge edge2 = new Edge(id2, id1, reverseEdgeType);
                Nodes[id2].Edges.Add(edge2);
                PublishEdgeAdded(edge2);
            } else
            {
                throw new Exception("All connected nodes must be bidirectional.");
            }
        }

        public void DisconnectNodes(string id1, string id2)
        {
            var edge1 = Nodes[id1].Edges.Find(e => e.Id2 == id2);
            if (edge1 != null)
            {
                Nodes[id1].Edges.Remove(edge1);
                PublishEdgeRemoved(edge1);
            }

            var edge2 = Nodes[id2].Edges.Find(e => e.Id1 == id2);
            if (edge2 != null)
            {
                Nodes[id2].Edges.Remove(edge2);
                PublishEdgeRemoved(edge2);
            }
        }

        public void SetNodeProperty(string nodeId, string propertyName, object propertyValue)
        {
            var property = Nodes[nodeId].Properties.Find(p => p.Name == propertyName);
            if (property != null)
            {
                property.Value = propertyValue;
            }
            else
            {
                property = new Property(propertyName, propertyValue);
                Nodes[nodeId].Properties.Add(property);
            }
            PublishPropertyChanged(nodeId, property);
        }

        public void SetEdgeProperty(string id1, string id2, string propertyName, object propertyValue)
        {
            var edge = Nodes[id1].Edges.Find(e => e.Id2 == id2);
            if (edge != null)
            {
                var property = edge.Properties.Find(p => p.Name == propertyName);
                if (property != null)
                {
                    property.Value = propertyValue;
                }
                else
                {
                    property = new Property(propertyName, propertyValue);
                    edge.Properties.Add(property);
                }
                PublishPropertyChanged($"{id1}-{id2}", property);
            }
        }

        public void AddEventHandler(IGraphEventHandler handler)
        {
            eventHandlers.Add(handler);
        }

        public void RemoveEventHandler(IGraphEventHandler handler)
        {
            eventHandlers.Remove(handler);
        }

        private void PublishNodeAdded(Node node)
        {
            foreach (var handler in eventHandlers)
            {
                handler.HandleNodeAdded(node);
            }
        }

        private void PublishNodeRemoved(Node node)
        {
            foreach (var handler in eventHandlers)
            {
                handler.HandleNodeRemoved(node);
            }
        }

        private void PublishEdgeAdded(Edge edge)
        {
            foreach (var handler in eventHandlers)
            {
                handler.HandleEdgeAdded(edge);
            }
        }

        private void PublishEdgeRemoved(Edge edge)
        {
            foreach (var handler in eventHandlers)
            {
                handler.HandleEdgeRemoved(edge);
            }
        }

        private void PublishPropertyChanged(string nodeOrEdgeId, Property property)
        {
            foreach (var handler in eventHandlers)
            {
                handler.HandlePropertyChanged(nodeOrEdgeId, property);
            }
        }
    }
}

The Node class:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DataStore
{
    public class Node
    {
        public string Id { get; private set; }
        public object Data { get; private set; }
        public List<Edge> Edges { get; private set; }
        public List<Property> Properties { get; private set; }

        public Node(string id, object data)
        {
            Id = id;
            Data = data;
            Edges = new List<Edge>();
            Properties = new List<Property>();
        }
    }
}

The Edge class:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DataStore
{
    public class Edge
    {
        public string Id1 { get; private set; }
        public string Id2 { get; private set; }
        public string EdgeType { get; private set; }
        public List<Property> Properties { get; private set; }

        public Edge(string id1, string id2, string edgeType)
        {
            Id1 = id1;
            Id2 = id2;
            EdgeType = edgeType;
            Properties = new List<Property>();
        }
    }

}

The EdgeType class:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DataStore
{
    public class EdgeType
    {
        public string Name { get; private set; }
        public string ReverseName { get; private set; }

        public EdgeType(string name, string reverseName)
        {
            Name = name;
            ReverseName = reverseName;
        }
    }
}

The Property class:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DataStore
{
    public class Property
    {
        public string Name { get; private set; }
        public object Value { get; set; }

        public Property(string name, object value)
        {
            Name = name;
            Value = value;
        }
    }
}

The GraphEvents classes:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DataStore
{
    public abstract class GraphEvent
    {
        // Common properties and methods for graph events
    }

    public class NodeAddedEvent : GraphEvent
    {
        public Node AddedNode { get; }

        public NodeAddedEvent(Node node)
        {
            AddedNode = node;
        }
    }

    public class NodeRemovedEvent : GraphEvent
    {
        public Node RemovedNode { get; }

        public NodeRemovedEvent(Node node)
        {
            RemovedNode = node;
        }
    }

    public class EdgeAddedEvent : GraphEvent
    {
        public Edge AddedEdge { get; }

        public EdgeAddedEvent(Edge edge)
        {
            AddedEdge = edge;
        }
    }

    public class EdgeRemovedEvent : GraphEvent
    {
        public Edge RemovedEdge { get; }

        public EdgeRemovedEvent(Edge edge)
        {
            RemovedEdge = edge;
        }
    }

    public class PropertyChangedEvent : GraphEvent
    {
        public string NodeOrEdgeId { get; }
        public Property Property { get; }

        public PropertyChangedEvent(string nodeOrEdgeId, Property property)
        {
            NodeOrEdgeId = nodeOrEdgeId;
            Property = property;
        }
    }

    // ... existing classes (Node, Edge, Property, World, etc.) ...
}

The IGraphEventHandler interface:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace DataStore
{
    public interface IGraphEventHandler
    {
        void HandleNodeAdded(Node node);
        void HandleNodeRemoved(Node node);
        void HandleEdgeAdded(Edge edge);
        void HandleEdgeRemoved(Edge edge);
        void HandlePropertyChanged(string nodeOrEdgeId, Property property);
    }
}

There are passing unit tests for all of this code and I'm moving on to the next module, the Map service. The Map service will be the "IF" layer on top of the data store, translating everything into nodes and edges and back.

The Map service will be the next entry in this blog. Stay tuned!

Subscribe to My So Called Interactive Fiction Life

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe