A location-aware interactive fiction system built on SugarCube 2. Passages unlock by physical proximity, shift their text by compass heading, and evolve over dwell time. The whole story lives in a single .twee file. A browser-based authoring tool lets you place and edit geo nodes on a live map and push directly to GitHub.
Live reader: index.html (root) — compiled SugarCube story
Authoring tool: author/index.html — map-based passage editor
Story source: story/main.twee — single-file Twee source of record
The system runs entirely in the browser. There is no server. GPS and compass data come from the device's native APIs (navigator.geolocation, DeviceOrientationEvent). A 1-second heartbeat loop (setup.geo.heartbeat) continuously checks position, heading, candidate proximity, dwell time, and directional rules, then updates the display DOM directly — no SugarCube passage navigation happens during play.
| Layer | What controls it |
|---|---|
| Active passage | The geo node whose radius the player has dwelled inside long enough |
| Displayed passage | Either the active passage, its @then stage-two variant (after @after ms), or a @dir directional variant |
| Compass | Points toward all geo nodes linked from the active passage (or all Start-linked nodes in pre-active mode) |
| Pre-active mode | Before any passage is dwelled into: compass shows all Start-linked nodes with real GPS bearings as soon as location locks |
/
├── index.html # Compiled SugarCube reader (Twine output, do not hand-edit)
├── story/
│ └── main.twee # All story content — the single source of truth
├── author/
│ └── index.html # Browser-based authoring tool
├── media/ # Images and audio files referenced by @image / @audio
├── vendor/ # Local copies of any vendored JS (SugarCube, etc.)
├── archive/ # Old or experimental builds
└── manifest.json # Web app manifest for PWA / home screen install
Every geo-tagged passage starts with @ directives before its body text. All directives are optional except @geo.
:: PassageName [geo optional-extra-tags]
@geo LAT,LNG,RADIUS_METRES
@image media/filename.jpg
@lede Short italic line shown above the body.
@dwell MILLISECONDS
@facing DEGREES
@after MILLISECONDS
@then OtherPassageName
@audio filename.mp3
@dir START_DEG-END_DEG:TargetPassageName
Body text goes here. Plain prose.
[[LinkedPassage]]
[[AnotherLinkedPassage]]
Links and the compass:
[[PassageName]]links in a geo passage body are what the compass reads.getLinkedTargets()parses them from the passage text at runtime. The authoring tool manages links as a dedicated Linked Passages field — separate from prose body — to ensure they are always serialised correctly.
| Directive | Description | Default |
|---|---|---|
@geo lat,lng,r |
Geographic anchor. r = trigger radius in metres. |
Required for geo tag |
@image media/file |
Image path relative to repo root (e.g. media/photo.jpg). Shown below body text. Managed via Upload button in authoring tool. |
None |
@lede text |
Short italic subtitle rendered above the body. | None |
@dwell ms |
Milliseconds the player must remain inside the radius before the passage activates. | 12000 ms |
@facing deg |
Override the global heading threshold (35°) for this passage's directional visibility. | 35° |
@after ms |
Milliseconds after activation before switching to the @then passage. |
None |
@then Name |
Second-stage passage displayed once @after elapses. |
None |
@audio file |
Audio filename relative to media/. Shown as <audio controls>. |
None |
@dir S-E:Name |
If heading is in the arc S°→E°, display Name instead of the base passage. |
None (multiple allowed) |
- Base passage:
PlaceName— the primary geo node - Stage-two:
PlaceName_late— shown after@afterms via@then - Directional variants:
PlaceName_north,PlaceName_east, etc. — shown via@dir - Tag groups: Add extra tags after
geoto group related nodes (e.g.[geo g8],[geo harbour])
Directional and stage-two passages do not need a @geo directive — they inherit their parent's location context.
Start is a non-geo passage that acts as the story's map index. Every top-level geo node should be linked here with [[PassageName]]. These links drive both the pre-active compass and the initial activation pool — before any passage is dwelled into, only Start-linked geo nodes are eligible candidates, and the compass shows them with real GPS bearings.
The authoring tool also uses the Start passage as the seed for the Passages list — on load, only Start and its directly linked passages appear in the list. See Authoring Tool for details.
:: Start
[[FerryTerminal]]
[[ClockTower]]
[[Waterline]]
Activation eligibility is link-graph-constrained at all times. The engine never scans all geo passages to find a candidate — it only considers the set of passages reachable from the current narrative position.
| State | Eligible activation candidates |
|---|---|
Pre-active (no v.active yet) |
Only geo passages linked from Start |
Active (v.active is set) |
Only geo passages linked via [[...]] from the current active passage |
| No linked geo targets | Empty set — no candidate is chosen; no fallback to all geo passages |
This prevents large-radius "region selector" nodes from re-hijacking activation once a player has entered a downstream passage. A node like Halifax (large radius, linked from Start) can bootstrap the initial area, but once a local node activates, Halifax is excluded from the candidate pool unless the local node explicitly links back to it.
Both activation eligibility (getEligibleCandidatePoints) and compass visibility (refreshVisible) resolve reachable nodes from the same source: getLinkedTargets(v.active.name) post-active, and getLinkedTargets('Start') pre-active. The only intentional asymmetry is the stale compass fallback (v.lastLinked) — stale nodes remain visible in grey between passages but are never returned as activation candidates.
refreshVisiblebuildsv.visiblefrom all Start-linked geo points using real bearings fromv.current- Facing threshold is not applied — all nodes appear regardless of direction
- Compass markers rotate correctly as heading and position update
refreshVisiblebuildsv.visiblefrom geo points linked via[[...]]in the active passage- Facing threshold applied as normal
- If the linked set becomes empty (between passages, heading away),
v.lastLinkedpreserves the last known set with updated bearings — markers remain visible in grey (stale state) until the next passage activates
- Stale markers render grey (
.marker.stale) with a note in the target list - Bearings are recalculated from current position each tick so dots track movement even in stale state
- Stale nodes are not eligible for activation — only compass display
All runtime state lives in a single SugarCube variable initialised in StoryInit.
| Key | Type | Description |
|---|---|---|
watchId |
int / null | navigator.geolocation.watchPosition ID |
current |
{lat, lng} / null |
Latest GPS fix |
heading |
number / null | Device compass heading in degrees |
active |
point object / null | The passage whose dwell threshold has been met |
candidate |
point object / null | Nearest passage inside its radius, not yet dwelled |
candidateSince |
timestamp / null | When the current candidate was first entered |
visible |
array | Geo nodes currently shown on compass |
lastLinked |
array | Last non-empty linked set; used as stale compass fallback between passages (not used for activation) |
unlocked |
array | Names of all passages ever activated (persistent within session) |
displayed |
string / null | Name of the passage currently shown in the UI |
displayedBase |
string / null | Base name (before directional override) |
stageSince |
timestamp / null | When the current displayed stage started |
mode |
string | Human-readable mode label (Idle, GPS live, Simulated walk, etc.) |
dwellMs |
number | Global default dwell time (12000) |
headingThreshold |
number | Global heading cone width in degrees (35) |
gray |
bool | Gray mode filter toggle |
error |
string | Last error message, shown in statusbar |
All engine logic lives in setup.geo.* inside Story JavaScript.
| Function | Purpose |
|---|---|
parse(text) |
Extracts @ directives and body from raw passage text |
points() |
Returns all [geo]-tagged passages with valid coordinates |
getLinkedTargets(passageName) |
Parses [[Link]] syntax from a passage's raw text and returns an array of target passage names. Used by both activation and compass as the single link-resolution path. |
getEligibleCandidatePoints() |
Returns the geo points eligible for activation: pre-active → Start-linked points only; post-active → points linked from v.active; no linked targets → empty array. No fallback to all geo passages after first activation. |
distance(aLat,aLng,bLat,bLng) |
Haversine distance in metres |
bearing(aLat,aLng,bLat,bLng) |
True bearing in degrees |
buildVisibleList(points, preActive) |
Shared helper: compute bearing/relative for a point list; skips facing filter when preActive=true |
chooseCandidatePassage() |
Returns the nearest point inside its radius from getEligibleCandidatePoints(), or null. Activation pool is always link-constrained — never all geo passages. |
updateActiveByDwell() |
Promotes candidate to active once dwell threshold met |
getDwellProgress() |
Returns {name, elapsed, required, pct} for UI feedback |
chooseDirectionalPassage(base) |
Picks a @dir variant matching current heading |
updateDisplayedPassage() |
Resolves active → @then → @dir chain |
refreshVisible() |
Populates $geo.visible; pre-active uses Start links with no facing filter; active uses passage links with lastLinked stale fallback (compass only) |
getShownPassage() |
Returns the parsed passage object currently on screen |
renderPassage() |
Writes title, lede, body, image, audio to DOM |
renderCompass() |
Draws heading arrow and bearing markers from v.visible; grey stale markers when v.visible[0].stale |
refresh() |
Master update: calls all of the above in order |
startGeolocation() |
Starts watchPosition |
enableOrientation() |
Requests DeviceOrientationEvent permission (iOS) and binds listener |
simulate() |
Steps through a hardcoded Halifax walk path for desktop testing |
toggleGrayMode() |
Applies subtle CSS filter |
bindUI() |
Attaches toolbar button listeners (idempotent) |
startHeartbeat() |
Starts 1s interval calling refresh() (idempotent) |
injectAppMeta() |
Injects PWA / iOS home-screen meta tags at runtime |
- Compass shows all Start-linked nodes with real bearings — markers move with heading and GPS
- Facing threshold not applied
- Passage display shows "Waiting for a place"
- Only Start-linked geo nodes are eligible activation candidates
- Passage title shows "Approaching [Name]"
- Body shows dwell progress percentage and countdown in seconds
- Passage title, lede, and body render from the displayed passage
- Compass shows linked nodes filtered by heading threshold
@after/@thenand@dirvariants update silently as time and heading change- Between passages: last linked set shown in grey (stale) until next passage activates
- Only passages explicitly linked from the active passage are eligible as the next candidate
A standalone HTML file — no build step, no server.
- Paste a GitHub Personal Access Token (repo scope) into the token field
- Click Load — parses the live file and places markers on the map
- Click the map to place a new geo passage (name it in the popup)
- Click New Passage in the toolbar to create any passage without a map location — directional variants (
PlaceName_north), stage-two passages (PlaceName_late), or any non-geo passage. Leave thegeotag out of the tags field. - Click a marker to select and edit a geo passage in the Editor tab
- Drag a marker to reposition (lat/lng update live)
- Edit fields in the Editor tab
- Linked Passages field — tap passage buttons under "Add Link" to add
[[links]]; tap ✕ on a chip to remove one. Changes write immediately to the passage data. - Image field — choose a file and click Upload to push it to
media/and attach it to the passage as@image. See Image Upload below. - Click Apply Changes to commit prose/field edits, then add a commit message and click Save
Important: The Linked Passages field is separate from Body Text. Links added here serialise as
[[Name]]lines in the.tweeoutput and are what the compass reads at runtime. Do not manually type[[links]]in the body textarea — use the Add Link buttons.
Activation note: Because activation is link-graph-constrained, any geo passage you want reachable from a given node must be linked from it in the Linked Passages field. Unlinked geo passages inside a player's radius will not activate.
| Method | Use when |
|---|---|
| Click the map | Placing a new geo node at a real-world location |
| New Passage button (toolbar) | Creating directional variants (_north, _east), stage-two passages (_late), Start-index entries, or any passage with no geographic coordinates |
- Passages — filtered list of reachable passages (see below); click to select
- Editor — form-based editor for all
@fields, directional rules, body text, linked passages, and image upload - Raw — live preview of the generated
.tweeblock for the selected passage
The Passages tab shows a context-aware subset of passages rather than the full list:
- On load: seeded from the
Startpassage — onlyStartitself and its directly linked passages appear - On select: when you click a passage, all of its linked passages are added to the visible set permanently (the set only ever grows within a session)
- Show All / Linked Only toggle: a button in the tab header switches between the filtered view and the complete passage list; the label updates to reflect the current mode
This mirrors the engine's own reachability logic and keeps the list focused on the part of the graph you are actively authoring. Use Show All to jump to any passage outside the current linked set.
Every passage has an Image section in the Editor tab:
- Choose a local image file using the file picker
- Click Upload — the tool base64-encodes the file and PUTs it to
media/<filename>in the repo via the GitHub Contents API (using your existing token). If a file with the same name already exists, it is replaced. - A thumbnail preview appears immediately in the Editor
- The passage's
@imagefield is set tomedia/<filename>and the Raw tab updates instantly - Click Apply Changes then Save to commit the updated
.twee(the image file itself is committed tomedia/at upload time, independently of the story save) - Click Remove to detach the image from the passage (does not delete the file from
media/)
Images are stored at media/<filename> and referenced in .twee as:
@image media/filename.jpg
The reader renders the image below the passage body text.
The authoring tool includes PWA meta tags and a mobile-optimised layout. Add to iOS home screen for full-screen use.
The reader is a compiled SugarCube .html file. To update it after editing story/main.twee:
- Open
story/main.tweein Tweego or the Twine desktop app - Compile to
index.htmlin the repo root - Push to
main— GitHub Pages serves the result
Note: GPS and
DeviceOrientationEventrequire HTTPS. GitHub Pages provides this automatically.
Place images and audio in the media/ directory. Reference by path from repo root:
@image media/harbour-terminal.jpg
@audio media/ferry-ambient.mp3
Images can be uploaded directly from the authoring tool's Editor tab without leaving the browser. Audio files must be added to media/ manually or via the GitHub web interface.
- Netscape 95 aesthetic — Windows 95 chrome,
#000080navy, inset borders,<marquee>, Times New Roman body text. - No framework dependencies in the reader — pure SugarCube + vanilla JS. Leaflet is used only in the authoring tool.
- Passage body is prose, not SugarCube markup —
@dirand@thenhandle branching at the engine level. - Active passage persists when leaving a radius — the last resolved passage stays visible until a new one activates.
- Links drive both the compass and activation —
[[PassageName]]in a passage's body/links section is whatgetLinkedTargets()parses, and this is the single source of truth for what is reachable next.
| Passage | Location | Notes |
|---|---|---|
FerryTerminal |
44.6488, -63.5752 | 4 directional variants + late stage |
ClockTower |
44.6476, -63.5728 | North directional variant |
Waterline |
44.6464, -63.5699 | Simple single-stage |
Resolutes Club |
44.6357, -63.5721 | Late stage + east variant; links to Palula |
Irving |
44.6353, -63.5710 | Simple single-stage |
citadel1 |
44.6470, -63.5782 | Simple single-stage |
radstorm |
44.6529, -63.5847 | Tight facing threshold (25°) + north variant |
Oteasare_Gorghe_vessesscen |
44.6363, -63.5721 | 4 directional variants |
G8_WorldTradeCentre |
44.6423, -63.5722 | G8 cluster; late stage + N/S variants |
G8_GrandParade |
44.6480, -63.5749 | G8 cluster; late stage + cityhall/church variants |
G8_Commons |
44.6430, -63.5869 | G8 cluster; late stage |
G8_Harbourfront |
44.6460, -63.5695 | G8 cluster; N/W variants |
G8_CitadelLookout |
44.6478, -63.5790 | G8 cluster; simple |
G8_RoutePoint |
44.6439, -63.5736 | G8 cluster; simple |
Palula |
44.6361, -63.5721 | Linked from Resolutes Club |
- Graph-constrained activation (Phases 2–5):
getEligibleCandidatePoints()introduced as the single gating function for activation candidates. Pre-active: onlyStart-linked geo points eligible. Post-active: only geo points linked from the current active passage. No fallback to all geo passages after first activation. Large-radius region nodes (e.g.Halifax,Sydney) cannot re-hijack activation once a downstream passage is active. getLinkedTargets(passageName)documented as the shared link-resolution path used by bothgetEligibleCandidatePoints(activation) andrefreshVisible(compass). Single source of truth for reachability.chooseCandidatePassage()updated to callgetEligibleCandidatePoints()exclusively — no longer iterates all geo passages.- Stale fallback clarified:
v.lastLinkedis compass-display only; stale nodes are visible in grey but never returned as activation candidates. - Authoring tool: Image upload — Editor tab now includes an Image section for every passage: choose a local file, click Upload to push it to
media/via the GitHub Contents API, preview the thumbnail inline, and detach with Remove. The@image media/<filename>directive is written to the passage and serialised in.tweeautomatically. - Authoring tool: Passages tab linked-only filter — list seeds from the
Startpassage on load; selecting a node permanently expands the visible set with that node's links; Show All / Linked Only toggle in the tab header to switch between filtered and full list - Authoring tool: New Passage button in toolbar — creates any passage without requiring a map click; supports non-geo passages (directional variants, stage-two passages, etc.) with an optional tags field; opens a Win95-style modal dialog; Esc/Enter keyboard shortcuts and backdrop-click to dismiss
- Authoring tool: Dedicated Linked Passages field with chip UI — links serialised as
[[Name]]lines, separated from prose body;parseTweeextracts existing[[links]]from body on load;applyEditsmigrates any inline[[links]]typed in body textarea into the links array automatically - Compass pre-active mode:
refreshVisiblepopulatesv.visiblefrom Start-linked geo points using real GPS bearings as soon as position locks, facing threshold skipped so all nodes appear immediately - Compass stale fallback:
v.lastLinkedpersists the last active linked set; between passages the compass shows these in grey with live-updated bearings so markers never freeze buildVisibleList(points, preActive)shared helper extracts bearing/relative computation into one place.marker.staleCSS class for grey between-passage compass dots- Browse mode (fixed evenly-spaced angles) removed; compass is always bearing-driven
- PWA meta injection,
@image/@audiosupport, dwell progress UI, iOS home-screen layout