Experiment builder reference

Every configuration card on the story settings page, explained in depth. Each section below describes what a card does, when you'd use it, every field it exposes, and the gotchas that have surprised researchers before. If you just want to scan the platform, the landing page has the short version.

Workspace & collaborators #workspace

What it does

Every story belongs to one user — the creator. The creator can grant other registered users co-owner access so a small team can run a study together without sharing a login.

When to use it

  • You're co-running a study with another researcher and they need to download data or replace HTML.
  • You're handing a study over to a colleague — add them as co-owner, confirm they can access it, then remove yourself with "Leave this study".

Roles & permissions

ActionCreatorCo-owner
Rename the studyYesYes
Edit completion behaviour, conditions, behavioural & multimodal settingsYesYes
Replace the HTML / upload assetsYesYes
Download dataYesYes
Add or remove collaboratorsYesNo
Delete the studyYesNo

Gotchas

Co-owners can't reassign ownership. Only the creator can. If the creator leaves the organisation, ask an admin to update ownership before the account is disabled.
Identifying a collaborator. The Add field accepts either a username or email. If both match different accounts, username wins — type the email explicitly to disambiguate.

Assets #assets

What it does

Hosts the static files your Twine story references — images, audio, video, custom CSS, anything you'd load with a relative URL. Files are served at /play/<slug>/assets/ and the platform injects a <base> tag into your HTML so paths like img/Logo.png resolve correctly.

When to use it

  • Your Twine uses any external media (images, sounds, fonts, etc.).
  • You want to keep your .html small by externalising large media.

Fields

FieldNotes
Pick filesMulti-select individual files. Folder structure is not preserved — all files land at the root of the assets dir.
Pick a whole folderUses the browser's directory picker (webkitdirectory). Subfolder structure is preserved. Use this for stories with organised img/, audio/ trees.

Gotchas

Per-file cap is 10 MB. Configurable via MAX_ASSET_FILE_MB on the server if you self-host; the default suits Prolific-scale audio/image work.
Same-name uploads overwrite without warning. Re-uploading img/logo.png replaces the existing file silently. Useful for iteration; surprising if you forgot.
Folder uploads use webkitRelativePath. Firefox and Safari support this; some older Linux distros' Firefox builds don't. Use the flat picker if a researcher reports the directory option doesn't surface a picker.

Tracker contract #tracker-contract

What it does

Every story you upload gets a small JavaScript block injected at the bottom of its HTML before it's served to participants. That block — the tracker — is what turns a stand-alone Twine export into a tracked experiment. It reads the participant's id from the URL, resumes their save if they're returning, records every passage they visit, every click and form change they make, and ships that back to the platform. The contract describes exactly what the tracker captures and what it sends — so you can audit, plan analysis, and design URLs against a fixed surface.

