PsyCloud

Stimuli & Responses

A screen is a sequence of steps built from components: stimuli that present information and responses that collect input. PsyCloud ships ~80 components (see the Components reference); this guide covers the patterns that matter most, then walks through a complete signal detection task end to end.

Presenting stimuli

Stimulus components include text, pictures, sound, video, shapes, fixation crosses, and richer task-specific stimuli. In code they're typed helpers — text(...), image(...), fixation_cross(...), circle(...), blank(...) — and their values can be bound to trial data so one screen serves every trial:

ask(
  text(trial$word, fill = trial$ink, font_size = 72, x = "center", y = "center"),
  keypress(keys = c("r", "b"))
)

trial$word / col.word reads the word column of the current trial, so one screen definition plays every row of your design.

Collecting responses

A response step opens a response window that closes when the participant responds or the step times out. Pick the collector that matches the input you want:

ComponentCollects
keypressa key from a set of valid keys
mousepress / clicka click, optionally on a target region
buttona click on an on-screen button
slidera continuous value along a track
likerta rating on an ordered scale
multichoiceone option from a list
promptfree text
anykeyany key (e.g. "press any key to continue")

Every collector takes timeout_ms to bound the window. If it elapses with no response, the trial is recorded as a timeout (more on that below).

How correctness works

Correctness is computed by the response component that collects it — not a separate scoring engine. Give the collector its correct answer (often a derived trial column) and it records whether the response matched:

keypress(keys = c("n", "o"), correct = trial$correct_key, timeout_ms = 2500)

In expressions (feedback, derived columns, reducers) read these canonical runtime fields:

FieldMeaning
response.primary.valuethe response value
response.primary.rtresponse time (ms)
response.primary.correctwhether the response was correct
outcome.correcttrial-level correctness
outcome.rtMstrial-level response time
outcome.timedOuttrue if the participant didn't respond
Don't write !outcome.correct

On a timeout, outcome.correct may be undefined — and !undefined is true, which silently marks misses as "correct" in feedback. Use explicit comparisons: outcome.correct == false or outcome.timedOut == true. Likewise, never read raw telemetry paths like payload.responseTime.rtMs from authoring expressions — use the canonical fields above.

Signal Detection Theory

Many psychology tasks ask a yes/no question about a noisy world: Was a target present? Have I seen this word before? Did the tone occur? Plain accuracy can't tell two very different participants apart — someone who is genuinely better at the task, versus someone who simply says "yes" more often. Signal detection theory (SDT) separates these two things: sensitivity (how well you tell signal from noise, measured by d′) and bias (your tendency to answer "yes" regardless, measured by the criterion c).

The four outcomes

On each trial the world is in one of two states (signal present or absent) and the participant gives one of two answers. Crossing them gives the four classic SDT outcomes — plus a fifth for trials with no response:

Responds "yes"Responds "no"
Signal presentHitMiss
Signal absentFalse alarmCorrect rejection

A timeout (no response) is counted separately as an omission.

How PsyCloud thinks about it — no magic

PsyCloud doesn't have a hidden "SDT mode." The classification is just two ordinary pieces of your design wired together:

  1. A truth column on each trial — a boolean like signal (true = signal present, false = noise). You add this when you build the design.
  2. The outcome.correct flag the response collector already computes (see How correctness works).

From those two, a hit is "signal present and correct," a false alarm is "signal absent and incorrect," and so on. The SDK ships helpers so you don't hand-write that logic, but it's no more than the table above turned into expressions.

The data flows in one direction:

design row `signal`  ──▶  derived `correctKey`  ──▶  collector sets `outcome.correct`

                            reducers read `trial.signal` + `outcome.correct`

                                       hit / miss / false alarm / correct rejection counters

A yes/no detection design

Here is a complete yes/no task: a signal factor, a correct key derived from it, the SDT counters wired in, and a keypress collector. The participant presses j for "yes" and f for "no".

library(psycloudr)
 
