I’m proud of Planedrift.app — my interactive fiction app. The initial feedback has been really good and one of the most common questions has been: “Ben, can you extract an Elm library from Planedrift so I can easily make my own interactive fiction client with innovative features?”
Okay, to be honest, no one asked for that. Four people did ask for dark mode. Dark mode can wait.
elm-zmachine is my Z-Machine interpreter — low-level, all bits and opcodes. Sitting above that is the library I’ve extracted from Planedrift, elm-zengine. It has a set of functionality that every online Z-Machine client might need — session management, queued input, transcript management, turn history, save/restore, and an event stream.
It lets you build a very minimal but usable in-browser player in around 100 lines of Elm. I’ve made a minimal client app in the demos folder of the repo and we’ll walk through some of that code below.
Alternatively, you could build something like I did — with a library, to-dos, dialogs, fonts, colors and all the other shiny bits you need for a full client. Planedrift uses this library itself to run everything game-related.
Or you could, and this is my real hope, build something truly extraordinary. I plan to use it to experiment with new features for Planedrift and odd things like if-pal. If you do make something fun, please let me know, I’d love to see it lead to some interesting stuff.
The rest of this post is a bit dry, but it does walk you through some of the code.
You’ll need
- Elm 0.19.1
- A Z-Machine v3 or v5 story file (.z3 / .z5). Infocom’s early catalogue is the canonical source; several stories are freely distributed for personal use.
You might need some familiarity with Elm and The Elm Architecture.
Using it in a new elm project
$ mkdir if-browser && cd if-browser
$ elm init
$ elm install techbelly/elm-zengine
The model
The engine keeps all game state inside an opaque Session value (“opaque” meaning the library doesn’t let you poke inside it — you just hold on to it and hand it back to ZEngine when it wants). A very simple model might be something like:
type alias Model =
{ session : Maybe Session
, input : String
, error : Maybe String
}
The messages
In the demo app, we have messages to support the app and one for the engine bookkeeping:
type Msg
= OpenClicked
| FileChosen File
| BytesLoaded Bytes
| InputChanged String
| Submit
| EngineMsg ZEngine.Msg
Update
OpenClicked ->
( model, Select.file [] FileChosen )
FileChosen file ->
( model, Task.perform BytesLoaded (File.toBytes file) )
-- `ZEngine.new` is how you make a new session. --
BytesLoaded bytes ->
ZEngine.new EngineMsg ZEngine.defaultConfig bytes
|> ZEngine.apply mergeEngine { model | error = Nothing }
InputChanged s ->
( { model | input = s }, Cmd.none )
-- ZEngine.sendLine queues a line of input ---
Submit ->
if String.isEmpty (String.trim model.input) then
( model, Cmd.none )
else
ZEngine.sendLine EngineMsg model.input model.session
|> ZEngine.apply mergeEngine { model | input = "" }
-- ZEngine.update handles any messages that need to go to the library ---
EngineMsg engineMsg ->
ZEngine.update EngineMsg engineMsg model.session
|> ZEngine.apply mergeEngine model
A couple of things to note — ZEngine.new takes a Config type. For now, we use ZEngine.defaultConfig — it tells the engine to auto-handle things like “press any key” and ignore “save” and “restore.”
Every session-advancing call in ZEngine — new, update, sendLine, and the more specialized sendChar, sendSaveResult, sendRestore, restoreFrom, and resumeFrom — returns the same thing:
type alias Step msg =
{ session : Maybe Session
, events : List Event
, cmd : Cmd msg
, error : Maybe Error
}
Of note is the events list. These are things the engine noticed during this step (a prompt was issued, the game ended, the status line changed). A minimal client can ignore these; a fancier one listens for GameOver or PromptIssued to play sounds, scroll, auto-save, etc.
ZEngine.apply is a helper that turns a Step into a ( Model, Cmd Msg ) tuple that you can just return from update, given a merge function of your own:
mergeEngine : Maybe ZEngine.Session -> Maybe ZEngine.Error -> Model -> Model
mergeEngine session error model =
{ model
| session = session
, error = Maybe.map ZEngine.errorMessage error
}
The merge function is the one piece of boilerplate you write per app because ZEngine can’t know what your Model looks like.
Rendering the transcript
The session holds a List Frame that represents the game transcript. Each frame is either output the game produced or a command the player entered:
viewTranscript : Model -> Html msg
viewTranscript model =
case model.session of
Just session ->
pre [ style "white-space" "pre-wrap", style "line-height" "1.4" ]
(List.map viewFrame (ZEngine.transcript session))
Nothing ->
p [] [ text "Load a .z3 story to begin." ]
viewFrame : Frame -> Html msg
viewFrame frame =
case frame of
OutputFrame data ->
text data.text
InputFrame data ->
span [ style "color" "#06c" ] [ text ("\n> " ++ data.command ++ "\n") ]
Because the transcript lives on the session and every step returns a new session, you never have to accumulate output yourself. Re-render from ZEngine.transcript session on every view call and you’re done. In practice, the Elm DOM diffing will make that plenty performant.
Other properties
In the demo app, you’ll see we also grab other properties from the Session — like the statusLine and the story title. See the docs for more.
What we skipped
The minimal client ignores several other ZEngine features:
- events: Listening to
PromptIssued,GameOver,TurnCompleted,StatusLineChanged, andTitleDetected. UseZEngine.foldEventsto thread a(Event -> Model -> ( Model, Cmd Msg ))handler through the step’s event list. - sendChar / sendSaveResult / sendRestore: The engine surfaces three other prompt types (single character input, in-game save, in-game restore).
defaultConfigauto-handles them; a real client can receive them instead by customizing Config. - Persistence:
ZEngine.snapshot,encodeSnapshot,snapshotDecoder,restoreFromlet you serialize a session to JSON, store it in localStorage or a file, and restore later. Skipped here. - turnHistory / resumeFrom: the engine records a per-turn checkpoint. You can offer “rewind to an earlier turn” with
resumeFrom.
If you’re planning to make a version of Planedrift.app that supports Dark mode, you’ll probably need all these bits too.