How it gets into your HTML

  • Injected automatically when you upload a story (or add / replace a condition's HTML).
  • Re-injection is idempotent: re-running it on already-tracked HTML is a no-op byte-for-byte.
  • The Tracker version card on the story page can re-inject the latest tracker into existing HTML without re-uploading — your sessions are preserved.
  • Two HTML elements are added, immediately before </body>: a <style id="tracker-hide"> that hides the page until resume completes, and a <script id="trip-hustlers-tracker"> that contains the tracker code.

URL parameters the platform reads

The platform accepts the participant id under any of the names below. Whichever you supply, the play route normalises the URL with a 302 redirect so the tracker always sees a canonical User_ID (or PROLIFIC_PID).

ParameterWhat it does
PROLIFIC_PIDProlific's participant id. Highest priority — if present, it's the canonical id.
Warwick_ResponseIDQualtrics-style response id from Warwick deployments. Accepted but not normalised; tracker still reads PROLIFIC_PID / User_ID after redirect.
User_IDGeneric participant id. Used when PROLIFIC_PID isn't present.
user_idLowercase fallback. The platform 302-redirects to the URL with User_ID set.
conditionMulti-arm assignment. Optional; for multi-arm stories the platform picks one and stickies the assignment if you omit it.
survey_donePlatform-internal flag set when returning from a configured pre-survey. Don't set it yourself.
face_previewQA-only. Append ?face_preview=1 to see a live preview of the multimodal landmarker. Don't ship it to participants.
If no id is provided, the platform generates oneU_ + 16 hex chars — and 302-redirects so the URL the participant sees and bookmarks contains it. The tracker then treats this id as not anonymous and resume works as normal. The word "anonymous" in the tracker code refers to a separate edge case: if a participant somehow lands on a URL with no id parameters AND the platform's redirect doesn't fire (e.g. JavaScript-only embed), the tracker generates its own anon_ id and skips resume. In practice you should never see that path.

Endpoints the tracker calls

Method · PathWhenPurpose
GET /resume_twine_data Once, on page load, in parallel with SugarCube boot. Fetches the participant's most recent save_blob for this story. Returns { save_blob, passage_index, session_created_at } or 404.
POST /upload_twine_data On every :passagedisplay that advances passage_index; via navigator.sendBeacon on tab-hidden and page-unload. Upserts the participant's session document. Idempotent — replays of the same passage are dropped.
POST /upload_twine_events Periodic flush (default every 5 s) when behavioural or multimodal capture is on; via beacon on tab-hidden and page-unload. Append-only stream of high-volume events (mouse, hover, scroll, key timing, idle, face blendshapes). Disabled by default.

What the tracker uploads (the payload)

Every /upload_twine_data POST carries a JSON body with these fields. The server upserts on (story_id, user_id), so a returning participant keeps the same row.

FieldWhat it carries
user_idThe id resolved from the URL (or platform-generated).
conditionThe condition from the URL, or "" for single-arm.
passage_indexSugarCube's State.length at the moment of upload. The server's regression guard refuses to clobber a higher value with a lower one.
save_blobBase64-encoded SugarCube save state (Save.base64.save()). Falls back to Save.serialize() on older engines.
click_historyArray of every passage the participant has visited so far in this session — { title, variables, t, t_wall }.
focus_historyArray of every tab-visibility change since page load — { t, t_wall, type, isVisible }. Server appends, so each upload only sends new entries.
decisionsArray of clicks on story links/buttons captured by the delegation handler — { t, t_wall, from, text, target }. Replaces on each upload (no append).
inputsArray of form-field changes inside the story — { t, t_wall, passage, name, type, value }.
reasonWhy the upload fired: passage (normal), hidden (tab backgrounded), pagehide (unload). Useful for filtering dropout analytics.

Resume flow

  • On load, the tracker fetches /resume_twine_data?user_id=... in parallel with SugarCube booting.
  • It waits until SugarCube has rendered its first passage and the resume call has returned.
  • If a save_blob is present, the tracker calls Save.base64.load() (or the legacy Save.deserialize()) and forces a re-render with Engine.show() — so the participant lands on the passage they left, not the start passage.
  • The hider <style> is removed once resume is settled. Until then, the page is blank — so a returning participant never sees a flash of the start passage before their save loads.
  • If multimodal capture is enabled with Require successful capture, resume also waits for the camera + landmarker to come up. A capture-required overlay covers the page if they don't.

Engine requirement

  • SugarCube v2.37+. Uploads of Harlowe or Chapbook stories are rejected at upload time (the platform checks <tw-storydata> + the application-name="SugarCube" meta).
  • jQuery is required and SugarCube ships it; nothing for you to add.
  • The tracker waits for window.SugarCube.State and window.SugarCube.Save before attaching any handlers. Stories that load slowly or stall on assets still get full instrumentation once SugarCube finishes booting.

What the tracker does not do

  • No keystroke contents. When behavioural keystroke timing is enabled, only an inter-keystroke category (printable, backspace, whitespace, control) is recorded. The actual key value is never sent. If you need free-text answers, use a story <input> and read it from inputs.csv.
  • No camera footage. Multimodal capture sends 52 numeric blendshape scores per frame. No image, no video, no facial landmark coordinates leave the participant's browser.
  • No IP, no fingerprint, no third-party calls. The tracker talks only to the platform host you've deployed.
  • No mutation of the story HTML beyond the injection block. Your Twine, your assets, your CSS — all untouched.

Server-side guarantees on the upload

  • One row per (story, user_id). Replays of the same passage_index don't create duplicate sessions.
  • Regression guard. When a returning participant's first upload looks like a boot render (lower passage_index than what's already stored), the server keeps the prior save_blob and only touches updated_at.
  • completed_at is stamped exactly once. The first upload whose click_history contains a configured ending-passage title sets it; further uploads never re-stamp or clear it. The per-arm quota counts off this field.
  • focus_history is additive. Each upload's entries are appended to the server-stored array, with a received_at stamp.
  • decisions and inputs snapshot on every upload — the server stores the latest version each time, with received_at preserved from the first sighting of a given index so the timestamp doesn't drift.

