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:
src/: the publicLoreline\classes and the autoloaderlib/: the Loreline runtimecomposer.json: package metadata, so the folder also works as a Composer path repository
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.