Using Loreline with PHP

Loreline provides a PHP library that works with PHP 8.1 and later. This guide shows how to set up a project, load a .lor script, handle dialogue, choices and script completion, and serve an interactive story as a web page with plain PHP.

Installing the library

Download loreline-php.zip (v0.10.0). The archive contains:

Copy the archive's content into a loreline/ folder in your project and require the autoloader:

<?php

require __DIR__ . '/loreline/src/autoload.php';

use Loreline\Loreline;

No extension is needed: the runtime is plain PHP.

Loading a script

Use Loreline::parse() to parse a .lor string:

$script = Loreline::parse(file_get_contents('story/CoffeeShop.lor'));

If the script uses import statements, pass its path and a file handler to resolve them:

$handleFile = function (string $path, callable $provide) {
    $provide(is_file($path) ? file_get_contents($path) : null);
};

$script = Loreline::parse(
    file_get_contents('story/CoffeeShop.lor'),
    'story/CoffeeShop.lor',
    $handleFile
);

One PHP-specific caution: if you write Loreline source inline rather than loading it from a file, use single quotes or a <<<'LOR' heredoc. Loreline interpolation uses $name, which double-quoted PHP strings would try to interpolate themselves:

$source = <<<'LOR'
beat Start
  Alex greets $customer with a smile.
LOR;

Handling dialogue

Playback is driven by three handlers passed to Loreline::play(). The dialogue handler receives the interpreter, a character identifier (or null for narrative text), the text, any tags, and a callback to advance the script:

$onDialogue = function ($interpreter, $character, $text, $tags, $advance) {
    if ($character !== null) {
        // Resolve display name from character definition
        $name = $interpreter->getCharacterField($character, 'name') ?? $character;
        echo "$name: $text\n";
    } else {
        // Narrative text (no character)
        echo "$text\n";
    }
    $advance();
};

In an application, you would typically display the text and call $advance() when the player is ready to continue.

Handling choices

The choice handler receives an array of ChoiceOption objects. Each option has a text field and an enabled field. Call the callback with the index of the selected option, counted in the full array, disabled options included:

$onChoice = function ($interpreter, $options, $select) {
    $enabled = [];
    foreach ($options as $index => $option) {
        if ($option->enabled) {
            $enabled[] = $index;
            echo '  [' . count($enabled) . "] {$option->text}\n";
        }
    }

    $answer = (int) trim(fgets(STDIN));
    $select($enabled[$answer - 1]);
};

Handling script completion

The finish handler is called when the script reaches its end:

$onFinish = function ($interpreter) {
    echo "--- The End ---\n";
};

With the three handlers in place, start the story:

Loreline::play($script, $onDialogue, $onChoice, $onFinish);

Starting from a specific beat

By default, play() starts from the beginning of the script. To start from a specific beat, pass its name:

Loreline::play($script, $onDialogue, $onChoice, $onFinish, 'MorningScene');

Interpreter options

The last argument of play() accepts an options array. Use it to register custom functions callable from your story:

$options = [
    'functions' => [
        'roll' => function ($interpreter, $args) {
            return rand(1, (int) $args[0]);
        },
    ],
];

Loreline::play($script, $onDialogue, $onChoice, $onFinish, null, $options);

The options array also accepts a translations entry; see Localization for the full translation workflow.

Saving and restoring state

$interpreter->save() returns the whole interpreter state as a JSON string, and Loreline::resume() starts a new run from it:

$saveData = $interpreter->save();

// Later, or in another process:
Loreline::resume($script, $onDialogue, $onChoice, $onFinish, $saveData);

Being a plain string, a save can go straight into a session, a file or a database:

$_SESSION['save'] = $interpreter->save();
file_put_contents('save.json', $interpreter->save());

The format is shared by every Loreline integration, so a save written here can be resumed by the JavaScript runtime or any other target, and the other way around.

A save taken during a dialogue or choice callback captures the state such that resuming re-delivers that same callback: the player sees the same line, or the same options, again. That timing is what makes the web setup below work.

Playing the story as a web page

PHP starts fresh on every request, so a story cannot simply keep running the way it does in a console script. The save and resume pair turns that constraint into a straightforward flow: run the story until it waits on a choice, save into the session, and render the page. When the player clicks an option, resume from the session, answer the re-delivered choice with the clicked index, and keep going until the next choice or the end.