Behavioural + multimodal extension

When you enable behavioural or multimodal capture for a story, the same tracker installs extra collectors and starts streaming to /upload_twine_events on a separate cadence (default every 5 s, plus beacons on tab hide / unload). Events live in a separate collection from the main session, so they don't bloat the session document or the save_blob. They show up as events.jsonl / events.csv in the downloaded data. See Behavioural measurements and Multimodal capture for the per-channel details.

Gotchas

Don't edit the injected block by hand. The platform re-injects on re-upload, on condition replace, and via the Tracker version refresh button. Manual edits will be silently overwritten. If you need to change tracker behaviour, edit src/tracker/inject.html in the platform source and re-deploy.
Resumed-prefix uploads carry partial timestamps. The tracker only stamps passages it actually witnessed. When a participant returns, the first upload after resume has t / t_wall = null on every entry that came from the loaded save. The server merges new timestamps in where present; older slots stay null.
The condition param is trusted unfiltered. If a participant edits their URL to switch arms, the platform's sticky-assignment table will lock them to the first arm they saw — but only if they previously landed without a condition. If you hand out per-arm URLs directly, the URL is authoritative on each visit.
Beacon uploads on unload are best-effort. navigator.sendBeacon is not guaranteed by the spec; mobile Safari has historically dropped beacons under memory pressure. Treat the :passagedisplay uploads as authoritative for completion signal; treat hidden / pagehide uploads as bonus dropout data.

Tracker version #tracker-status

What it does

The platform injects a small JavaScript block ("the tracker") into every uploaded story. When the platform's tracker code changes — to fix a bug or add a feature — older stories keep their old injected code until you re-inject. This card shows whether your study is on the latest tracker and gives you a one-click refresh.

When to use it

  • The card shows a vermillion "outdated" message.
  • You've read release notes that mention a tracker change relevant to your study.

Gotchas

Re-injection is idempotent and preserves sessions. Participant saves aren't touched; the HTML body and asset paths are unchanged. The only thing replaced is the injected <script id="trip-hustlers-tracker"> block.
Multi-arm studies refresh per arm. If you have conditions, each arm has its own injected HTML. The refresh button re-injects all of them in one go.

Replacing the HTML #replace

What it does

Swaps the story's HTML in place. The slug, the public URL, every collected participant session and assignment are preserved — only the story content is replaced.

When to use it

  • You found a typo in your Twine and re-exported.
  • You're polishing dialogue mid-study but don't want to disturb in-progress participants.

Gotchas

Behaviour changes once conditions exist. The whole-story Replace card disappears once you've added conditions. Use the per-condition Replace button in the Conditions table instead.
Existing saves resume against the new HTML. If you renamed a passage, participants whose save references the old passage name may break. Test the resume carefully before pushing a structural change mid-study.

Conditions (multi-arm) #conditions

What it does

Lets one study serve multiple HTML variants — one per experimental arm. Each condition gets its own injected HTML, its own public play URL (?condition=<name>), and optionally its own completion behaviour, weight (for randomization), and quota (cap on completed participants).

When to use it

  • You're running a between-subjects experiment with two or more arms.
  • You want one study, one data export, but different HTMLs per arm.
  • You need quota balancing across arms (e.g. exactly 50 completions each).

Converting single → multi

