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:
| Component | Collects |
|---|---|
keypress | a key from a set of valid keys |
mousepress / click | a click, optionally on a target region |
button | a click on an on-screen button |
slider | a continuous value along a track |
likert | a rating on an ordered scale |
multichoice | one option from a list |
prompt | free text |
anykey | any 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:
| Field | Meaning |
|---|---|
response.primary.value | the response value |
response.primary.rt | response time (ms) |
response.primary.correct | whether the response was correct |
outcome.correct | trial-level correctness |
outcome.rtMs | trial-level response time |
outcome.timedOut | true if the participant didn't respond |
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 present | Hit | Miss |
| Signal absent | False alarm | Correct 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:
- A truth column on each trial — a boolean like
signal(true= signal present,false= noise). You add this when you build the design. - The
outcome.correctflag 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 countersA 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()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 variable | Increments when |
|---|---|
sdt_hits | signal present and correct |
sdt_misses | signal present and incorrect |
sdt_false_alarms | signal absent and incorrect |
sdt_correct_rejections | signal absent and correct |
sdt_omissions | the 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_mode | Effect 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) |
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.