Twinelike 0.2.0

dotnet add package Twinelike --version 0.2.0
                    
NuGet\Install-Package Twinelike -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Twinelike" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Twinelike" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="Twinelike" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Twinelike --version 0.2.0
                    
#r "nuget: Twinelike, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Twinelike@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Twinelike&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=Twinelike&version=0.2.0
                    
Install as a Cake Tool

Twinelike

CI NuGet License: MIT

A C# library for parsing and running Twine/Harlowe interactive fiction, designed to embed inside game engines — Unity, Godot, anything that runs .NET, Mono, or IL2CPP.

It accepts Twine 2 HTML exports and Twee 3 source, parses the full Harlowe markup language, evaluates author-written macros at runtime, and surfaces rendered content through an engine-agnostic IRenderOutput interface — plain text, navigation links, semantic styles, and interactive regions. Implement that interface against your engine's text renderer (TextMeshPro, RichTextLabel, raw HTML, plain console) and you have a working interactive story.

Status: Targets netstandard2.0, so it drops into Unity 2018.1+, Godot 3 & 4, .NET Framework 4.6.1+, .NET 5+, Mono, and Xamarin. Tracks Harlowe 3.3.8.


Quick start

using Harlowe;
using Harlowe.Runtime;
using Harlowe.Twee; // only needed if you're loading Twee 3 source

// Load a Twine 2 HTML export, or use new TweeReader().Read(tweeText) instead.
var story = new Harlowe(File.ReadAllText("story.html"));

// Wrap it in a session — this is the stateful object you drive.
var session = new StorySession(story);

// Render the current passage. Every navigation method returns one of these.
var result = session.Render();

Console.WriteLine(result.Text);
// Output:
//   You stand in a clearing. There is a path north.

// The player clicked a link in your UI; advance.
var next = session.Goto("Forest");

// Or, they clicked an interactive region — pass the id back:
var after = session.DispatchEvent(regionIdFromYourUi);

// Roll back to the previous passage:
if (session.Undo()) {
  session.Render();
}

RenderResult carries the rendered content as both a flat .Text string and a list of typed .Entries (Text, Link, PushStyle, BeginInteractive, …). Pass the entries to your engine's renderer, or use the IRenderOutput interface below to receive them as a stream.

Engine integration

Implement IRenderOutput against whatever your engine uses to display text. The interface is small and stable:

public interface IRenderOutput
{
  // Prose text — already entity-decoded, post-macro evaluation.
  void Text(string content);

  // Raw author-written inline HTML (e.g. <b>hello</b>). Drop, escape,
  // or pass through depending on whether your engine wants HTML at all.
  void Html(string rawHtml);

  // A passage navigation link. Wire your UI's click handler to
  // session.Goto(target).
  void Link(string text, string target);

  // An in-prose error (bad expression, type mismatch, unknown macro).
  // The runtime never throws on the render hot path — errors come
  // through this channel instead.
  void Error(string message);

  // Bracketing semantic styles. StyleSpec carries flags (Bold, Italic,
  // Underline, Strikethrough), value fields (Color, BackgroundColor,
  // BackgroundImage, FontFamily, FontSize, Opacity, Alignment), and a list
  // of named Effects (Mark, Outline, Shadow, Blur, Shudder, Blink, ...).
  // PushStyle is always paired with a matching PopStyle; nesting is
  // well-formed.
  void PushStyle(StyleSpec style);
  void PopStyle();

  // Bracketing interactive regions. region.Id is opaque — pass it back
  // to session.DispatchEvent() when the user interacts. region.Kind is
  // Click / MouseOver / MouseOut.
  void BeginInteractive(InteractiveRegion region);
  void EndInteractive();
}

Mapping to common engines

The bracketing primitives (PushStyle/PopStyle, BeginInteractive/EndInteractive) intentionally line up with how inline-tag rich-text systems already work:

Engine PushStyle (Bold) BeginInteractive Dispatch wiring
Unity TextMeshPro <b>...</b> <link="region.Id">...</link> _onLinkClickedsession.DispatchEvent(linkId)
Godot RichTextLabel [b]...[/b] [url=region.Id]...[/url] meta_clickedsession.DispatchEvent(meta)
HTML / web <b>...</b> <a data-region-id="region.Id">...</a> use HtmlRenderOutput (built in)
CLI / plain text ANSI bold numbered hotkey manual prompt → dispatch

For web/HTML consumers, wrap your output in HtmlRenderOutput and it translates semantic events into HTML tags automatically — no manual mapping.

A complete minimal adapter

public class ConsoleOutput : IRenderOutput
{
  public void Text(string content)             => Console.Write(content);
  public void Html(string rawHtml)             => Console.Write(rawHtml);
  public void Link(string text, string target) => Console.Write($"[{text} -> {target}]");
  public void Error(string message)            => Console.Write($"<<error: {message}>>");

  public void PushStyle(StyleSpec style) {
    if (style.Bold)   Console.Write("\x1b[1m");
    if (style.Italic) Console.Write("\x1b[3m");
  }
  public void PopStyle() => Console.Write("\x1b[0m");

  public void BeginInteractive(InteractiveRegion region) => Console.Write("[");
  public void EndInteractive()                           => Console.Write("]");
}

Supported Harlowe features

✓ shipped · ⚠ partial · ✗ not yet

Language