The first time you add a condition, the form asks for a name to give the existing HTML (e.g. control) and lets you upload the second arm. The existing HTML is reused as-is — no re-injection, no copy.

Fields

FieldType / DefaultWhat it controls
Name[a-zA-Z0-9_-]{1,32}Used in the URL: ?condition=<name>. Pick something short and human-readable.
Weight0–1000 · default 1Relative weight used by weighted randomization. Ignored under uniform / sequential.
Quota0–1,000,000 · 0 = no capMaximum completed participants in this arm. Once reached, new arrivals to this arm are sent to the over-quota URL.
Replace.html onlySwap this arm's HTML. Slug and sessions preserved.
DeleteRemoves the arm and its HTML. Existing sessions and assignments are kept but the participants assigned to it will be re-randomized on next visit.

Randomization mode

ModeHow it picks
uniformEach arm equally likely. Weights ignored.
weightedProbability proportional to each arm's weight. Use this for unequal allocation (e.g. 3:1 treatment-to-control).
sequentialRound-robins arms in declaration order. Useful for tight, deterministic balance early in a study.

Per-arm completion override

Each row in the Conditions table can expand a "Customize completion" drawer to override the study-wide completion settings (endings, action kind, debrief text, redirect URL, delay, max session, hide UI bar) for just that arm. Tick "Override study-wide completion config" to make the override active.

Gotchas

Assignments stick. Once a participant hits the play URL and gets randomized, their (user_id, story) pair is locked to that arm. They'll see the same arm if they return — even if you change weights or modes.
Deleting an arm doesn't delete its data. Sessions and assignments to that arm stay in the database; you'll still see them in your export. New visits by re-assigned participants come in fresh against a different arm.
Hand-out URLs. If you're pasting links into Prolific studies, hand out the per-condition URL (the one in the table) — not the bare play URL — so each Prolific cohort goes to the right arm.

Pre-survey #pre-survey

What it does

Sends participants to an external survey before the Twine experience starts. When a participant arrives at the play URL, the platform forwards every query parameter they brought (PROLIFIC_PID, condition, User_ID, …) to the survey URL, plus an extra redirect_url that points back to the Twine with survey_done=1 appended. The survey is responsible for redirecting to redirect_url on submission — otherwise the participant never reaches the Twine.

When to use it

  • You collect demographics or consent on Qualtrics / SurveyMonkey before play.
  • You need a screening question (age, attention check) before the participant burns time on the story.

Fields

FieldNotes
Pre-survey URLThe anonymous link to your survey. Don't append your own query string — the platform adds the params for you.

