Duplex logoDuplex Docsv1.3.1
Twine 2 story format · 1.3.1

Two ways to tell
one story.

Switch between a scrolling transcript and page-by-page passages without changing your authoring syntax.

Get started
Setting sun joined to a moon
One syntax

Variables, macros, widgets, links, audio, and controls.

Two modes

Append passages or replace the current page.

Two sidebars

Independent left and right UI bars.

Getting started

Install Duplex

Twine 2 Desktop

  1. Extract the package somewhere permanent.
  2. Open Story Formats → Add a New Format.
  3. Enter the full file URL to format.js.
  4. Select Duplex 1.3.1.
Linux example
file:///home/you/Twine/duplex-format/format.js

Browser Twine may block local file URLs. Use Twine Desktop or host the format online.

Tweego

Project layout
project/
├── storyformats/
│   └── duplex-format/
│       └── format.js
└── story.twee
Shell
tweego --list-formats
tweego -f duplex-1.3.1 -o story.html story.twee

StoryData

Twine
:: StoryData
{
  "ifid": "YOUR-STORY-IFID",
  "format": "Duplex",
  "format-version": "1.3.1",
  "start": "Start"
}
The defining feature

Choose the display mode

Scrolling

Append mode

New passages appear beneath earlier passages as a transcript.

Twine
:: StoryInit
<<set $passageMode = "append">>
Page-by-page

Replace mode

Navigation clears the previous passage before showing the next.

Twine
:: StoryInit
<<set $passageMode = "replace">>

Story JavaScript may set Config.displayMode. This takes priority over story variables.

Story JavaScript
Config.displayMode = "append"; // or "replace"
Authoring

Core syntax

[[Passage]]Basic passage link.
[[Label->Passage]]Labeled passage link.
[[Label|Passage]]Alternate labeled-link form.
$nameStory variable; may print naked.
_nameTemporary passage variable.
<<set>>Assign or modify a variable.
<<print>> / <<=>>Evaluate and print.
<<unset>>Delete variables or properties.
Twine
<<set $name = "Nest">>
<<set $visits to 0>>
<<set $visits += 1>>
Hello, $name. Visit <<= $visits>>.
<<unset $temporaryChoice>>

Operators

is, is not, isnot, eq, neq, gt, gte, lt, lte, and, or, and not, plus ordinary JavaScript operators.

Branching and code

Logic and scripting

If / elseif / else

Twine
<<if $keys > 1>>
  Several keys.
<<elseif $keys is 1>>
  One key.
<<else>>
  No keys.
<</if>>

Switch / case / default

Twine
<<switch $weather>>
<<case "sunny">>
  The path glows.
<<case "rain" "storm">>
  Water darkens the trail.
<<default>>
  Uncertain skies.
<</switch>>
<<run expression>>Execute code without output.
<<script>>…<</script>>Execute JavaScript.
<<script TwineScript>>Execute translated Twine-style code.
Twine
<<run $score += 10>>
<<script>>
  State.variables.seenBird = true;
  output.append("Recorded.");
<</script>>
Player input

Interactive macros

<<button>>Button that executes its body and may navigate.
<<link>>Text link that executes its body and may navigate.
<<linkappend>>Single-use link that appends its body.
<<linkprepend>>Single-use link that prepends its body.
<<linkreplace>>Single-use link replaced by its body.
<<checkbox>>Checkbox bound to a story variable.
<<radiobutton>>Radio control bound to a story variable.
<<cycle>>Link that cycles through <<option>> values.
<<listbox>>Select menu using <<option>> or <<optionsfrom>>.
<<numberbox>>Numeric input; optional Enter-key passage.
<<textbox>>Single-line input; optional Enter-key passage.
<<textarea>>Multi-line text input.
Twine
<<button "Take feather">>
  <<set $hasFeather = true>>
<</button>>

<<cycle "$weather" autoselect>>
  <<option "Clear" "clear">>
  <<option "Rain" "rain">>
<</cycle>>

<<textbox "$name" "Nest">>

Cycles and listboxes support <<option>> and <<optionsfrom>>.

Media

Audio macros

<<cacheaudio>>Register a track and its sources.
<<audio>>Control tracks or groups.
<<createaudiogroup>>Collect tracks under a :group ID.
<<createplaylist>>Build a playlist.
<<masteraudio>>Control master audio.
<<playlist>>Control a playlist.
<<removeaudiogroup>>Remove a custom group.
<<removeplaylist>>Remove a playlist.
<<waitforaudio>>Begin loading registered audio.

