0
GAMES#2a2f623f
Prompt Duel
@princess-peach·deposited 3w ago·updated 3w ago·87 views
GAMES#2a2f623f
Prompt Duel
PR
@princess-peach
87Views
0Comments
0Forks
0Saves
SHARE · REMIX
Prompt Duel — a JSX games widget by @princess-peach.
CONTROLS
No comments yet. Be the first!
✦ Remix with AI
SDK in this widgetNo Vibes SDK features detected yet
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "Prompt Duel" by @princess-peach]
Source: https://itjustvibes.com/princess-peach/prompt-duel
Type: React/JSX
--- SOURCE CODE ---
```jsx
function Widget() {
const STORE_KEY = 'promptDuel:v1';
const ROOM_OPTIONS = [
{ id: 'spark-dome', label: 'Spark Dome' },
{ id: 'glitch-court', label: 'Glitch Court' },
{ id: 'boss-room', label: 'Boss Room' },
];
const PHASES = ['challenge', 'submit', 'reveal', 'vote', 'winner'];
const PHASE_COPY = {
challenge: {
eyebrow: 'Phase 1',
title: 'Challenge',
detail: 'Read the brief, gather your chaos, and get ready to write one killer prompt.',
action: 'Open Submissions',
},
submit: {
eyebrow: 'Phase 2',
title: 'Submit',
detail: 'Each duelist gets one shot. Keep it sharp, funny, and wildly effective.',
action: 'Reveal Prompts',
},
reveal: {
eyebrow: 'Phase 3',
title: 'Reveal',
detail: 'The room can finally see the cards. No votes yet, just dramatic inspection.',
action: 'Start Voting',
},
vote: {
eyebrow: 'Phase 4',
title: 'Vote',
detail: 'Pick the one prompt that would most likely create the best thing.',
action: 'Crown Winner',
},
winner: {
eyebrow: 'Phase 5',
title: 'Winner',
detail: 'A champion has emerged. Save the crown or roll into the next duel.',
action: 'Next Challenge',
},
};
const TITLE_POOL = [
'Latency Gladiator',
'Prompt Warlord',
'Tiny Build Tyrant',
'Chaos Architect',
'Arena Whisperer',
'Fork Goblin Supreme',
];
const CHALLENGES = [
{
title: 'Ship a useful app in under 20KB',
category: 'Efficiency',
constraint: 'Prioritize one clean workflow, no fluff, instant value.',
},
{
title: 'Make a ridiculous but lovable image toy',
category: 'Visuals',
constraint: 'The result should be screenshot bait in under ten seconds.',
},
{
title: 'Design a tiny game for people waiting in line',
category: 'Game',
constraint: 'Rules must fit on a sticky note and feel good on a phone.',
},
{
title: 'Build a tool that fixes one annoying creator problem',
category: 'Utility',
constraint: 'The pitch should sound instantly practical and a little dangerous.',
},
{
title: 'Pitch a widget that turns daily chores into drama',
category: 'Lifestyle',
constraint: 'High personality, clear payoff, compact scope.',
},
];
const memoryProfileRef = React.useRef({
displayName: 'Prompt Rogue',
userId: `duelist-${Math.random().toString(36).slice(2, 8)}`,
roomId: ROOM_OPTIONS[0].id,
wins: 0,
favorites: [],
streakTitle: 'Undercard Instigator',
});
const sharedRef = React.useRef(null);
const roomInputRef = React.useRef(ROOM_OPTIONS[0].id);
const profileRef = React.useRef(memoryProfileRef.current);
const awardRef = React.useRef(null);
const [ready, setReady] = React.useState(false);
const [profile, setProfile] = React.useState(memoryProfileRef.current);
const [roomInput, setRoomInput] = React.useState(ROOM_OPTIONS[0].id);
const [draft, setDraft] = React.useState('');
const [copied, setCopied] = React.useState(false);
const [notice, setNotice] = React.useState('Shared room ready for chaos.');
const [roomState, setRoomState] = React.useState(createRoomState(ROOM_OPTIONS[0].id, 1));
function normalizeRoomId(value) {
const cleaned = String(value || '')
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 18);
return cleaned || ROOM_OPTIONS[0].id;
}
function getChallenge(index) {
return CHALLENGES[index % CHALLENGES.length];
}
function getTitle(seed) {
return TITLE_POOL[seed % TITLE_POOL.length];
}
function createRoomState(roomId, round, challengeIndex) {
const normalizedRoomId = normalizeRoomId(roomId);
const finalRound = Math.max(1, Number(round) || 1);
const finalIndex = typeof challengeIndex === 'number'
? challengeIndex % CHALLENGES.length
: (normalizedRoomId.length + finalRound - 1) % CHALLENGES.length;
return {
roomId: normalizedRoomId,
round: finalRound,
phase: 'challenge',
challengeIndex: finalIndex,
challenge: getChallenge(finalIndex),
submissions: [],
votes: {},
winnerId: null,
updatedAt: Date.now(),
};
}
function sanitizeFavorites(items) {
if (!Array.isArray(items)) return [];
return items
.filter((item) => item && typeof item === 'object')
.map((item) => ({
id: String(item.id || `fav-${Math.random().toString(36).slice(2, 8)}`),
title: String(item.title || 'Winning prompt').slice(0, 90),
author: String(item.author || 'Unknown duelist').slice(0, 28),
roomId: normalizeRoomId(item.roomId || ROOM_OPTIONS[0].id),
}))
.slice(0, 6);
}
function sanitizeProfile(saved) {
return {
displayName: typeof saved?.displayName === 'string' && saved.displayName.trim()
? saved.displayName.trim().slice(0, 24)
: 'Prompt Rogue',
userId: typeof saved?.userId === 'string' && saved.userId
? saved.userId.slice(0, 40)
: `duelist-${Math.random().toString(36).slice(2, 8)}`,
roomId: normalizeRoomId(saved?.roomId || ROOM_OPTIONS[0].id),
wins: Math.max(0, Number(saved?.wins) || 0),
favorites: sanitizeFavorites(saved?.favorites),
streakTitle: typeof saved?.streakTitle === 'string' && saved.streakTitle.trim()
? saved.streakTitle.trim().slice(0, 32)
: 'Undercard Instigator',
};
}
function sanitizeSubmission(item, index) {
return {
id: typeof item?.id === 'string' && item.id ? item.id : `submission-${index + 1}`,
authorId: typeof item?.authorId === 'string' ? item.authorId.slice(0, 40) : `guest-${index + 1}`,
authorName: typeof item?.authorName === 'string' && item.authorName.trim()
? item.authorName.trim().slice(0, 24)
: `Duelist ${index + 1}`,
prompt: typeof item?.prompt === 'string' && item.prompt.trim()
? item.prompt.trim().slice(0, 220)
: 'Mystery prompt incoming.',
createdAt: Number(item?.createdAt) || Date.now() + index,
};
}
function sanitizeVotes(votes, submissions) {
const allowedIds = new Set(submissions.map((item) => item.id));
if (!votes || typeof votes !== 'object') return {};
const clean = {};
Object.entries(votes).forEach(([userId, submissionId]) => {
if (typeof userId !== 'string' || typeof submissionId !== 'string') return;
if (!allowedIds.has(submissionId)) return;
clean[userId.slice(0, 40)] = submissionId;
});
return clean;
}
function sanitizeRoomState(nextState, fallbackRoomId) {
const roomId = normalizeRoomId(nextState?.roomId || fallbackRoomId || ROOM_OPTIONS[0].id);
const round = Math.max(1, Number(nextState?.round) || 1);
const challengeIndex = Math.max(0, Number(nextState?.challengeIndex) || 0) % CHALLENGES.length;
const phase = PHASES.includes(nextState?.phase) ? nextState.phase : 'challenge';
const submissions = Array.isArray(nextState?.submissions)
? nextState.submissions.map(sanitizeSubmission).slice(0, 8)
: [];
const votes = sanitizeVotes(nextState?.votes, submissions);
const winnerId = typeof nextState?.winnerId === 'string' && submissions.some((item) => item.id === nextState.winnerId)
? nextState.winnerId
: null;
return {
roomId,
round,
phase,
challengeIndex,
challenge: getChallenge(challengeIndex),
submissions,
votes,
winnerId,
updatedAt: Number(nextState?.updatedAt) || Date.now(),
};
}
function getVoteCount(submissionId, votes) {
return Object.values(votes || {}).filter((value) => value === submissionId).length;
}
function getWinner(state) {
if (!state.submissions.length) return null;
const ranked = [...state.submissions].sort((a, b) => {
const voteGap = getVoteCount(b.id, state.votes) - getVoteCount(a.id, state.votes);
if (voteGap !== 0) return voteGap;
return a.createdAt - b.createdAt;
});
return ranked[0];
}
function getRoomLabel(roomId) {
const match = ROOM_OPTIONS.find((room) => room.id === roomId);
if (match) return match.label;
return roomId
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
React.useEffect(() => {
profileRef.current = profile;
}, [profile]);
React.useEffect(() => {
roomInputRef.current = roomInput;
}, [roomInput]);
React.useEffect(() => {
let cancelled = false;
const initialRoom = createRoomState(ROOM_OPTIONS[0].id, 1);
sharedRef.current = initialRoom;
setRoomState(initialRoom);
const api = window.vibes;
const shared = api && api.shared ? api.shared : null;
const ROOM = 'prompt-duel';
const SHARED_KEY = 'state';
if (shared && typeof shared.join === 'function') {
shared.join(ROOM, { persistent: true })
.then(() => {
if (typeof shared.onChange === 'function') {
shared.onChange(SHARED_KEY, (nextState) => {
if (cancelled || !nextState) return;
const safeState = sanitizeRoomState(nextState, roomInputRef.current);
sharedRef.current = safeState;
if (safeState.roomId === profileRef.current.roomId) {
setRoomState(safeState);
setNotice(`Live sync active in ${getRoomLabel(safeState.roomId)}.`);
}
});
}
})
.catch((err) => console.error('shared.join failed:', err && err.message));
}
if (!api || typeof api.onReady !== 'function') {
setReady(true);
setNotice('Running in local-only fallback mode.');
return () => {
cancelled = true;
};
}
api.onReady(async () => {
const saved = await api.load(STORE_KEY);
if (cancelled) return;
const nextProfile = sanitizeProfile(saved || {});
memoryProfileRef.current = nextProfile;
profileRef.current = nextProfile;
setProfile(nextProfile);
setRoomInput(nextProfile.roomId);
const nextRoom = createRoomState(nextProfile.roomId, 1);
sharedRef.current = nextRoom;
setRoomState(nextRoom);
setReady(true);
setNotice(shared && typeof shared.set === 'function'
? `Connected to shared room sync for ${getRoomLabel(nextProfile.roomId)}.`
: 'Saved profile loaded. Shared sync unavailable, using local room state.');
});
return () => {
cancelled = true;
};
}, []);
React.useEffect(() => {
if (!ready) return;
if (roomState.roomId === profile.roomId) return;
const nextRoom = sharedRef.current && sharedRef.current.roomId === profile.roomId
? sharedRef.current
: createRoomState(profile.roomId, 1);
setRoomState(nextRoom);
if (nextRoom !== sharedRef.current) {
publishRoom(nextRoom);
}
}, [profile.roomId, ready]);
React.useEffect(() => {
if (roomState.phase !== 'winner') return;
const resolvedWinner = roomState.submissions.find((item) => item.id === roomState.winnerId) || getWinner(roomState);
if (!resolvedWinner) return;
awardWinner(resolvedWinner);
}, [roomState.phase, roomState.winnerId, roomState.roomId, roomState.round]);
async function saveProfile(nextProfile) {
const safeProfile = sanitizeProfile(nextProfile);
memoryProfileRef.current = safeProfile;
profileRef.current = safeProfile;
setProfile(safeProfile);
const api = window.vibes;
if (api && typeof api.save === 'function') {
await api.save(STORE_KEY, safeProfile);
}
}
function publishRoom(nextState) {
const safeState = sanitizeRoomState(nextState, profileRef.current.roomId);
sharedRef.current = safeState;
setRoomState(safeState);
const api = window.vibes;
if (api && api.shared && typeof api.shared.set === 'function') {
api.shared.set('state', safeState);
}
}
async function handleNameChange(value) {
await saveProfile({
...profileRef.current,
displayName: value.slice(0, 24),
});
}
async function joinRoom(nextRoomId) {
const normalized = normalizeRoomId(nextRoomId);
setRoomInput(normalized);
await saveProfile({
...profileRef.current,
roomId: normalized,
});
const nextRoom = sharedRef.current && sharedRef.current.roomId === normalized
? sharedRef.current
: createRoomState(normalized, 1);
publishRoom(nextRoom);
setDraft('');
setNotice(`Joined ${getRoomLabel(normalized)}.`);
}
function hasSubmitted() {
return roomState.submissions.some((item) => item.authorId === profile.userId);
}
async function submitPrompt(event) {
event.preventDefault();
if (roomState.phase !== 'submit') return;
const text = draft.trim();
if (!text || hasSubmitted()) return;
const entry = {
id: `${profile.userId}-${Date.now().toString(36)}`,
authorId: profile.userId,
authorName: profile.displayName || 'Prompt Rogue',
prompt: text.slice(0, 220),
createdAt: Date.now(),
};
publishRoom({
...roomState,
submissions: [...roomState.submissions, entry].slice(0, 8),
updatedAt: Date.now(),
});
setDraft('');
setNotice('Prompt locked in. Time to stare down the room.');
}
async function advancePhase() {
const currentIndex = PHASES.indexOf(roomState.phase);
if (currentIndex === -1) return;
if (roomState.phase === 'vote' && roomState.submissions.length > 0) {
const winner = getWinner(roomState);
publishRoom({
...roomState,
phase: 'winner',
winnerId: winner ? winner.id : null,
updatedAt: Date.now(),
});
await awardWinner(winner);
return;
}
if (roomState.phase === 'winner') {
const nextRound = roomState.round + 1;
const nextIndex = (roomState.challengeIndex + 1) % CHALLENGES.length;
publishRoom(createRoomState(roomState.roomId, nextRound, nextIndex));
setNotice('Fresh challenge loaded. Gloves back on.');
return;
}
publishRoom({
...roomState,
phase: PHASES[currentIndex + 1],
updatedAt: Date.now(),
});
setNotice(`${PHASE_COPY[PHASES[currentIndex + 1]].title} phase is live.`);
}
async function awardWinner(winner) {
if (!winner || awardRef.current === `${roomState.roomId}:${roomState.round}`) return;
awardRef.current = `${roomState.roomId}:${roomState.round}`;
if (winner.authorId !== profile.userId) return;
const nextWins = profileRef.current.wins + 1;
await saveProfile({
...profileRef.current,
wins: nextWins,
streakTitle: getTitle(nextWins),
});
setNotice(`You won round ${roomState.round} and earned ${getTitle(nextWins)}.`);
}
async function castVote(submissionId) {
if (roomState.phase !== 'vote') return;
const target = roomState.submissions.find((item) => item.id === submissionId);
if (!target || target.authorId === profile.userId) return;
const nextVotes = { ...roomState.votes };
if (nextVotes[profile.userId] === submissionId) {
delete nextVotes[profile.userId];
setNotice('Vote cleared. The drama remains unresolved.');
} else {
nextVotes[profile.userId] = submissionId;
setNotice(`Vote cast for ${target.authorName}.`);
}
publishRoom({
...roomState,
votes: nextVotes,
updatedAt: Date.now(),
});
}
async function saveFavoriteWinner() {
const winner = roomState.submissions.find((item) => item.id === roomState.winnerId) || getWinner(roomState);
if (!winner) return;
const favorites = [
{
id: `${roomState.roomId}-${roomState.round}`,
title: winner.prompt,
author: winner.authorName,
roomId: roomState.roomId,
},
...profileRef.current.favorites.filter((item) => item.id !== `${roomState.roomId}-${roomState.round}`),
].slice(0, 6);
await saveProfile({
...profileRef.current,
favorites,
});
setNotice('Winning prompt saved to your pocket hall of fame.');
}
async function copyChallenge() {
if (!navigator.clipboard) return;
const text = `Prompt Duel challenge: ${roomState.challenge.title}. Constraint: ${roomState.challenge.constraint}`;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}
const winner = roomState.submissions.find((item) => item.id === roomState.winnerId) || (roomState.phase === 'winner' ? getWinner(roomState) : null);
const myVote = roomState.votes[profile.userId] || null;
const phaseMeta = PHASE_COPY[roomState.phase];
const roomHasShared = !!(window.vibes && window.vibes.shared && typeof window.vibes.shared.set === 'function');
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(250,204,21,0.18),_transparent_30%),radial-gradient(circle_at_bottom,_rgba(244,63,94,0.16),_transparent_34%),linear-gradient(180deg,#14080f_0%,#1f1021_45%,#12060c_100%)] p-4 text-zinc-100">
<section className="mx-auto flex max-w-md flex-col gap-4 rounded-[28px] border border-white/10 bg-black/35 p-4 shadow-[0_18px_70px_rgba(0,0,0,0.55)] backdrop-blur">
<header className="overflow-hidden rounded-[26px] border border-rose-200/15 bg-[linear-gradient(135deg,rgba(251,191,36,0.24),rgba(244,63,94,0.16),rgba(59,130,246,0.16))] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.32em] text-amber-100/80">Live Multiplayer Room</p>
<h1 className="text-3xl font-black tracking-tight text-white">Prompt Duel</h1>
<p className="mt-1 max-w-xs text-sm text-rose-50/80">Challenge the room, hide your prompt in plain sight, and let the crowd decide who cooked.</p>
</div>
<div className="rounded-2xl border border-white/10 bg-black/30 px-3 py-2 text-right">
<p className="text-[10px] uppercase tracking-[0.2em] text-zinc-400">Round</p>
<p className="text-2xl font-black text-amber-200">{roomState.round}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-2">
<div className="rounded-2xl bg-black/25 p-3">
<p className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">Room</p>
<p className="mt-1 text-sm font-bold text-white">{getRoomLabel(roomState.roomId)}</p>
</div>
<div className="rounded-2xl bg-black/25 p-3">
<p className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">Wins</p>
<p className="mt-1 text-sm font-bold text-emerald-300">{profile.wins}</p>
</div>
<div className="rounded-2xl bg-black/25 p-3">
<p className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">Mode</p>
<p className="mt-1 text-sm font-bold text-sky-200">{roomHasShared ? 'Shared' : 'Local'}</p>
</div>
</div>
</header>
<section className="grid grid-cols-2 gap-3">
<label className="col-span-2 flex flex-col gap-2 rounded-2xl border border-white/10 bg-white/5 p-3">
<span className="text-[11px] font-bold uppercase tracking-[0.2em] text-zinc-400">Display Name</span>
<input
value={profile.displayName}
onChange={(event) => handleNameChange(event.target.value)}
className="rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-amber-300"
placeholder="Prompt Rogue"
/>
</label>
<label className="col-span-2 flex flex-col gap-2 rounded-2xl border border-white/10 bg-white/5 p-3">
<span className="text-[11px] font-bold uppercase tracking-[0.2em] text-zinc-400">Create or Join Room</span>
<div className="flex gap-2">
<input
value={roomInput}
onChange={(event) => setRoomInput(normalizeRoomId(event.target.value))}
className="min-w-0 flex-1 rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-rose-300"
placeholder="spark-dome"
/>
<button
type="button"
onClick={() => joinRoom(roomInput)}
className="rounded-xl bg-white/10 px-3 py-2 text-xs font-black uppercase tracking-[0.14em] text-white"
>
Join
</button>
</div>
<div className="flex flex-wrap gap-2">
{ROOM_OPTIONS.map((room) => (
<button
key={room.id}
type="button"
onClick={() => joinRoom(room.id)}
className={`rounded-full px-3 py-1 text-[11px] font-black uppercase tracking-[0.12em] ${
profile.roomId === room.id
? 'bg-amber-300 text-zinc-950'
: 'bg-white/5 text-zinc-300'
}`}
>
{room.label}
</button>
))}
</div>
</label>
</section>
<section className="rounded-[26px] border border-amber-300/20 bg-black/25 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.28em] text-amber-200">{phaseMeta.eyebrow}</p>
<h2 className="mt-1 text-2xl font-black text-white">{phaseMeta.title}</h2>
<p className="mt-2 text-sm text-zinc-300">{phaseMeta.detail}</p>
</div>
<button
type="button"
onClick={advancePhase}
className="shrink-0 rounded-2xl bg-gradient-to-r from-amber-300 via-rose-300 to-sky-300 px-3 py-2 text-xs font-black uppercase tracking-[0.14em] text-zinc-950"
>
{phaseMeta.action}
</button>
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">{roomState.challenge.category}</p>
<h3 className="mt-1 text-lg font-black text-white">{roomState.challenge.title}</h3>
<p className="mt-2 text-sm text-zinc-300">{roomState.challenge.constraint}</p>
</div>
<button
type="button"
onClick={copyChallenge}
className="rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-xs font-bold text-zinc-100"
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
</section>
<form onSubmit={submitPrompt} className="flex flex-col gap-3 rounded-[26px] border border-rose-300/15 bg-white/5 p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-black uppercase tracking-[0.24em] text-rose-100">Submit Your Prompt</h2>
<span className="text-xs text-zinc-500">{draft.trim().length}/220</span>
</div>
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value.slice(0, 220))}
disabled={roomState.phase !== 'submit' || hasSubmitted()}
placeholder="Example: Build a one-screen studio app that turns a messy idea into a polished prompt with zero onboarding."
className="min-h-28 rounded-2xl border border-white/10 bg-black/30 p-3 text-sm text-white outline-none focus:border-rose-300 disabled:cursor-not-allowed disabled:opacity-50"
/>
<button
type="submit"
disabled={roomState.phase !== 'submit' || hasSubmitted() || !draft.trim()}
className="rounded-2xl bg-gradient-to-r from-rose-400 via-amber-300 to-sky-300 px-4 py-3 text-sm font-black uppercase tracking-[0.16em] text-zinc-950 disabled:cursor-not-allowed disabled:opacity-50"
>
{hasSubmitted() ? 'Prompt Locked' : 'Enter the Duel'}
</button>
<p className="text-xs text-zinc-500">
{roomState.phase === 'submit'
? 'One prompt per player this round.'
: 'Submissions reopen when the room returns to the submit phase.'}
</p>
</form>
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-black uppercase tracking-[0.24em] text-zinc-300">Duel Cards</h2>
<p className="text-xs text-zinc-500">{roomState.submissions.length} loaded</p>
</div>
<div className="space-y-3">
{roomState.submissions.length === 0 ? (
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/5 p-4 text-sm text-zinc-500">
No prompts in the room yet. Open submissions and let the first duelist swing.
</div>
) : (
roomState.submissions.map((item, index) => {
const votes = getVoteCount(item.id, roomState.votes);
const revealed = roomState.phase !== 'challenge' && roomState.phase !== 'submit';
const showAuthor = roomState.phase === 'winner';
const isWinner = winner && winner.id === item.id;
const isMine = item.authorId === profile.userId;
const canVote = roomState.phase === 'vote' && !isMine;
return (
<article
key={item.id}
className={`rounded-[26px] border p-[1px] ${
isWinner
? 'border-transparent bg-gradient-to-r from-amber-300 via-rose-300 to-sky-300'
: 'border-white/10 bg-white/5'
}`}
>
<div className="rounded-[24px] bg-zinc-950/90 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.24em] text-zinc-500">
{revealed ? `Card ${index + 1}` : 'Hidden Entry'}
</p>
<h3 className="mt-1 text-base font-black text-white">
{revealed ? item.prompt : 'Submission sealed until reveal.'}
</h3>
<p className="mt-2 text-xs text-zinc-400">
{showAuthor ? `By ${item.authorName}` : isMine ? 'Your card is in the stack.' : 'Author hidden until the crown lands.'}
</p>
</div>
<div className="text-right">
<p className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">Votes</p>
<p className={`text-2xl font-black ${isWinner ? 'text-amber-200' : 'text-white'}`}>{votes}</p>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="text-xs text-zinc-500">
{isWinner ? getTitle(votes + roomState.round) : isMine ? 'Your duel entry' : 'Audience target'}
</div>
<button
type="button"
onClick={() => castVote(item.id)}
disabled={!canVote}
className={`rounded-2xl px-3 py-2 text-xs font-black uppercase tracking-[0.14em] ${
myVote === item.id
? 'bg-emerald-300 text-emerald-950'
: canVote
? 'bg-white/10 text-white'
: 'bg-white/5 text-zinc-500'
}`}
>
{myVote === item.id ? 'Voted' : canVote ? 'Vote' : isMine ? 'Own Card' : 'Locked'}
</button>
</div>
</div>
</article>
);
})
)}
</div>
</section>
<section className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<article className="rounded-[24px] border border-white/10 bg-white/5 p-4">
<p className="text-[10px] font-black uppercase tracking-[0.24em] text-zinc-500">Winner Board</p>
{winner && roomState.phase === 'winner' ? (
<>
<h2 className="mt-2 text-lg font-black text-amber-200">{winner.authorName}</h2>
<p className="mt-1 text-sm text-white">{winner.prompt}</p>
<p className="mt-2 text-xs text-zinc-400">{getTitle(getVoteCount(winner.id, roomState.votes) + roomState.round)}</p>
<button
type="button"
onClick={saveFavoriteWinner}
className="mt-3 rounded-2xl bg-amber-300 px-3 py-2 text-xs font-black uppercase tracking-[0.14em] text-zinc-950"
>
Save Crown
</button>
</>
) : (
<p className="mt-2 text-sm text-zinc-500">The winner card lights up after the room reaches the final phase.</p>
)}
</article>
<article className="rounded-[24px] border border-white/10 bg-white/5 p-4">
<p className="text-[10px] font-black uppercase tracking-[0.24em] text-zinc-500">Saved Hall of Fame</p>
<p className="mt-1 text-xs text-sky-200">{profile.streakTitle}</p>
<div className="mt-3 space-y-2">
{profile.favorites.length === 0 ? (
<p className="text-sm text-zinc-500">Saved winning prompts will stack here for future remixing.</p>
) : (
profile.favorites.map((item) => (
<div key={item.id} className="rounded-2xl bg-black/25 p-3">
<p className="text-sm font-bold text-white">{item.title}</p>
<p className="mt-1 text-xs text-zinc-400">{item.author} in {getRoomLabel(item.roomId)}</p>
</div>
))
)}
</div>
</article>
</section>
<footer className="rounded-[22px] border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
<p>{notice}</p>
</footer>
</section>
</main>
);
}
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Agent-Accessible State (vibes.ai.*)
State written with the standard `vibes.save` / `vibes.load` methods is private to each user and is **NOT readable by AI agents**. To share state with an AI agent (via the MCP connector), use the `vibes.ai` sub-namespace:
| Method | Description |
|--------|-------------|
| `await vibes.ai.setState(key, value)` | Write agent-readable state. Key is stored internally as `ai/<key>`. |
| `await vibes.ai.getState(key)` | Read agent-readable state. Returns `null` if key absent. |
| `await vibes.ai.listKeys()` | List all agent-readable keys (without the `ai/` prefix). |
**Important rules:**
- Regular `vibes.save()` / `vibes.load()` is **NOT agent-accessible** — use `vibes.ai.*` for state you want agents to read.
- The widget **owner** must enable agent access in **Manage → Agent tab** before any agent can read or write `vibes.ai.*` state.
- Keys are auto-prefixed to `ai/` internally; you supply just the short key (e.g. `'context'`).
```js
vibes.onReady(async () => {
// Write state an AI agent can later read
await vibes.ai.setState('context', { currentLevel: 3, score: 1500 });
// Read it back (same auto-prefix applies)
const ctx = await vibes.ai.getState('context');
// List all agent-accessible keys for this widget
const keys = await vibes.ai.listKeys(); // e.g. ['context']
});
```
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **External JS libraries are allowed and encouraged** — load them via a `<script src>` tag from a reputable CDN (jsDelivr, unpkg, or cdnjs) with a pinned version, or inline the library source. The only exception: do NOT add a script tag for the Vibes SDK itself — it is auto-injected (see Rule 2).
6. **Keep total code under 100KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained JSX file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/princess-peach/prompt-duel */