What users actually need after losing or winning a competitive AI evaluation — emotional state design for winners, close misses, and clear losses, with TSX layout and copy patterns that make feedback feel fair rather than algorithmic.
What users actually need after losing or winning a competitive AI evaluation — emotional state design for winners, close misses, and clear losses, with TSX layout and copy patterns that make feedback feel fair rather than algorithmic.
Competitive Product Psychology
Review Checklist
Losing users see acknowledgment of effort before they see the gap — the sequence matters: what they did well → what cost them → gap to top → next step
Close-miss users (ranked N+1 where N = prize cutoff) get specific handling — not the same template as 10th place
Winning users get validation first, then growth signal — never lead with "what you could improve" for a winner
Feedback copy is written for the emotional state, not just the ranking — test with a real user who just lost; does it feel condescending, dismissive, or fair?
No score is shown without context — "72.4" means nothing; "72.4 — above median, 8 points from the prize line" means something
The word "unfortunately" never appears in feedback copy — it signals pity, not respect
Generic phrases are eliminated: no "good job", no "room for improvement", no "keep trying" — all copy is specific
The one concrete next step is framed as an upgrade, not a correction — "strengthen X" not "fix X"
Close-miss copy acknowledges the specific number of points between their score and the prize cutoff
Winner copy mentions the specific competitive advantage that put them on top — not just "you won"
All emotional state copy is AB-testable — the text strings are in config/constants, not hardcoded in JSX
The fair-feeling sequence component is reusable — can be used with any submission result
The Emotional States: What Users Actually Feel
Competition results trigger one of four emotional states. Each demands different UX.
State 1: Winner (Rank 1 or prize-winning)
Emotional state: Pride + validation + mild anxiety (will I be able to repeat this?)
What they need first: Explicit acknowledgment that this is a real achievement, not luck
What they need second: Specific evidence of what made them win (so they can repeat it)
What they do NOT need: Immediate critique or "areas for growth" — this dilutes the win
Trigger words to avoid: "Even though you won…", "You could improve…" (before celebrating)
State 2: Close Miss (Rank = prize_positions + 1 or prize_positions + 2)
Emotional state: Acute frustration — "I was SO CLOSE"
What makes it uniquely painful: They can see exactly how many points they needed. The prize was tangible.
What they need: Acknowledgment of the specific gap, not a generic "so close!" — quantify it
What they do NOT need: Platitudes. "You almost got it!" triggers rage in close-miss users.
The close-miss user is your most valuable user: they are highly motivated to improve and re-enter. If you handle this wrong, they churn. If you handle it right, they become regulars.
State 3: Middle-field loss (Rank > prize_positions + 2, but not bottom 20%)
Emotional state: Disappointment, mixed with genuine curiosity about what went wrong
What they need: A clear, specific explanation of the gap, with at least one thing they did well
What they do NOT need: "Sorry you didn't win" — this is condescending; they know
State 4: Bottom-field (Bottom 20% of field)
Emotional state: Embarrassment + possible question of whether the evaluation was fair
What they need: Dignity first — acknowledge the attempt; then one specific, actionable insight
What they do NOT need: Comparisons to how far above them the median was — this crushes motivation
The Fair-Feeling Sequence
Research in feedback psychology shows that the ORDER of information determines whether feedback feels fair, regardless of content. This is the sequence that consistently produces the "fair" response:
1. Acknowledge what they did well (specific, not generic)
2. Explain what cost them (the exact thing, with evidence)
3. Show the gap to top (quantified, contextual)
4. Give one concrete next step (upgrade framing, not correction)
Reversing this sequence (starting with the gap) triggers defensive rejection. The feedback is identical — the order is what makes it land.
// components/feedback/FairFeedbackLayout.tsx'use client';
importReactfrom'react';
import { cn } from'@/lib/utils';
exportinterfaceFairFeedbackProps {
// Step 1: What they did wellstrength: {
headline: string; // e.g., "Strong execution in the planning phase"detail: string; // e.g., "Your agent correctly identified all 4 constraints before attempting a solution."laneId?: string; // link to the lane this came fromscore?: number;
maxScore?: number;
};
// Step 2: What cost themprimaryGap: {
headline: string; // e.g., "Verification was the deciding factor"detail: string; // specific explanationevidenceQuote?: string; // direct quote from judgelaneId?: string;
scoreActual?: number;
scorePossible?: number;
};
// Step 3: Gap to topfieldContext: {
userScore: number;
topScore: number;
prizeThreshold: number | null;
userRank: number;
totalParticipants: number;
pointsFromPrize: number | null;
};
// Step 4: Next stepnextStep: {
action: string; // "Focus your next submission on explicit verification steps"rationale: string; // "Verification was the differentiating factor for 3 of the top 5 finishers"
};
// ContextoutcomeType: 'winner' | 'close-miss' | 'loss' | 'bottom';
}
exportfunctionFairFeedbackLayout({
strength,
primaryGap,
fieldContext,
nextStep,
outcomeType,
}: FairFeedbackProps) {
return (
<divclassName="space-y-4">
{/* Step 1: Strength — always first */}
<StrengthCardstrength={strength}outcomeType={outcomeType} />
{/* Step 2: Primary gap — only for non-winners */}
{outcomeType !== 'winner' && (
<GapCardgap={primaryGap} />
)}
{/* Step 3: Field context */}
<FieldContextCardcontext={fieldContext}outcomeType={outcomeType} />
{/* Step 4: Next step */}
<NextStepCardstep={nextStep}outcomeType={outcomeType} /></div>
);
}
// ─── Step 1: Strength Card ────────────────────────────────────────────────────functionStrengthCard({
strength,
outcomeType,
}: {
strength: FairFeedbackProps['strength'];
outcomeType: FairFeedbackProps['outcomeType'];
}) {
// Winners get a more prominent treatment — green border, larger textconst isWinner = outcomeType === 'winner';
return (
<divclassName={cn(
'rounded-xlp-5border',
isWinner
? 'bg-emerald-50border-emerald-200'
: 'bg-whiteborder-gray-200'
)}><divclassName="flex items-start gap-3"><divclassName={cn(
'mt-0.5h-8w-8shrink-0rounded-lgflexitems-centerjustify-centertext-lg',
isWinner ? 'bg-emerald-100' : 'bg-gray-100'
)}>
{isWinner ? '🏆' : '✓'}
</div><divclassName="min-w-0"><pclassName={cn(
'font-semibold',
isWinner ? 'text-emerald-900text-base' : 'text-gray-900text-sm'
)}>
{strength.headline}
</p><pclassName={cn(
'mt-1leading-relaxed',
isWinner ? 'text-smtext-emerald-800' : 'text-smtext-gray-600'
)}>
{strength.detail}
</p>
{strength.score !== undefined && strength.maxScore !== undefined && (
<pclassName={cn('text-xsmt-2', isWinner ? 'text-emerald-600' : 'text-gray-400')}>
{strength.score}/{strength.maxScore} in this lane
</p>
)}
</div></div></div>
);
}
// ─── Step 2: Gap Card ──────────────────────────────────────────────────────────functionGapCard({ gap }: { gap: FairFeedbackProps['primaryGap'] }) {
return (
<divclassName="rounded-xl p-5 bg-white border border-gray-200"><divclassName="flex items-start gap-3"><divclassName="mt-0.5 h-8 w-8 shrink-0 rounded-lg bg-amber-50 flex items-center justify-center text-lg">
↑
</div><divclassName="min-w-0"><pclassName="text-sm font-semibold text-gray-900">{gap.headline}</p><pclassName="text-sm text-gray-600 mt-1 leading-relaxed">{gap.detail}</p>
{gap.evidenceQuote && (
<blockquoteclassName="mt-3 text-xs text-gray-500 italic pl-3 border-l-2 border-amber-200">
"{gap.evidenceQuote}"
</blockquote>
)}
{gap.scoreActual !== undefined && gap.scorePossible !== undefined && (
<pclassName="text-xs text-gray-400 mt-2">
Scored {gap.scoreActual}/{gap.scorePossible} here
{gap.scorePossible - gap.scoreActual > 0 && (
<spanclassName="text-amber-600">
{' '}— {gap.scorePossible - gap.scoreActual} points available
</span>
)}
</p>
)}
</div></div></div>
);
}
// ─── Step 3: Field Context Card ───────────────────────────────────────────────functionFieldContextCard({
context,
outcomeType,
}: {
context: FairFeedbackProps['fieldContext'];
outcomeType: FairFeedbackProps['outcomeType'];
}) {
const isCloseMiss = outcomeType === 'close-miss';
const isWinner = outcomeType === 'winner';
return (
<divclassName={cn(
'rounded-xlp-5border',
isCloseMiss ? 'bg-amber-50border-amber-200' : 'bg-gray-50border-gray-200'
)}><pclassName={cn(
'text-xsfont-semibolduppercasetracking-widemb-3',
isCloseMiss ? 'text-amber-600' : 'text-gray-400'
)}>
Where you stood
</p><divclassName="flex items-end gap-4"><div><spanclassName={cn(
'text-2xlfont-boldtabular-nums',
isWinner ? 'text-emerald-700' :isCloseMiss ? 'text-amber-700' : 'text-gray-900'
)}>
{context.userScore.toFixed(1)}
</span><spanclassName="text-sm text-gray-400 ml-1">your score</span></div>
{context.pointsFromPrize !== null && context.pointsFromPrize > 0 && (
<divclassName="text-sm"><spanclassName={cn('font-semibold', isCloseMiss ? 'text-amber-700' : 'text-gray-600')}>
{context.pointsFromPrize.toFixed(1)} points
</span><spanclassName="text-gray-400"> from the prize line</span></div>
)}
</div>
{/* Close-miss specific copy */}
{isCloseMiss && context.pointsFromPrize !== null && (
<pclassName="text-sm text-amber-700 mt-3 font-medium">
You needed {context.pointsFromPrize.toFixed(1)} more points.
That's a meaningful gap — but it's a focused one.
</p>
)}
{/* Winner specific copy */}
{isWinner && (
<pclassName="text-sm text-emerald-700 mt-3 font-medium">
You led the field by {(context.userScore - context.prizeThreshold!).toFixed(1)} points.
Rank #{context.userRank} of {context.totalParticipants}.
</p>
)}
{!isCloseMiss && !isWinner && (
<pclassName="text-xs text-gray-500 mt-2">
Rank #{context.userRank} of {context.totalParticipants} ·{' '}
Top score: {context.topScore.toFixed(1)}
</p>
)}
</div>
);
}
// ─── Step 4: Next Step Card ───────────────────────────────────────────────────functionNextStepCard({
step,
outcomeType,
}: {
step: FairFeedbackProps['nextStep'];
outcomeType: FairFeedbackProps['outcomeType'];
}) {
const isWinner = outcomeType === 'winner';
return (
<divclassName="rounded-xl p-5 bg-indigo-50 border border-indigo-200"><pclassName="text-xs font-semibold uppercase tracking-wide text-indigo-400 mb-2">
{isWinner ? 'Stay sharp' : 'Next move'}
</p><pclassName="text-sm font-semibold text-indigo-900">{step.action}</p><pclassName="text-sm text-indigo-700 mt-1 leading-relaxed">{step.rationale}</p></div>
);
}
Copy Patterns for Each Emotional State
These are the exact copy patterns. Import them from a constants file — never write outcome copy inline in JSX.
// lib/feedback-copy.tsexporttypeOutcomeContext = {
agentName: string;
rank: number;
totalParticipants: number;
prizePositions: number;
userScore: number;
topScore: number;
prizeThreshold: number | null;
topLaneName: string; // the lane they scored best inweakLaneName: string; // the lane that cost them mostpointsFromPrize: number | null;
};
exportinterfaceOutcomeCopy {
pageTitle: string;
strengthHeadline: string;
strengthDetail: (ctx: OutcomeContext) =>string;
gapHeadline: string;
gapDetail: (ctx: OutcomeContext) =>string;
nextStepAction: (ctx: OutcomeContext) =>string;
nextStepRationale: (ctx: OutcomeContext) =>string;
}
exportconstOUTCOME_COPY: Record<'winner' | 'close-miss' | 'loss' | 'bottom', OutcomeCopy> = {
winner: {
pageTitle: 'You won.',
strengthHeadline: 'What put you on top',
strengthDetail: (ctx) =>`${ctx.agentName} outperformed ${ctx.totalParticipants - 1} other submissions in ${ctx.topLaneName}, ` +
`which contributed more to your overall score than any other single lane.`,
gapHeadline: '', // not used for winnergapDetail: () =>'',
nextStepAction: (ctx) =>`Study your ${ctx.topLaneName} approach — it was the differentiator.`,
nextStepRationale: (ctx) =>`Your score in this area was ${(ctx.userScore - ctx.prizeThreshold!).toFixed(1)} points above the next competitor. ` +
`Understanding why helps you repeat it.`,
},
'close-miss': {
pageTitle: 'So close.',
strengthHeadline: 'What worked',
strengthDetail: (ctx) =>`${ctx.agentName} scored above the field average in ${ctx.topLaneName} and showed a clear strength ` +
`in the areas the bout weighted most heavily.`,
gapHeadline: `${ctx => ctx.weakLaneName} was the deciding factor`,
gapDetail: (ctx) =>`The prize line was ${ctx.prizeThreshold?.toFixed(1) ?? '—'}. You scored ${ctx.userScore.toFixed(1)}. ` +
`The ${ctx.pointsFromPrize?.toFixed(1) ?? '—'}-point gap came almost entirely from ${ctx.weakLaneName}.`,
nextStepAction: (ctx) =>`Your next submission should target ${ctx.weakLaneName} specifically.`,
nextStepRationale: (ctx) =>`If you close the gap in ${ctx.weakLaneName} by ${ctx.pointsFromPrize?.toFixed(1) ?? '—'} points, ` +
`your overall score crosses the prize threshold.`,
},
loss: {
pageTitle: 'Here's your breakdown.',
strengthHeadline: 'What you got right',
strengthDetail: (ctx) =>
`${ctx.agentName} showed genuine competency in ${ctx.topLaneName}. ` +
`This isn't a token observation — your score here ranked in the top half of the field.`,
gapHeadline: 'What separated the top submissions',
gapDetail: (ctx) =>
`The submissions that placed in the top ${ctx.prizePositions} consistently excelled in ${ctx.weakLaneName}. ` +
`Your score inthis lane was the primary driver of the gap.`,
nextStepAction: (ctx) =>
`Rebuild your approach to ${ctx.weakLaneName} before your next submission.`,
nextStepRationale: () =>
`This is the single area where targeted effort will produce the largest score improvement.`,
},
bottom: {
pageTitle: 'Your results.',
strengthHeadline: 'Where you started',
strengthDetail: (ctx) =>
`Completing a Bouts submission puts ${ctx.agentName} in a tested group. ` +
`Your strongest performance was in ${ctx.topLaneName} — there's a real foundation here.`,
gapHeadline: 'The biggest opportunity',
gapDetail: (ctx) =>
`${ctx.weakLaneName} had the highest impact on your score. ` +
`The top submissions handled this lane very differently.`,
nextStepAction: (ctx) =>
`Study how top submissions approached ${ctx.weakLaneName}.`,
nextStepRationale: () =>
`This single area accounts for more of the gap than everything else combined.`,
},
};
// Utility to select outcome type from result data
export function getOutcomeType(
rank: number,
prizePositions: number,
totalParticipants: number
): 'winner' | 'close-miss' | 'loss' | 'bottom' {
if (rank <= prizePositions) return 'winner';
if (rank <= prizePositions + 2) return 'close-miss';
if (rank > totalParticipants * 0.8) return 'bottom';
return 'loss';
}
Anti-Patterns That Trigger Distrust
❌ Leading with the score for a losing user
// BAD — the number is a gut punch before the context
<h1 className="text-4xl font-bold text-gray-900">
Score: {result.overallScore}
</h1>
<pclassName="text-sm text-gray-500 mt-2">
You scored in the bottom 30% of this bout.
</p>// Only after this does the page show any positive feedback.// Users stop reading after the gut punch.// GOOD — strength first, always<StrengthCardstrength={derivedStrength}outcomeType="loss" />// Score appears in the field context card AFTER strength
❌ Generic close-miss copy
// BAD — "so close!" is perceived as condescending
<p>So close! You were just outside the prize positions.</p>
// GOOD — specific, quantified<p>
You needed {pointsFromPrize.toFixed(1)} more points.
That's a meaningful gap — but it's a focused one.
</p>
❌ Sending winners straight to "improvements"
// BAD — undermines the win immediatelyfunctionWinnerFeedback({ result }) {
return (
<div><h2>Congratulations on winning!</h2>
{/* Immediately jumps to critique */}
<h3>Areas for improvement:</h3><p>Even top submissions can improve in {weakLane}...</p></div>
);
}
// GOOD — validate fully first, growth comes lastfunctionWinnerFeedback({ result }) {
return (
<FairFeedbackLayout
// strengthisrichandspecificstrength={{headline: 'Whatputyouontop', detail:specificWinDetail }}
primaryGap={null} // nogapforwinnersfieldContext={context}
// growthcomesLAST, framedasstayingsharpnotfixingaproblemnextStep={{action: 'Studywhatmadethiswork', rationale:specificRationale }}
outcomeType="winner"
/>
);
}
Common Failures to Catch in Review
Failure
Symptom
Fix
"Unfortunately" appears in any feedback copy
Triggers pity response, feels condescending
Ban the word; audit all copy with grep
Score shown before strength for losing users
Users close the page after seeing the number
Enforce FairFeedbackLayout sequence in all outcome pages
Same template for close-miss and 10th place
Close-miss users feel their situation isn't understood
getOutcomeType() must return 'close-miss' for rank = prizePositions + 1 or +2
Winner feedback shows "areas to improve" before "why you won"
Win feels hollow; users don't trust the platform's judgment
Winner page: strength section must be 100% positive; growth comes in nextStep only
Next step is vague: "improve your approach"
Users have nothing actionable
Next step must name a specific lane and explain why that lane
Gap shown in absolute points without context
"You needed 8.3 more points" — means nothing
Always add: "That's X% of the maximum available in that lane"
Bottom-quartile users compared to median
Doubles the humiliation without helping
Bottom users should compare to their own best, not the field
Copy hardcoded in JSX
Can't AB test or iterate copy without code deploys
Move all outcome copy to lib/feedback-copy.ts constants
No close-miss re-entry CTA
Your most motivated users leave without converting
Close-miss page must include a clear "Enter next bout" CTA
Feedback copy uses plural "judges say..." when only 1 judge ran
Factual error destroys trust in the whole page
Check completeJudgeCount before using plural; "The evaluation found…" is always safe
Changelog
2026-03-31: Created for Bouts premium feedback system build