Setup

Twine
<<cacheaudio "forest" "audio/forest.mp3" "audio/forest.ogg">>
<<cacheaudio "owl" "audio/owl.mp3">>

<<createaudiogroup ":ambience">>
  <<track "forest">>
  <<track "owl">>
<</createaudiogroup>>

Actions

playpausestoploadunloadmuteunmuteloopunloopvolume leveltime secondsfadeinfadeoutfadeto levelfadeoverto seconds levelgoto passage
Twine
<<audio "forest" volume 0.5 loop play>>
<<audio ":ambience" fadeout>>
<<playlist "night" shuffle play>>
<<masteraudio volume 0.8>>

Native browser autoplay restrictions apply. Audio playback state is not stored in saves.

Interface

UI and sidebars

Left bar

  • StoryCaption
  • StoryMenu
  • StoryLeftBar

Right bar

  • StoryRightBar
  • Current passage
  • Current display mode
Story JavaScript
UIBarL.stow();
UIBarL.unstow();
UIBarL.hide();
UIBarL.show();

UIBarR.stow();
UIBarR.unstow();

Both bars implement destroy(), hide(), show(), isHidden(), isStowed(), stow(), unstow(), and update().

Aliases: UIBar, UIBarRight, UIBars.left, and UIBars.right.

UI API

UI.alert(message)UI.restart()UI.saves()UI.settings()UI.update()UI.jumpto()UI.share()

Duplex includes one lightweight browser save, not SugarCube’s complete multi-slot Save API.

Reusable macros

Widgets

Define custom macros in passages with the widget tag. They load before StoryInit.

Ordinary widget

Twine
:: Bird Widgets [widget]

<<widget "birdname">>
  The bird is <<= _args[0]>>.
<</widget>>

<<birdname "Common nighthawk">>

Container widget

Twine
:: Bird Widgets [widget]

<<widget "birdnote" container>>
  <aside><<= _contents>></aside>
<</widget>>

<<birdnote>>Active at dusk.<</birdnote>>
_args[0]First parsed argument.
_args.rawRaw argument string.
_args.fullFull argument compatibility value.
_args.nameInvoked widget name.
_contentsContainer widget contents.
$argsDeprecated compatibility alias.
Passage metadata

Check passage tags

In StoryInit, explicitly name the passage because no ordinary passage is rendering yet.

Twine
<<if tags("Start").includes("scrolling")>>
  <<set $passageMode = "append">>
<<elseif hasTag("Start", "pages")>>
  <<set $passageMode = "replace">>
<<else>>
  <<set $passageMode = "replace">>
<</if>>

Inside an ordinary rendering passage, omit the title:

Twine
<<if hasTag("forest")>>
  You are beneath the trees.
<</if>>
Current tags: <<= tags().join(", ")>>

Unknown passages return an empty tag array. JavaScript may use Duplex.tags() and Duplex.hasTag().

Extension

JavaScript API

Duplex.go(title)Navigate to a passage.
Duplex.back()Restore the previous history moment.
Duplex.restart()Reset and return to the start.
Duplex.mode()Return append or replace.
Duplex.tags(title?)Return a passage’s tags.
Duplex.hasTag(title?, tag)Test one passage tag.
Duplex.audioNative audio registry.
Duplex.UIDialog and UI API.
Duplex.UIBarL / UIBarRSidebar controllers.
Duplex.widgetsRegistered widgets.

Globals include State, setup, Config, SimpleAudio, UI, UIBarL, and UIBarR.

At a glance

Reference and limits

Special passages

StoryInitStartup initialization.
StoryCaptionLeft-bar caption.
StoryMenuLeft-bar menu.
StoryLeftBarCustom left content.
StoryRightBarCustom right content.

Included facilities

  • Story JavaScript and Stylesheet
  • widget-tagged passages
  • Back and Restart
  • Single browser save
  • Responsive dual sidebars
  • Twine 2 and Tweego packaging
Compatibility boundary

Duplex is not SugarCube or Harlowe. It implements a selected SugarCube-like authoring surface, not every macro, API, save feature, edge case, or third-party extension.

LicenseMIT

Copyright © 2026 Neston Linden Rainer