The Trial
Overview
This lesson builds the core task page, the experiment inside the experiment. Three colored circles move continuously around the screen. Participants click them to earn points. Which circle earns points changes from phase to phase, and participants are never told which circle is active or when the rules change. They discover it through their own clicking, which is exactly the point: this is the three-phase resurgence procedure from the Our Task lesson, running in a browser.
| Circle | Label | Phase 1 | Phase 2 | Phase 3 |
|---|---|---|---|---|
| Blue | R1 | Earns | Inactive | Inactive |
| Green | R2 | Inactive | Earns | Inactive |
| Red | Distractor | Never earns | Never earns | Never earns |
Phase 3 is the resurgence test: nothing earns points, and the question is whether clicking returns to the blue circle that paid off first. The red distractor is present throughout to split attention and give you a measure of indiscriminate clicking.
What You're Building
This is a preview of the page we are building. The browser window is what a participant sees. The parameter panel above it is your side as the researcher: participants never see those settings, and in the real task they live in the application's code, where you change them by asking Claude Code. The panel is here so you can feel what each one does before you set the real values.
Choose the reinforcement schedules, phase lengths, and circle speed, then start it, click the blue circle, and keep going through the phase changes to the end. Run it more than once. FR1 and FR10 on the same circle feel like different experiments, and that difference is exactly the kind of variable your study design manipulates.
Researcher parameters · participants never see these
In the real experiment these values are set in the application's code, not on the page. This panel exists only in the preview so you can feel what each one changes.
Phase 1 · 0:15
0
points
The real page adds the data layer on top of what you just played:
- Every click is recorded. Each click on any circle becomes a row in a
new
Responsetable: which circle, which phase, and whether it earned a point. Clicks per phase is your resurgence measure, and because each phase has a fixed duration, counts convert to rates during analysis. - Phases run on timers you control. Each phase's length is a setting. Short values for testing, real durations for your study.
- The phase change is signaled but never explained. The background color shifts and a brief "Phase 2" label appears, telling participants something changed without telling them what to do about it, in keeping with the resurgence procedure.
- Finishing is recorded. When Phase 3 ends, the participant's record
gets
taskCompleted: trueand they move to the debrief.
Building It With Claude Code
This is the biggest build in the course, and the prompt is correspondingly the longest. It's still just outcomes: what moves, what earns, what gets recorded.
@prisma/schema.prisma
Build a three-phase clicking task at src/app/task/ with three colored
circles that move around a bounded play area and bounce off the walls.
1. Circle 1 (blue, "R1") earns points in Phase 1 only. Circle 2 (green,
"R2") earns points in Phase 2 only. Circle 3 (red, "distractor")
never earns points. Make the reinforcement schedules configurable
fixed-ratio constants (FR_R1, FR_R2) that default to 1.
2. Each phase runs for a fixed duration set by a configurable list of
minutes per phase. Use [0.5, 0.5, 0.2] as the default for testing.
When a phase's time is up, advance automatically. Phase 3 runs last
with no active target, then the task ends.
3. Give each phase its own play-area background color with a smooth
transition, and show a brief "Phase N" overlay when the phase changes
(but not when the task first loads).
4. Show a point counter and a phase timer at the top, with a brief +1
flash when a click earns a point.
5. Record every click to a new Response table with the participant, the
phase number, the button label ("R1", "R2", or "distractor"), and
whether it was reinforced. Record clicks for all circles, reinforced
or not.
6. When Phase 3 ends, set taskCompleted to true on the participant and
redirect to /debrief. Add taskCompleted (boolean, default false) to
the Participant model along with the Response model.
Run the migration (name it add-response) and regenerate the Prisma
client.
What to expect while it works. Approve the schema edit and migration as usual. Claude Code will create the task folder with the page and a server file for recording clicks. This build has the most moving parts so far, so if the first version isn't quite right, describe what you see and let it revise; that's the normal loop, not a failure.
Testing What You Built
The default settings are made for quick testing: 30-second Phases 1 and 2, a 12-second Phase 3, and every click on the active circle earning a point.
Go through the flow with a new ID. On the task page, click the blue circle
for 30 seconds. You should see the background change and a "Phase 2" label
appear. Switch to the green circle. After Phase 3's 12 seconds with nothing
earning, the task should end and send you to a 404 at /debrief, the final
unbuilt page.
Open Prisma Studio with npm run db:studio and check the Response table.
There should be a row for every click you made, with the right phase number
and button label. Rows with button: "distractor" should always show
reinforced: false. Check the participant's row too: taskCompleted should
now be true.
This is also a good moment for the calibration habit from the architecture lesson: run it once more with a plan, say exactly ten clicks on blue in Phase 1 and five on red, then confirm the table shows exactly those counts.
If You Get Stuck
Same pattern: what you did, what you expected, what happened, plus any error text. Common symptoms:
- The circles don't move. "The task page loads and shows the three circles, but they sit still instead of moving around the play area. Find out why."
- Clicks aren't being recorded. "I clicked circles through a full run of the task, but the Response table in Prisma Studio is empty. Clicks are not being saved. Find out why."
- The phase never advances. "The task stays in Phase 1 past the 30 seconds it should run. The phase timer is not advancing the task."
- The task ends but nothing happens. "Phase 3 ended but I was never sent to /debrief, and taskCompleted is still false in Prisma Studio. Finishing the task is not being recorded."
- Points appear on the wrong circle. "The green circle is earning points during Phase 1. Only the blue circle should earn in Phase 1. Fix which circle is active in each phase."
Make It Your Own
Everything you adjusted in the preview's parameter panel is a setting in the real task too, changed by asking Claude Code. The preview is where you feel out values; this is where you make them real. The two that matter most for your study design:
Phase durations. The testing default is half a minute, half a minute, and twelve seconds. Before running real participants, set durations that fit your design:
@src/app/task/page.tsx
Set the phase durations to 3 minutes, 3 minutes, and 1 minute.
How hard the circles are to catch. Speed and size change response rates independently of the reinforcement schedule, so they're worth piloting. Rough guide for speed, in pixels per animation frame:
| Speed | Feel |
|---|---|
| 1 | Slow, easy to click |
| 2 | Moderate |
| 3.5 | Fast |
| 5.5 | Very fast, hard to hit |
@src/app/task/page.tsx
Slow the circles down to a moderate speed and make them a bit larger.
You can also change the reinforcement schedules ("make R1 pay off every tenth click"), the background colors ("use the same background for all three phases so the phase change isn't visually signaled"), or anything else you can describe.
The Code, If You're Curious
You don't need to read anything in this section to continue the course. It's here for the curious and for anyone who wants to compare against the checkpoint branch. Three pieces: the schema additions, the server file that records clicks, and the task page itself.
The schema: the Response table and taskCompleted
model Participant {
id String @id @default(cuid())
prolificId String @unique
createdAt DateTime @default(now())
consented Boolean @default(false)
demographics Demographics?
instructionsCompleted Boolean @default(false)
comprehensionAttempts Int @default(0)
taskCompleted Boolean @default(false)
responses Response[]
}
model Response {
id String @id @default(cuid())
participantId String
participant Participant @relation(fields: [participantId], references: [id])
phase Int // 1, 2, or 3
button String // "R1", "R2", or "distractor"
reinforced Boolean // true if this click produced a point
respondedAt DateTime @default(now())
}Unlike Demographics, a participant has many Response rows, one per click. That's the one-to-many link from the architecture lesson, and it's what makes per-phase response rates computable later.
The server file: recording clicks and completion
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import prisma from "@/lib/prisma";
export async function recordResponse(
phase: number,
button: string,
reinforced: boolean
) {
const cookieStore = await cookies();
const participantId = cookieStore.get("participantId")?.value;
if (!participantId) return;
await prisma.response.create({
data: { participantId, phase, button, reinforced },
});
}
export async function completeTask() {
const cookieStore = await cookies();
const participantId = cookieStore.get("participantId")?.value;
if (!participantId) redirect("/");
await prisma.participant.update({
where: { id: participantId },
data: { taskCompleted: true },
});
redirect("/debrief");
}The task page: circles, timers, and phases
"use client";
import { useState, useEffect, useRef } from "react";
import { recordResponse, completeTask } from "./actions";
// --- Experimental parameters ---
const PHASE_MINS = [0.5, 0.5, 0.2]; // minutes per condition (phase 1, 2, 3)
const PHASE_BG_COLORS = ["#e0f2fe", "#fef9c3", "#fce7f3"]; // background color per phase
const FR_R1 = 1;
const FR_R2 = 1;
const CIRCLE_SIZE = 120; // diameter in pixels
const SPEED = 5.5; // pixels per animation frame
// --------------------------------
type Circle = { x: number; y: number; dx: number; dy: number };
function randomVelocity() {
const angle = Math.random() * 2 * Math.PI;
return { dx: Math.cos(angle) * SPEED, dy: Math.sin(angle) * SPEED };
}
const CIRCLE_STYLES = [
"bg-blue-500 hover:bg-blue-400",
"bg-green-500 hover:bg-green-400",
"bg-red-400 hover:bg-red-300",
];
export default function TaskPage() {
const [phase, setPhase] = useState(1);
const [phaseElapsed, setPhaseElapsed] = useState(0);
const [points, setPoints] = useState(0);
const [presses1, setPresses1] = useState(0);
const [presses2, setPresses2] = useState(0);
const [feedback, setFeedback] = useState(false);
const [done, setDone] = useState(false);
const [circles, setCircles] = useState<Circle[]>(() => [
{ x: 100, y: 100, ...randomVelocity() },
{ x: 300, y: 150, ...randomVelocity() },
{ x: 200, y: 250, ...randomVelocity() },
]);
const [transitionLabel, setTransitionLabel] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const calledComplete = useRef(false);
const animRef = useRef<number>(0);
const isFirstPhase = useRef(true);
// Show overlay on phase change (skip phase 1 start)
useEffect(() => {
if (isFirstPhase.current) {
isFirstPhase.current = false;
return;
}
setTransitionLabel(`Phase ${phase}`);
const t = setTimeout(() => setTransitionLabel(null), 2000);
return () => clearTimeout(t);
}, [phase]);
// Circle animation loop
useEffect(() => {
if (done) return;
function tick() {
const el = containerRef.current;
if (el) {
const w = el.clientWidth;
const h = el.clientHeight;
setCircles((prev) =>
prev.map((c) => {
let { x, y, dx, dy } = c;
x += dx;
y += dy;
if (x <= 0 || x >= w - CIRCLE_SIZE) dx = -dx;
if (y <= 0 || y >= h - CIRCLE_SIZE) dy = -dy;
x = Math.max(0, Math.min(w - CIRCLE_SIZE, x));
y = Math.max(0, Math.min(h - CIRCLE_SIZE, y));
return { x, y, dx, dy };
})
);
}
animRef.current = requestAnimationFrame(tick);
}
animRef.current = requestAnimationFrame(tick);
return () => {
if (animRef.current) cancelAnimationFrame(animRef.current);
};
}, [done]);
// Phase timer
useEffect(() => {
if (done) return;
const t = setTimeout(() => {
const next = phaseElapsed + 1;
const limit = PHASE_MINS[phase - 1] * 60;
if (next >= limit) {
if (phase >= PHASE_MINS.length) {
if (!calledComplete.current) {
calledComplete.current = true;
setDone(true);
completeTask();
}
return;
}
setPhase((p) => p + 1);
setPhaseElapsed(0);
} else {
setPhaseElapsed(next);
}
}, 1000);
return () => clearTimeout(t);
}, [phaseElapsed, phase, done]);
async function handleClick(index: number) {
if (done) return;
const labels = ["R1", "R2", "distractor"];
const button = labels[index];
let reinforced = false;
if (index === 0 && phase === 1) {
const next = presses1 + 1;
setPresses1(next);
if (next % FR_R1 === 0) {
reinforced = true;
setPoints((p) => p + 1);
setFeedback(true);
setTimeout(() => setFeedback(false), 400);
}
} else if (index === 1 && phase === 2) {
const next = presses2 + 1;
setPresses2(next);
if (next % FR_R2 === 0) {
reinforced = true;
setPoints((p) => p + 1);
setFeedback(true);
setTimeout(() => setFeedback(false), 400);
}
}
await recordResponse(phase, button, reinforced);
}
const limit = PHASE_MINS[phase - 1] * 60;
const secsLeft = limit - phaseElapsed;
const minsLeft = Math.floor(secsLeft / 60);
const secsPart = secsLeft % 60;
return (
<div className="flex flex-col flex-1">
<div className="text-center py-4 shrink-0">
<p className="text-sm text-zinc-400 dark:text-zinc-500">
Phase {phase} · {minsLeft}:{secsPart.toString().padStart(2, "0")}
</p>
<p className="text-4xl font-bold text-zinc-900 dark:text-white">
{points}
{feedback && <span className="text-green-500 text-xl ml-3">+1</span>}
</p>
<p className="text-zinc-400 dark:text-zinc-500 text-sm">points</p>
{done && (
<p className="text-zinc-500 dark:text-zinc-400 text-sm mt-2">
Task complete. Loading next section…
</p>
)}
</div>
<div
ref={containerRef}
className="flex-1 relative overflow-hidden mx-4 mb-4 rounded-xl"
style={{
backgroundColor: PHASE_BG_COLORS[phase - 1],
transition: "background-color 0.8s ease",
}}
>
{transitionLabel && (
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
<div className="bg-black/50 text-white text-3xl font-bold px-10 py-5 rounded-2xl">
{transitionLabel}
</div>
</div>
)}
{circles.map((c, i) => (
<button
key={i}
onClick={() => handleClick(i)}
disabled={done}
style={{
position: "absolute",
left: c.x,
top: c.y,
width: CIRCLE_SIZE,
height: CIRCLE_SIZE,
}}
className={`rounded-full transition-colors disabled:cursor-not-allowed ${CIRCLE_STYLES[i]}`}
/>
))}
</div>
</div>
);
}The short version: the block of constants at the top is the experiment's control panel (durations, colors, schedules, size, speed). An animation loop moves the circles and bounces them off the walls. A once-per-second timer advances the phase and ends the task after Phase 3, recording completion. Every click, reinforced or not, is sent to the server file to become a Response row.
🌿 Checkpoint branch: 06-the-trial: This is the finished task page.
Check out this branch to see the completed experiment and catch up to this
point in the course.
⚠️ If you built earlier lessons yourself and then switch to this
branch: your database was set up by your own migrations, not this
branch's, so npm run db:migrate will report a mismatch and offer to
reset the database. Accepting the reset is the right move during the
course: it deletes your practice data and rebuilds the tables to match
this branch. Never accept a reset once real participant data is in the
database.