Qualtrics setup (most common)

  1. Open the survey in the editor → Survey Flow.
  2. Click Add a New ElementEmbedded Data. Move the new block to the very top of the flow.
  3. In that block, add fields named exactly: redirect_url, PROLIFIC_PID, User_ID, condition. Leave the values empty — Qualtrics auto-fills them from the URL when "Value will be set from Panel or URL" is the default. Save the flow.
  4. Go to Survey OptionsSurvey Termination (or End of Survey in newer UI) → choose Redirect to a URL.
  5. Paste this exactly into the URL box: ${e://Field/redirect_url}
  6. Publish the survey and copy the anonymous link into the Pre-survey URL field above.

SurveyMonkey

Use the Custom Variables feature to capture redirect_url from the link, then set Survey Ending → Redirect to your own webpage with the value [redirect_url].

Google Forms

Forms doesn't support outbound redirects natively. You'd need an Apps Script add-on (a Form Submit trigger that calls HtmlService to redirect). Use Qualtrics or SurveyMonkey instead if you can.

Custom survey / web form

Read redirect_url from window.location.search on page load, store it, and on submit do window.location.assign(redirect_url).

Gotchas

Always test before launching. Open the play URL with a fake Prolific ID (e.g. ?PROLIFIC_PID=TEST123), complete the survey, and confirm you land on the Twine. The classic failure: the survey redirects to a literal ${e://Field/redirect_url} string — that means Qualtrics didn't capture the embedded-data field. Re-check step 3.
What gets forwarded. The platform appends every URL parameter the participant arrived with, plus redirect_url (URL-encoded). Your survey only needs to act on redirect_url; the rest are captured for your analysis.

Completion quota #over-quota

What it does

Caps the number of completed participants. Once the cap is hit, new participants are redirected to the over-quota URL (typically a Prolific "study full" completion code) instead of seeing the Twine. Participants who already started can always come back and finish.

When to use it

  • You're paying per participant and need a hard ceiling.
  • Your Prolific study has a fixed sample size and you want over-recruited participants to receive a completion code instead of starting the study.

Fields

FieldNotes
Over-quota URLWhere to send participants once the cap is met. Supports templates: {user_id}, {USER_ID}, {PROLIFIC_PID}, {condition} (case-insensitive). For backward compatibility, user_id and condition are also appended as query params; no other tracker params are forwarded.
Target completionsSingle-arm studies only. The cap. 0 means no cap. Per-condition caps live in the Conditions table instead.

Gotchas

"Completed" means reaching an ending passage. A participant who closes the tab mid-story is not counted. Configure ending passages in the Completion behaviour card so the count is accurate.
Per-condition caps replace the story-level cap. Once you have conditions, the story-level Target completions field is hidden — the per-condition Quota fields in the Conditions table are the source of truth.
In-progress participants always finish. The cap only redirects new arrivals. Resume by an in-progress participant is never gated.

Completion behaviour #completion

What it does

Defines what happens when a participant reaches one of the configured "ending" passages: stay on the page, show a debrief, or redirect to an external URL (typically the Prolific completion link).

When to use it

  • You're using Prolific and need to forward participants to a completion URL.
  • You want a debrief screen with the study description, links to literature, or contact info.
  • You need to cap session duration (e.g. fire completion after 30 minutes regardless of progress).

Ending passages

One passage title per line. The builder offers two helpers: a "Detected endings" chip row (passages no other passage links to — likely terminal nodes) and an "All passage names" picker. Click a chip to append it to the list.

Action fields

FieldType / DefaultWhat it controls
Kind = noneDo nothing. Participant stays on the ending passage. Useful for self-paced studies.
Kind = debriefShow the debrief text on a clean page. Plain text or basic Markdown.
Kind = redirectRedirect to the URL below. Use this for Prolific completion.
Debrief texttextareaPlain text or basic Markdown. Used only when kind = debrief.
Redirect URLURLUsed only when kind = redirect. Supports templates: {user_id}, {USER_ID}, {PROLIFIC_PID}, {condition}. Case-insensitive.
Delay (seconds)0–3600 · default 10How long to wait after the ending passage is reached before firing the action. Gives participants time to read the final passage.
Max session (minutes)0–1440 · 0 = unlimitedHard timer on the session. Once exceeded, the completion action fires automatically — even if no ending passage was reached. Survives reload & resume.
Hide UI barcheckboxHide SugarCube's default UI bar (history controls, restart link, etc.). Recommended for clean Prolific-style experiments.

Gotchas

An ending passage isn't a SugarCube concept. It's whatever passage title you put in the list. Detected endings (passages with no incoming link) are usually right — but if you have a hub passage that's reachable from multiple endings, the detector will list it as terminal even though it isn't.
Per-arm overrides live in the Conditions card. The completion settings in this card are the study-wide default; conditions can override them individually.
Redirect URL templates are case-insensitive. {user_id}, {USER_ID}, {User_ID} all expand to the same value. Pick whichever matches your downstream system's convention.

Behavioural measurements #behavioral

What it does

Captures rich behavioural signals — high-precision reaction time, mouse trajectory, hover dwell on choices, window focus / idle, scroll, keystroke timing — beyond the basic passage and choice history. Off by default. When enabled, all channels turn on unless you've already picked individual ones.

When to use it

  • You're running a study where reaction time matters (e.g. forced-choice paradigms).
  • You want to detect distraction or off-task behaviour (idle, blur, tab switches).
  • You're studying hesitation / hover patterns before choice clicks.

Channels

ChannelWhat it captures
reaction_timeHigh-precision performance.now() timestamps for passage display and choice click.
mouse_trajectoryMouse position samples while the participant moves over a passage. Sampled at the rate below.
hover_dwellHow long the cursor sits on each choice link before clicking (or not).
rich_focusWindow blur, focus, and idle detection events.
scrollScroll position events (debounced).
keystroke_timingTiming of keypresses on text inputs. Timing only — never the keys typed.

Parameters

ParameterDefaultWhat it controls
mouse_hz20Sample rate for mouse trajectory in Hz. 20 Hz = sample every 50 ms.
mouse_cap_per_session30000Max number of mouse samples per session. Hard ceiling to keep payload small.
flush_interval_ms5000How often the client batches events to the server. sendBeacon body limit is ~64 KB; keep ≥ 1000.
hover_min_ms150Minimum hover duration (ms) before a hover counts. Filters out cursor pass-throughs.
idle_threshold_ms30000How long without input before the session is considered idle (ms).
scroll_debounce_ms200Debounce window for scroll events (ms). Lower = denser sampling, larger payload.

Gotchas

Keys typed are never recorded. The keystroke-timing channel records only timestamps and key-up/key-down event categories — never the character. This is a privacy guarantee built into the platform; not a setting you can flip.
The first-enable convenience. Ticking "Enable behavioural measurements" for the first time turns on all channels (since most researchers want them all). Untick the ones you don't need before saving.
flush_interval_ms is bounded by sendBeacon body size. A 64 KB beacon body limit means very small flush intervals can drop events under heavy load. The default of 5000 ms is comfortable for typical studies.

Multimodal capture #multimodal

What it does

Streams MediaPipe Face Landmarker blendshape scores (52 expression dimensions per frame) from the participant's webcam during play. No video or image data leaves the browser — only the numerical blendshape vectors are uploaded.

When to use it

  • You're running affect / emotion research and need per-frame expression signals.
  • You want a low-bandwidth alternative to full video recording for facial analysis.

Fields

FieldDefaultWhat it controls
EnabledoffMaster switch. The browser will prompt for camera access on play.
Require successful captureoffIf on, participants who deny camera access see a "Try again" error page instead of being allowed to play uncaptured.
sample_hz10How often blendshapes are computed (Hz). 10 Hz = every 100 ms.
max_samples_per_session30000Hard ceiling on samples per participant session.
flush_interval_ms5000How often the client batches samples to the server (ms).

Gotchas

Consent is your responsibility. The platform does not show a consent screen for camera access. You must obtain participant consent in your pre-survey, Prolific description, or Twine intro passage under your IRB / ethics protocol. The builder shows a confirmation dialog the first time you enable this — but that's a reminder for you, not a substitute for participant consent.
What is uploaded. Only the numerical blendshape scores (52 floats per frame) and timestamps. No video frame, no still image, no facial landmark coordinates.
Browser support. MediaPipe needs WebAssembly + camera access in a secure context. Older browsers without WASM SIMD will be much slower. Test on a few device classes before launching.

Downloaded data #export

What it does

The Download data button on the dashboard (and the per-study page) packages everything the platform has collected for that story into a single ZIP file named <slug>-<timestamp>.zip. The same archive is produced for admin exports at /admin/studies/:id/export, with the action recorded in the audit log. The export is generated on demand from the current database state — there's no nightly snapshot or cached copy, so you always get the latest.

What's inside the ZIP

FileFormatGranularityUse for
sessions.json JSON One object per participant, with every nested array intact. Complete fidelity; load into a notebook for any analysis.
sessions.csv CSV One row per participant, summary counts only. Quick spreadsheet view of who took part and how far they got.
clicks.csv CSV One row per passage visit (flattened click_history). Path analysis, time-on-passage, sequence mining.
focus.csv CSV One row per tab-visibility / focus / blur event. Attention checks; detecting tab-switching during the study.
decisions.csv CSV One row per click on a story link or button (#story a/button). Choice analysis — what the participant clicked and when.
inputs.csv CSV One row per change on a story <input> / <textarea> / <select>. Free-text answers, slider values, dropdown selections.
events.jsonl JSON Lines One JSON object per line. Behavioural + multimodal events. High-volume streams (mouse, blendshapes); easy to parse line-by-line.
events.csv CSV Same events flattened; payload kept as a JSON-encoded column. When you want events in a spreadsheet too.

Every CSV uses standard quoting (RFC 4180): fields containing commas, quotes, or newlines are wrapped in double quotes and embedded quotes are doubled. Objects (variables, decision data, payload) are serialised as JSON inside their cell. Excel reads it correctly; pandas reads it with the default read_csv — for the embedded-JSON columns, parse with json.loads on the column afterwards.

Data dictionary — sessions.json / sessions.csv

One participant per row. sessions.json includes every nested array verbatim from the database; sessions.csv replaces the nested arrays with their lengths (click_count, focus_event_count, …) for quick scanning. If you only need counts, use the CSV; for any deeper analysis go to the JSON or to the per-event CSVs below.

FieldInWhat it captures
user_idbothParticipant identifier the tracker recorded. Source-of-truth priority: PROLIFIC_PID > Warwick_ResponseID > User_ID > user_id. The platform generates U_<16-hex> if nothing is supplied in the URL.
conditionbothArm name for multi-condition studies. Empty string ("") for single-arm stories.
passage_indexbothInteger count of passage transitions in this session (advances on each :passagedisplay the tracker sees). Used by the server-side regression guard to prevent boot-render uploads from clobbering progress.
save_blobJSONSugarCube's Save.base64.save() output — the complete engine state needed to resume. Opaque to the platform; only the participant's browser deserialises it.
click_historyJSONArray of passage visits in order. Each element has title, variables (SugarCube State.variables snapshot), and (when captured) t / t_wall. See clicks.csv for the flat version.
focus_historyJSONTab-visibility events. Each element has timestamp, type, isVisible. Appended on every payload (additive); see focus.csv for the flat version.
decisionsJSONClicks on story links/buttons captured by the tracker's delegation handler. See decisions.csv.
inputsJSONChange events on story inputs/textareas/selects. See inputs.csv.
click_countCSVLength of click_history.
focus_event_countCSVLength of focus_history.
decision_countCSVLength of decisions.
input_countCSVLength of inputs.
created_atbothServer time (ISO) when the participant's record was first written.
updated_atbothServer time of the most recent upload from this participant.
completed_atbothServer time when an ending passage first appeared in click_history. null until then; never re-stamped. Source-of-truth for "this participant completed" and what the per-arm quota counts against.

Data dictionary — clicks.csv

One row per element of click_history. Visit order within a session is given by click_index.

FieldWhat it captures
user_idParticipant id (see above).
conditionArm name; empty for single-arm.
click_index0-based position within this participant's click_history.
tClient performance.now() at the time of the visit, in ms since the play page loaded. Monotonic — immune to wall-clock changes. Empty for resumed-prefix passages: when a participant returns, the tracker can't retroactively stamp visits it didn't witness.
t_wallClient Date.now() at the time of the visit, in ms since the Unix epoch. Subject to the participant's clock being correct. Empty for resumed-prefix passages.
titlePassage name as SugarCube sees it (entity-decoded). Match against the story's ending list to know whether this passage completed the study.
variablesJSON snapshot of State.variables at the moment this passage was displayed. Everything the Twine author kept in $variable form is here.

Data dictionary — focus.csv

Tab-visibility and window-focus events. The browser fires these on backgrounding the tab, switching apps, locking the screen, etc.

FieldWhat it captures
user_id, conditionAs above.
focus_index0-based position within this participant's focus_history.
timestampClient Date.now() at the time of the event (ms since epoch).
typeEvent kind. Always tabVisibility from the canonical tracker; richer values (focus, blur) appear only when behavioural rich focus is enabled.
is_visibleBoolean. true when the tab is currently visible; false when hidden.
received_atServer time the event landed in the database. Useful when the participant's local clock is suspect.

Data dictionary — decisions.csv

One row per click on a story link or button (the tracker delegates on #story a and #story button). Captures the choice the participant made.

FieldWhat it captures
user_id, conditionAs above.
decision_index0-based position within this participant's decisions.
tClient performance.now() at the click (ms since page load). Monotonic.
t_wallClient Date.now() at the click (ms since epoch).
received_atServer time the decision landed.
dataJSON of the full decision: the link text the participant saw, the resolved target passage, and any SugarCube macro metadata. The platform doesn't try to flatten this — different authoring styles produce different keys.

Data dictionary — inputs.csv

Changes on story form fields (free-text answers, sliders, dropdowns). The tracker records every change event on #story input / textarea / select.

FieldWhat it captures
user_id, conditionAs above.
input_index0-based position within this participant's inputs.
tClient performance.now() when the value changed.
t_wallClient Date.now() when the value changed.
received_atServer time.
dataJSON with the input name, type (text, radio, range, …), and value. Final answers are easy to extract by grouping by (user_id, name) and keeping the last row.

Data dictionary — events.jsonl / events.csv

Behavioural and multimodal events live in a separate collection — they're append-only and high-volume, so they're streamed rather than batched into the session document. Each row is one event. The events.jsonl file is line-delimited JSON, ideal for pandas.read_json(path, lines=True); events.csv is the same data with payload kept as a JSON-encoded column.

FieldWhat it captures
user_id, conditionParticipant + arm.
typeEvent kind. One of: mouse, key, hover, scroll, idle, rt (reaction time), focus, session_start, plus the multimodal lifecycle set: face, face_started, face_denied, face_model_load_failed, face_stream_ended, face_capped, face_unsupported.
tClient performance.now() (ms since page load). Monotonic.
t_wallClient Date.now() (ms since epoch).
received_atServer insert time.
passagePassage title at the time of the event, or null if it fired outside an active passage (e.g. session_start).
payloadType-specific JSON. mouse: {x, y} in CSS pixels. key: timing only (key codes are not recorded). hover: {selector, ms}. scroll: {x, y}. idle: {ms}. rt: {passage, ms}. face: 52-element blendshapes array. Lifecycle events have {reason}.

Working with the data

  • Python (pandas): pd.read_csv("sessions.csv"); for embedded-JSON columns (variables, data, payload) follow up with df["data"].apply(json.loads).
  • Python (notebooks, full fidelity): json.load(open("sessions.json"))["sessions"] gives you the nested list directly.
  • R: readr::read_csv() for the CSVs; jsonlite::stream_in() for events.jsonl.
  • Joining: user_id is the join key everywhere. The CSVs already carry condition for convenience, but it's also unambiguously available from sessions.csv.

Gotchas

t vs t_wall. Always prefer t for within-session timings. t_wall is the participant's wall clock — fine for ordering across the session but unreliable for durations if the device's clock changes mid-experiment (timezone shift, NTP correction, manual edit).
Resumed-prefix rows have null timestamps. When a participant returns and SugarCube re-renders earlier passages from the save, the tracker can't fabricate the original timestamps. t and t_wall are empty for those rows; only the visits the tracker actually witnessed have timing data.
Keystroke privacy. When behavioural keystroke timing is enabled, only inter-keystroke intervals are recorded — never the keys themselves. If you need free-text answers, use a story input field; those come through inputs.csv with the actual value.
Multimodal payload is blendshapes only. Face capture stores 52 numeric expression scores per frame. No image, video, or face landmark coordinates leave the browser.
Excel and large numeric ids. If your participants have purely-numeric ids, Excel will silently coerce them to scientific notation on open. Either prefix the column with a single quote on import, or use the JSON file instead.
The export is a point-in-time snapshot. A participant who completes the study after you click Download will not appear in this archive. Re-download when the study closes for the final dataset.

Deleting a story #delete

What it does

Removes the story, its HTML (all arms if multi-condition), its assets, and every participant session and event collected against it.

When to use it

  • You uploaded the wrong file and want a clean slate.
  • The study is finished and you've exported your data.

Gotchas

This is irreversible. Sessions and assets are gone — no soft delete, no undo. Export your data first.
Only the creator can delete. Co-owners see no Delete button. If the creator is gone, ask an admin.