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.

CircleLabelPhase 1Phase 2Phase 3
BlueR1EarnsInactiveInactive
GreenR2InactiveEarnsInactive
RedDistractorNever earnsNever earnsNever 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.

yourapp.com/task

Phase 1 · 0:15

0

points

A working preview of the task. The browser window is what a participant sees; the panel above it is your side. Set the schedules, phase lengths, and speed, then start it and click the blue circle. Try FR1 against FR10 to feel what a schedule change does. Nothing is saved; it runs entirely in this page.

The real page adds the data layer on top of what you just played:

  1. Every click is recorded. Each click on any circle becomes a row in a new Response table: 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.
  2. Phases run on timers you control. Each phase's length is a setting. Short values for testing, real durations for your study.
  3. 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.
  4. Finishing is recorded. When Phase 3 ends, the participant's record gets taskCompleted: true and 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:

SpeedFeel
1Slow, easy to click
2Moderate
3.5Fast
5.5Very 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.