Here is a complete, self-contained example. First the story, saved as story.lor:

state
  hasCroissant: false

character barista
  name: Alex

beat Start
  The café smells of fresh coffee and warm pastry.

  barista: Morning! What can I get you today?

  choice
    Order a double espresso
      -> Counter
    Grab a croissant first
      -> Pastries

beat Pastries
  hasCroissant = true

  You pick the last croissant of the morning, still warm from the oven.

  -> Counter

beat Counter
  if hasCroissant
    barista: Good pick! That croissant goes well with a flat white.

    You settle at the counter with your coffee and croissant.

  else
    barista: One double espresso, coming right up.

    You settle at the counter and take a first sip.

  barista: Enjoy! Anything else before the morning rush?

  choice
    Stay a while and people-watch
      The morning crowd drifts in and out, and the espresso machine hums its steady tune.
    Finish up and head out
      You drain the last sip, wave Alex goodbye, and step out into the morning.

Then the whole application, as a single index.php:

<?php

require __DIR__ . '/loreline/src/autoload.php';

use Loreline\Loreline;

session_start();

$script = Loreline::parse(file_get_contents(__DIR__ . '/story.lor'));

// The story so far, read before running so the finish handler can clear
// the session without losing the final page's transcript
$transcript = $_SESSION['transcript'] ?? [];

// What this request adds
$lines = [];
$options = null;
$finished = false;

// The choice index the player just clicked, if any
$picked = isset($_GET['choice']) ? (int) $_GET['choice'] : null;

$onDialogue = function ($interpreter, $character, $text, $tags, $advance) use (&$lines) {
    if ($character !== null) {
        $name = $interpreter->getCharacterField($character, 'name') ?? $character;
        $lines[] = $name . ': ' . $text;
    } else {
        $lines[] = $text;
    }
    $advance();
};

$onChoice = function ($interpreter, $choiceOptions, $select) use (&$options, &$picked) {
    if ($picked !== null) {
        // Resuming re-delivers the choice the story was waiting on:
        // answer it with the option the player clicked, and the story
        // carries on synchronously from here.
        $index = $picked;
        $picked = null;
        $select($index);
    } else {
        // Nothing to answer with: save, and wait for the next request.
        $options = $choiceOptions;
        $_SESSION['save'] = $interpreter->save();
    }
};

$onFinish = function ($interpreter) use (&$finished) {
    $finished = true;
    unset($_SESSION['save'], $_SESSION['transcript']);
};

if (isset($_GET['restart']) || !isset($_SESSION['save'])) {
    unset($_SESSION['save'], $_SESSION['transcript']);
    $transcript = [];
    Loreline::play($script, $onDialogue, $onChoice, $onFinish);
} else {
    Loreline::resume($script, $onDialogue, $onChoice, $onFinish, $_SESSION['save']);
}

// Keep the whole story so far across requests
$transcript = array_merge($transcript, $lines);
if (!$finished) {
    $_SESSION['transcript'] = $transcript;
}

?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Coffee Shop</title>
</head>
<body>
<?php foreach ($transcript as $line): ?>
<p><?= htmlspecialchars($line) ?></p>
<?php endforeach ?>

<?php if ($options !== null): ?>
<ul>
<?php foreach ($options as $index => $option): ?>
<?php if ($option->enabled): ?>
<li><a href="?choice=<?= $index ?>"><?= htmlspecialchars($option->text) ?></a></li>
<?php endif ?>
<?php endforeach ?>
</ul>
<?php endif ?>

<?php if ($finished): ?>
<p><em>The End.</em></p>
<p><a href="?restart=1">Play again</a></p>
<?php endif ?>
</body>
</html>

Run it with PHP's built-in server and open http://localhost:8080 in a browser:

php -S localhost:8080 index.php

Each page shows the story so far and the pending options as plain links. A few details worth noting: the choice links carry the option's index in the full options array, so disabled options keep the numbering stable. The transcript is kept in the session so the page always shows the whole story, and it is read before the interpreter runs because the finish handler clears the session. And since each request parses story.lor again, edits to the story are picked up on the next click; for larger scripts in production you would cache the parsed script rather than re-parsing per request.

Going further

The same session pattern extends naturally: swap the links for styled forms, store saves in a database keyed by user rather than in the session, or expose the handlers through a JSON endpoint and render from JavaScript. For everything the language itself can do, head over to the Writer's guide.