experiment(id = "demo.sdt-yesno", name = "Yes/No Detection") |>
  phase("main", label = "Trials") |>
    factors(signal = c(TRUE, FALSE)) |>
    cross() |>
    # 1. correctKey: press "j" on signal trials, "f" on noise trials
    derive("correctKey", expr_if(row$signal, "j", "f")) |>
    # a readable on-screen label (optional)
    derive("label", expr_if(row$signal, "SIGNAL", "NOISE")) |>
    repeat_rows(40) |>
    shuffle(seed = seed_per_participant("sdt-yesno")) |>
    # 2. running hit/miss/FA/CR/omission counters
    phase_state(sdt_state(prefix = "sdt")) |>
    # 3. classify each trial at trial_end
    reducer(sdt_reducers(signal_field = "signal", omission_mode = "omit")) |>
  screen("trial") |>
    ask(
      text(trial$label, font_size = 48, x = "center", y = "center"),
      # 4. the collector computes outcome.correct from correctKey
      keypress(keys = c("j", "f"), correct = trial$correctKey, timeout_ms = 1500)
    ) |>
  bundle()
row vs trial / col

Use row in derive(...) — it refers to the design-table row being built before the study runs. Use trial (R) / col (Python) inside a screen — it refers to the running trial's bound values. R auto-binds the columns your screen references; Python lists them once with bind_self(...).

What the helpers record

sdt_state(prefix = "sdt") declares five running counters, and sdt_reducers(...) increments the right one after every trial:

State variableIncrements when
sdt_hitssignal present and correct
sdt_missessignal present and incorrect
sdt_false_alarmssignal absent and incorrect
sdt_correct_rejectionssignal absent and correct
sdt_omissionsthe participant timed out

Change prefix to track several conditions independently (e.g. sdt_state(prefix = "high_load")). Because these are ordinary state variables, you can read them live — for example to show end-of-block feedback like "You detected 18 of 20 targets."

Counting non-responses

A timeout sets outcome.timedOut == true and usually leaves outcome.correct unset. That's why the helpers compare against outcome.correct == false rather than !outcome.correct (see the warning above). The omission_mode argument decides what happens to those trials:

omission_modeEffect on a timeout
"omit" (default)counts only as an omission; excluded from hits/misses/FA/CR
"as_no"treated as a "no" response (miss on signal trials, correct rejection on noise trials)
"as_incorrect"treated as an error (miss on signal trials, false alarm on noise trials)
Pick a mode before you collect data

Under "omit", your rate denominators are the responded trials (signal_trials = hits + misses, noise_trials = false_alarms + correct_rejections). Under "as_no" / "as_incorrect", omissions fold into those counts. Decide which convention your analysis assumes up front — it changes d′.

From counts to d′ and criterion

The counters give you raw tallies; SDT measures come from the rates. Sensitivity is d′ = z(H) − z(FA), and bias is c = −½·[z(H) + z(FA)], where z is the inverse normal, H is the hit rate, and FA is the false-alarm rate. Higher d′ means better discrimination; c > 0 means a conservative ("no") bias and c < 0 a liberal ("yes") bias.

sdt_dprime() computes d′ with a log-linear correction (Hautus, 1995) so that perfect scores (H = 1 or FA = 0) don't blow up to infinity. There's no criterion helper, so compute c with the same correction by hand:

# From your exported counts (omission_mode = "omit"):
signal_trials <- hits + misses
noise_trials  <- false_alarms + correct_rejections
 
dprime <- sdt_dprime(hits, false_alarms, signal_trials, noise_trials)
 
# Criterion c — same log-linear correction sdt_dprime() uses internally:
zH  <- qnorm((hits + 0.5)         / (signal_trials + 1))
zFA <- qnorm((false_alarms + 0.5) / (noise_trials  + 1))
criterion <- -0.5 * (zH + zFA)

You'll usually run this after the study, computing per-participant counts from your exported trial table — see Data & Analysis. The same numbers are available live in the sdt_* state variables if you want to drive feedback or adaptive logic during the run.

One-call setup

If the defaults suit you, sdt_yesno() returns the derive step, the state variables, and the reducers in one object — the three numbered pieces above — ready to splat into your builder. Reach for the individual sdt_correct_key() / sdt_state() / sdt_reducers() helpers when you need to customize keys, prefixes, or omission handling, as in the worked example.

Next