Participants & Data Collection¶
MUG tracks each participant by a subject ID in the URL, runs them through the Stager scene-by-scene, and writes their data to a per-experiment folder on disk. This page covers the pieces you need to run a study end-to-end: how IDs are assigned, what's recorded automatically, where it lands on disk, and how to generate completion codes for panel platforms like Prolific or MTurk.
The subject ID is the URL path¶
A participant's identity is the path component of the URL they land on — not a query parameter.
| URL a participant visits | Resulting subject ID |
|---|---|
https://your-server.com/ |
Auto-generated UUID, 302-redirected to /<uuid> |
https://your-server.com/abc-123 |
abc-123 (verbatim) |
https://your-server.com/prolific_5f2e1a |
prolific_5f2e1a |
When a browser hits the root route, MUG generates a UUID and redirects:
# mug/server/app.py
@app.route("/")
def index(*args):
"""If no subject ID provided, generate a UUID and re-route them."""
subject_id = str(uuid.uuid4())
return flask.redirect(flask.url_for("user_index", subject_id=subject_id))
If the participant visits /<something> directly, <something> is used as-is. That means panel platforms can construct the URL themselves and pass whatever identifier they like:
- Prolific:
https://your-server.com/{{%PROLIFIC_PID%}} - MTurk:
https://your-server.com/{{workerId}}(via a redirect from the HIT page) - Custom links: any URL-safe string works
Query parameters are not parsed
MUG does not read URL query parameters. https://your-server.com/?prolific_pid=abc will not set abc as the subject ID — it will generate a fresh UUID and discard the query string. If you need to carry extra metadata, put it in the path or stash it in mugGlobals (see below).
Subject-ID guarantees¶
Several things happen automatically once a subject ID exists:
- Duplicate-tab rejection. If a participant already has an active socket connection and opens a second tab with the same ID, the second tab is rejected with a
duplicate_sessionerror. Each subject ID owns exactly one active session at a time. - Completion lockout. When a participant reaches the end of their Stager, their ID is added to
PROCESSED_SUBJECT_NAMES. Re-visiting the URL returns "You have already completed the experiment with this ID!" — participants cannot replay a completed session. - Session restoration. If a participant disconnects mid-experiment, their
ParticipantSession(which holdsstager_state,mug_globals, and the last scene ID) persists on the server. When they re-open the same URL, MUG restores their stager at the scene they left off on and replays asession_restoredevent to the client.
Session restoration restarts the current scene
Restoring a session brings the participant back to the start of the scene they were on, not to their exact position within it. GymScenes in particular are not resumed mid-episode — gameplay restarts from scratch for that scene. Any data streamed to disk before the disconnect (e.g. completed _ep{N}.csv files) is preserved, but the scene itself replays from its first episode.
Participant lifecycle¶
The server tracks two layers of state per participant:
- A
ParticipantSessiondataclass holding long-lived context (subject ID, stager state, mug globals, socket ID, connection flag, timestamps). - A
ParticipantStateenum for in-game lifecycle (IDLE,IN_WAITROOM,IN_GAME,GAME_ENDED), with validated transitions inmug/server/participant_state.py.
The flow from a fresh browser load through scene completion is:
- Browser GETs
/<subject_id>. The server creates aParticipantSession, builds a freshStagerinstance, and returnsindex.htmlwith the subject ID baked in. - Client opens a socket and emits
register_subject. The server ties the socket ID to the subject, starts the stager, and activates the first scene. - Scene activation writes metadata. If the active scene has
should_export_metadata=True,{subject_id}_metadata.jsonis written immediately. - The participant interacts with the scene. For
GymScenes, the client streamsemit_episode_dataat each episode boundary and the server writes{subject_id}_ep{N}.csv. - Client emits
advance_scene. The stager deactivates the current scene and activates the next one. Metadata for the new scene is exported, and the loop repeats from step 3. - Terminal scene reached. On
EndSceneorCompletionCodeScene, the final metadata (including the completion code, if any) is written and the subject ID is added toPROCESSED_SUBJECT_NAMES, preventing replays.
Concurrent participants are isolated by keying every server-side data structure on subject_id: STAGERS[subject_id], PARTICIPANT_SESSIONS[subject_id], SUBJECTS[subject_id] (a per-participant threading.Lock), and so on. Two participants can never collide with each other's scene state.
What gets recorded automatically¶
MUG writes five kinds of files to disk. All are keyed by subject_id and namespaced by experiment_id and scene_id:
| File | Written when | Contents |
|---|---|---|
{subject_id}_metadata.json |
On scene activation, for scenes where should_export_metadata=True |
Scene ID, scene type, timestamp, element_ids, form answers, experiment_config, any subclass-added fields (like completion_code) |
{subject_id}_ep{N}.csv |
At the end of every episode of a GymScene |
Observations, actions, rewards, terminateds, truncateds, episode_num, timestep t — nested dicts flattened with dotted column names |
{subject_id}_globals.json |
Alongside every episode CSV | Latest snapshot of client-side mugGlobals (defaults to {"subjectName": subject_id}, plus whatever you've added) |
{subject_id}.csv |
Only if a scene sends its data in one post-hoc blob instead of streaming | Same shape as _ep{N}.csv, but full game. In normal operation this file is never written — episode streaming is preferred because end-of-scene uploads fail on large payloads |
{subject_id}_multiplayer_metrics.json |
On emit_multiplayer_metrics |
P2P connection type/health, frame hashes, desync events, input delivery stats, rollback metrics |
Episode streaming, not end-of-scene uploads¶
When a GymScene runs, every time an episode terminates, the client posts the buffered data via emit_episode_data and the server writes {subject_id}_ep{N}.csv immediately:
# mug/server/app.py — simplified
@socketio.on("emit_episode_data")
def receive_episode_data(data):
...
filename = f"data/{CONFIG.experiment_id}/{data['scene_id']}/{subject_id}_ep{episode_num}.csv"
df.to_csv(filename, index=False)
This is deliberate. A long-running scene can generate hundreds of thousands of rows of observation data, and a single end-of-scene upload would frequently fail to transfer from the client back to the server. Per-episode streaming keeps each payload small and gives you partial results even if a participant drops mid-scene.
The end-of-scene handler (emit_remote_game_data) still exists, but it no-ops when it sees the data was already streamed:
# Check if there's any data to save (may be empty if data was sent per-episode)
if not decoded_data or not decoded_data.get("t"):
logger.info(f"No final data to save for scene {data.get('scene_id')} (data was sent per-episode)")
return
mugGlobals: extensible client-side state¶
mugGlobals is a dict that lives on the client across all scenes and is continuously synced to the server via sync_globals socket events. It's a good place for data that needs to survive scene transitions but doesn't fit neatly into a scene's built-in metadata — survey answers, condition assignments, panel IDs you read from window.location, and so on.
Anything written to mugGlobals ends up in {subject_id}_globals.json next to the episode CSVs. Every episode's save overwrites the file with the latest snapshot, so you always get the final value.
Directory layout¶
All files land under data/{experiment_id}/{scene_id}/{subject_id}*. A typical Overcooked-style experiment with a start scene, tutorial, two gameplay scenes, a feedback scene, and a completion-code scene looks like this:
data/
└── overcooked_hai/
├── overcooked_start_scene/
│ └── abc123_metadata.json
├── overcooked_tutorial/
│ ├── abc123_metadata.json
│ ├── abc123_ep0.csv
│ └── abc123_globals.json
├── cramped_room_sp_0/
│ ├── abc123_metadata.json
│ ├── abc123_ep0.csv
│ ├── abc123_ep1.csv
│ ├── abc123_ep2.csv
│ └── abc123_globals.json
├── cramped_room_ibc_0/
│ ├── abc123_metadata.json
│ ├── abc123_ep0.csv
│ ├── abc123_ep1.csv
│ └── abc123_globals.json
├── feedback_scene/
│ └── abc123_metadata.json
└── end_completion_code_scene/
└── abc123_metadata.json
When def123 finishes the same experiment, their files land next to abc123's inside each scene's subdirectory — one folder per scene, one file per participant per data type.
Configuring collection¶
Two knobs control data collection:
Experiment-level, on ExperimentConfig:
config = (
experiment_config.ExperimentConfig()
.experiment(
stager=stager.Stager(scenes=[...]),
experiment_id="overcooked_hai", # → data/overcooked_hai/...
save_experiment_data=True, # master switch, default True
)
)
Per-scene, on any Scene:
start_scene = (
static_scene.StartScene()
.scene(
scene_id="overcooked_start_scene",
should_export_metadata=True, # write metadata JSON for this scene
)
.display(scene_header="Welcome", ...)
)
Scenes that capture form input (TextBox, OptionBoxes, ScalesAndTextBox, CompletionCodeScene) typically set should_export_metadata=True — without it, the participant's answers are collected in the browser but never persisted.
Things that aren't configurable
- The output root is hardcoded to
data/relative to the process's working directory. If you need files in a different location, run the server from a different CWD orln -sthe directory. - There is no per-scene "extra fields to capture" hook beyond what's serialized into
scene_metadataautomatically. To attach custom data to a scene, subclass the scene and extend itsscene_metadataproperty, or write tomugGlobals. - Data is filesystem-only. There is no built-in database, S3, or cloud integration.
Completion codes for Prolific, MTurk, and similar panels¶
Panel platforms usually require each participant to submit a unique code at the end of the task so the platform can reconcile who should be paid. MUG ships CompletionCodeScene for exactly this:
from mug.scenes import static_scene
end_scene = (
static_scene.CompletionCodeScene()
.scene(
scene_id="end_completion_code_scene",
should_export_metadata=True,
)
.display(
scene_header="Thanks for participating!",
scene_body="Please copy your completion code and return to Prolific.",
)
)
On scene build, it generates a fresh uuid.uuid4(), renders it in a styled HTML block for the participant to copy, and attaches it to the scene's metadata:
# mug/scenes/static_scene.py
@property
def scene_metadata(self) -> dict:
metadata = super().scene_metadata
metadata["completion_code"] = self.completion_code
return metadata
After the experiment runs, you can reconcile the panel's submitted codes against your actual participants by loading every end_completion_code_scene/{subject_id}_metadata.json:
import glob, json
import pandas as pd
rows = []
for path in glob.glob("data/overcooked_hai/end_completion_code_scene/*_metadata.json"):
meta = json.load(open(path))
subject_id = path.split("/")[-1].removesuffix("_metadata.json")
rows.append({"subject_id": subject_id, "completion_code": meta["completion_code"]})
codes = pd.DataFrame(rows)
# Left-join against Prolific's CSV export on "completion_code"
Any participant who submits a code that isn't in codes.completion_code.values didn't actually finish your experiment — reject or refund accordingly.
Loading data back for analysis¶
MUG does not ship a loader module — the data is plain CSV and JSON, and the directory layout is designed so glob + pandas is enough. A minimal analysis starter:
import glob, json
import pandas as pd
EXP = "data/overcooked_hai"
# 1. All episode data, tagged with scene and participant
ep_files = glob.glob(f"{EXP}/*/*_ep*.csv")
episodes = []
for path in ep_files:
scene_id = path.split("/")[-2]
fname = path.split("/")[-1]
subject_id, ep_part = fname.rsplit("_ep", 1)
episode_num = int(ep_part.removesuffix(".csv"))
df = pd.read_csv(path)
df["subject_id"] = subject_id
df["scene_id"] = scene_id
df["episode_num"] = episode_num
episodes.append(df)
all_episodes = pd.concat(episodes, ignore_index=True)
# 2. All scene metadata (form answers, completion codes, timings)
meta_files = glob.glob(f"{EXP}/*/*_metadata.json")
metadata = []
for path in meta_files:
scene_id = path.split("/")[-2]
subject_id = path.split("/")[-1].removesuffix("_metadata.json")
meta = json.load(open(path))
metadata.append({"subject_id": subject_id, "scene_id": scene_id, **meta})
all_metadata = pd.DataFrame(metadata)
A few details worth knowing when you start slicing:
- Observation and action columns are already flattened with
flatten_dictusing dot notation. A nested obs like{"player_0": {"position": [1, 2]}}becomes columns likeplayer_0.position.0andplayer_0.position.1. No need tojson.loadsanything on load. - Each episode CSV is padded so all columns have equal length; timesteps that don't have a given key will be
None/NaN. - Scene metadata JSONs include the full
experiment_configdict passed into.scene(), so condition assignments, layout IDs, and similar bookkeeping travel with the data.
Related reading¶
- Scenes — how to build and configure the scenes whose data ends up on disk
- Stager — how scenes are sequenced for each participant
- Participant Exclusion — entry screening and mid-experiment ejection (separate from data collection)