Feature Status
Variables ($story, _temp) and full expression grammar
Every operator from the Harlowe 3.3.8 precedence table
Property access ('s, of, its)
Ordinal indexing (1st, last, Nthlast)
Hooks: anonymous […], \|name>[…], […]<name\|
Twine links: [[text->target]], [[target<-text]], bare [[name]]
Lambdas: where, via, making, each (incl. implicit it)
Hook references: ?name, ?passage, ?page, ?link (+ ordinal narrowing)
(goto:) with multi-step undo
Inline <html> passthrough in passage bodies
String escape sequences (\n/\r/\t/\\/\"/\xHH/\uHHHH, etc.)
when lambda clause ✗ (reserved for (event:))

Macros

Macro family Status
(set:), (put:), (print:), (display:)
(if:), (unless:), (else-if:), (else:)
(random:), (either:), (history:)
(a:), (dm:), (modulo:), (text:), (num:)
(find:), (all-pass:), (some-pass:), (none-pass:), (altered:)
(for:), (folded:), (rotated-to:), (sorted:)
(text-style:) — full name set incl. mark, outline, shadow, blur, mirror, shudder, blink, fade-in-out, … (variadic, with "none" reset)
(text-color:) / (text-colour:) / (color:) / (colour:)
(background:) / (bg:) (colour or image url)
(font:), (text-size:) / (size:), (opacity:), (align:)
(border:), (border-colour:), (border-size:), (corner-radius:), (rotate:)
(hover-style:), (line-style:), (char-style:), (link-style:)
(replace:), (append:), (prepend:)
(change:), (enchant:) ✓ hook-name targets (string targets pending)
(click:) / (click-replace:) / (click-append:) / (click-prepend:)
(mouseover:) and -replace/-append/-prepend variants
(mouseout:) and -replace/-append/-prepend variants
(link:), (link-replace:), (link-reveal:), (link-goto:)
(live:), (event:), (trigger:)
(t8n:), (transition:), transition modifiers
Custom (macro:), (output:)
Storylets, (unpack:), ... spread, property assignment

Storage

Feature Status
Twine 2 HTML import
Twee 3 read & write
Programmatic editing (AddPassage/RemovePassage/RenamePassage)
Lazy reserialization — clean passages round-trip byte-for-byte

Story writers: a story that uses only ✓ features will play unchanged. ⚠ rows mean the macro is recognised but only a subset of arguments work. ✗ macros produce an in-prose error rather than crashing — surrounding content keeps rendering.

Twee 3 example

A minimal story this library happily parses and runs:

:: StoryTitle
The Clearing

:: StoryData
{
  "ifid": "00000000-0000-0000-0000-000000000001",
  "format": "Harlowe",
  "format-version": "3.3.8",
  "start": "Clearing"
}

:: Clearing
You stand in a |spot>[clearing]. There is a path north.
(click: ?spot)[A breeze stirs the grass.]

[[Go north->Forest]]

:: Forest
The forest is dark and full of |secret>[secrets].
(enchant: ?secret, (text-style: "italic"))

[[Back->Clearing]]

Loaded with:

var story = new TweeReader().Read(File.ReadAllText("clearing.tw"));
var session = new StorySession(story);

Build & test

dotnet build Twinelike.sln
dotnet test  Twinelike.sln

Library targets netstandard2.0. Test project multi-targets net48 + net8.0 (xUnit). Both TFMs exercise the same code; CI runs net8.0 on Linux.

To produce the distributable DLL:

# Release build merges HtmlAgilityPack into twinelike.dll via ILRepack
# (Debug builds skip the merge for fast dev cycles).
dotnet build Twinelike.sln -c Release
# → bin/Release/netstandard2.0/twinelike.dll  (self-contained, ~270 KB)

# Or for a clean publish folder:
dotnet publish Twinelike.csproj -c Release -o ./dist/Twinelike
# → dist/Twinelike/twinelike.dll

# Or for the NuGet package:
dotnet pack Twinelike.csproj -c Release -o ./dist
# → dist/Twinelike.0.2.0.nupkg

Drop the produced twinelike.dll into Unity's Assets/Plugins/ or reference it from any .NET project — no other DLLs required.

Architecture

Two parsing layers, then a runtime. Briefly:

  • Layer 1 — host. Either Harlowe(htmlText) (HtmlAgilityPack pulls <tw-storydata> / <tw-passagedata> and HTML-entity-decodes the inner text) or TweeReader().Read(tweeText) (header splits on :: Name [tags] {position} at column 0). Both produce the same Harlowe story object.
  • Layer 2 — Harlowe markup. Shared between both front-ends: HarloweTokenizerHarloweBodyParser (which hands off to HarloweExpressionParser at every macro) → PassageBody AST.
  • Runtime. StorySession owns the variable store, macro registry, and render-tree state. BodyRenderer walks an AST into a RenderTreeBuilder (an IRenderOutput-shaped tree-of-nodes); RenderTreeFlusher replays the finished tree as the flat event stream your IRenderOutput receives. Revision macros ((replace:), (append:), (prepend:)) mutate the tree in place; enchantment macros ((change:), (enchant:)) re-wrap matched nodes; interaction macros ((click:) family) wrap targets in InteractiveRegion brackets and register handlers that fire on DispatchEvent.

For implementation depth — file layout, the descriptor-patch changer model, design rationale, conventions — see CLAUDE.md.

Dependencies

None at runtime. The shipped twinelike.dll is a single self-contained assembly — HtmlAgilityPack (used by the Twine 2 HTML loader) is merged into it at build time via ILRepack with its types internalized, so it doesn't propagate as a NuGet dependency and doesn't conflict if your project already references HAP for its own purposes.

Errors are inline, not exceptional

The runtime never throws on the render hot path. A bad expression renders an inline error message at the spot it happened (delivered through IRenderOutput.Error) and the rest of the passage keeps rendering — mirroring Harlowe's authoring model, where one broken macro doesn't take down the whole story. Engine integrations don't need try/catch around every render call.

License

MIT. Use it in commercial games, open-source projects, anything — just keep the copyright notice with the source.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 41 6/9/2026
0.1.1 56 6/8/2026