Don't make code changes outside of this file. Help me edit this prompt so that Townie with Claude Opus 4.5 gives best results.
I want to change the postgame workflow to support club leaderboards.
After the user gets their scored results,
-
header
-
resultsTable
-
clubNameInput
-
userNameInput
-
submitToLeaderboardButton
-
club names should be a string of only 26 letters in the alphabet. no digits or other characters or spaces.(ie, can be input using the keyboard component)
-
club names should be no more than 25 characters long
likewise for players:
- player names should be a string of 26 letters in the alphabet. no digits or other characters or spaces.(ie, can be input using the keyboard component)
- player names should be no more than 25 characters long
each submission gets a unique id, two players can submit with the same userName and clubName
Here's what I was thinking for data needed to be collected to support the leaderboard view. Although submitted at is not currently needed, it should be included. if this is insufficient, or could be handled better, make those changes.
-
submission_id
-
game_id
-
club_name
-
user_name
-
submitted_at: timestamp when the user submitted to finish the final round, ending the game.
-
total_points: sum of length of every valid submission, as displayed on the postGame resultsTable.
-
submissions: array of strings containing the words the user played, in order
-
submission_validity : array of booleans of same length of submission, true for valid words and false for invalid,
-
create a file scripts/initDbResults.ts to initialize a game_results table where these are stored.
- add leaderboard as a gameState.
- the user transitions to the leaderboard state after they press the submitToLeaderboardButton
- the leaderboard view displays a scrollable table with the results of every submission of to that club.
- the results should be sorted by default from most total points to least
- reuse the roundIndicator, but with an extra unconnected dot for total points, to allow the user to change the sort order to sort by round.
- if two users have the same total points, use the username as a tiebreaker for sort order.
- if two users have the same number of points for a round, use the submitted word as the first tiebreaker, followed by user_name.
- The club name should be autopopulated (but editable) if the user navigated to the app via a path with "/club/{clubname}/" (or "/club/{clubname}" without the terminal "/")
- if a user inputs the url with a path "/club/{clubname}/leaderboard/" into their browser, that should take them to club leaderboard
- if there are no player submissions with that club name, indicate "nobody in the club has played yet!"
- when the user navigates from the postGame to leaderboard gameState, update the path in the browser as well.
Current Architecture:
- Main app component:
frontend/components/App.tsx - GameState type:
"preGame" | "inGame" | "postGame"(defined in App.tsx) - Backend server:
index.ts(Hono framework) - Database: Val Town SQLite via
import { sqlite } from "https://esm.town/v/std/sqlite" - React version: 18.2.0 (via esm.sh)
- Keyboard component exists at:
frontend/components/Keyboard.tsx(supports A-Z input only) - RoundIndicator component exists at:
frontend/components/RoundIndicator.tsx - Existing API endpoint:
POST /api/validatefor word validation
Create a new file scripts/initDbResults.ts to initialize the game_results table for storing club leaderboard data.
Requirements:
-
Import sqlite:
import { sqlite } from "https://esm.town/v/std/sqlite" -
Create table with this schema:
CREATE TABLE IF NOT EXISTS game_results (
submission_id TEXT PRIMARY KEY,
game_id TEXT NOT NULL,
club_name TEXT NOT NULL,
user_name TEXT NOT NULL,
submitted_at TEXT NOT NULL,
total_points INTEGER NOT NULL,
submissions TEXT NOT NULL,
submission_validity TEXT NOT NULL
)
- Create an index for efficient club queries:
CREATE INDEX IF NOT EXISTS idx_club_name ON game_results(club_name)
- Create an index for efficient club + total_points queries (for leaderboard sorting):
CREATE INDEX IF NOT EXISTS idx_club_leaderboard
ON game_results(club_name, total_points DESC, user_name)
Implementation notes:
submissionsandsubmission_validitywill be stored as JSON strings (arrays)submission_idshould be generated as a UUID when insertingclub_nameanduser_nameshould be stored as uppercase for case-insensitive matchingsubmitted_atshould be ISO 8601 timestamp string- Use
sqlite.execute()with SQL strings
Validation: After creating the file, run it to initialize the table.
Update frontend/components/App.tsx to add club submission UI to the postGame view.
Requirements:
- Update GameState type:
type GameState = "preGame" | "inGame" | "postGame" | "leaderboard";
- Add state for club submission form (add these useState calls):
const [clubName, setClubName] = useState("");
const [playerName, setPlayerName] = useState("");
- Add URL routing logic:
- On component mount (useEffect), check
window.location.pathname - If path matches
/club/{clubname}or/club/{clubname}/, extract clubname and set it toclubNamestate (converted to uppercase) - If path matches
/club/{clubname}/leaderboardor/club/{clubname}/leaderboard/, set gameState to "leaderboard" and clubName state
- Update the postGame JSX block (where
gameState === "postGame"):
After the <ResultsTable results={results} />, add:
<div className="club-submission">
<h2>Submit to Club Leaderboard</h2>
<div className="input-group">
<label htmlFor="club-name">Club Name</label>
<input
id="club-name"
type="text"
value={clubName}
onChange={(e) => {
const val = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
if (val.length <= 25) setClubName(val);
}}
placeholder="Enter club name (A-Z only, max 25)"
maxLength={25}
/>
</div>
<div className="input-group">
<label htmlFor="player-name">Player Name</label>
<input
id="player-name"
type="text"
value={playerName}
onChange={(e) => {
const val = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
if (val.length <= 25) setPlayerName(val);
}}
placeholder="Enter your name (A-Z only, max 25)"
maxLength={25}
/>
</div>
<button
onClick={handleSubmitToLeaderboard}
disabled={clubName.length === 0 || playerName.length === 0}
className="submit-leaderboard-button"
>
Submit to Leaderboard
</button>
</div>
- Add handleSubmitToLeaderboard function:
const handleSubmitToLeaderboard = async () => {
if (!results || clubName.length === 0 || playerName.length === 0) return;
// Calculate total points
const totalPoints = results.reduce((sum, r) =>
sum + (r.isValid ? r.word.length : 0), 0
);
// Generate game_id (you can use timestamp or a simple UUID approach)
const gameId = crypto.randomUUID();
const payload = {
game_id: gameId,
club_name: clubName,
user_name: playerName,
total_points: totalPoints,
submissions: results.map(r => r.word),
submission_validity: results.map(r => r.isValid)
};
try {
const response = await fetch("/api/submit-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (response.ok) {
// Navigate to leaderboard
setGameState("leaderboard");
window.history.pushState({}, "", `/club/${clubName}/leaderboard`);
} else {
alert("Failed to submit to leaderboard");
}
} catch (error) {
console.error("Submission error:", error);
alert("Failed to submit to leaderboard");
}
};
Implementation notes:
- Input validation happens in onChange handlers
- Only A-Z characters allowed, automatically converted to uppercase
- Submit button is disabled unless both fields have valid input
- After successful submission, transition to leaderboard gameState and update browser URL
Add two new API endpoints to index.ts:
Endpoint 1: POST /api/submit-result
Add this route to the Hono app:
app.post("/api/submit-result", async (c) => {
const { game_id, club_name, user_name, total_points, submissions, submission_validity }
= await c.req.json();
// Validate inputs
if (!club_name || !user_name || !game_id) {
return c.json({ error: "Missing required fields" }, 400);
}
// Generate unique submission_id
const submission_id = crypto.randomUUID();
const submitted_at = new Date().toISOString();
// Convert arrays to JSON strings for storage
const submissionsJson = JSON.stringify(submissions);
const validityJson = JSON.stringify(submission_validity);
try {
await sqlite.execute({
sql: `INSERT INTO game_results
(submission_id, game_id, club_name, user_name, submitted_at, total_points, submissions, submission_validity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
submission_id,
game_id,
club_name.toUpperCase(),
user_name.toUpperCase(),
submitted_at,
total_points,
submissionsJson,
validityJson
]
});
return c.json({ submission_id });
} catch (error) {
console.error("Database error:", error);
return c.json({ error: "Failed to save result" }, 500);
}
});
Endpoint 2: GET /api/leaderboard/:clubName
Add this route to the Hono app:
app.get("/api/leaderboard/:clubName", async (c) => {
const clubName = c.req.param("clubName").toUpperCase();
try {
const result = await sqlite.execute({
sql: `SELECT submission_id, user_name, total_points, submissions, submission_validity, submitted_at
FROM game_results
WHERE club_name = ?
ORDER BY total_points DESC, user_name ASC`,
args: [clubName]
});
// Transform rows to objects and parse JSON fields
const entries = result.rows.map(row => ({
submission_id: row[0],
user_name: row[1],
total_points: row[2],
submissions: JSON.parse(row[3] as string),
submission_validity: JSON.parse(row[4] as string),
submitted_at: row[5]
}));
return c.json({
club_name: clubName,
entries
});
} catch (error) {
console.error("Database error:", error);
return c.json({ error: "Failed to fetch leaderboard" }, 500);
}
});
Implementation notes:
- Both endpoints use parameterized queries to prevent SQL injection
- club_name and user_name are normalized to uppercase for case-insensitive matching
- Arrays are stored as JSON strings and parsed when retrieved
- Leaderboard is sorted by total_points DESC, with user_name as tiebreaker
Create a new component frontend/components/Leaderboard.tsx and integrate it into App.tsx.
Requirements:
- Create Leaderboard.tsx:
/** @jsxImportSource https://esm.sh/react@18.2.0 */
import { useState, useEffect } from "https://esm.sh/react@18.2.0";
import { RoundIndicator } from "./RoundIndicator.tsx";
interface LeaderboardEntry {
submission_id: string;
user_name: string;
total_points: number;
submissions: string[];
submission_validity: boolean[];
submitted_at: string;
}
interface LeaderboardProps {
clubName: string;
}
export function Leaderboard({ clubName }: LeaderboardProps) {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [sortByRound, setSortByRound] = useState<number | null>(null); // null = total
useEffect(() => {
fetchLeaderboard();
}, [clubName]);
const fetchLeaderboard = async () => {
try {
const response = await fetch(`/api/leaderboard/${clubName}`);
const data = await response.json();
setEntries(data.entries || []);
} catch (error) {
console.error("Failed to load leaderboard:", error);
} finally {
setLoading(false);
}
};
const sortedEntries = [...entries].sort((a, b) => {
if (sortByRound === null) {
// Sort by total points
if (b.total_points !== a.total_points) {
return b.total_points - a.total_points;
}
return a.user_name.localeCompare(b.user_name);
} else {
// Sort by specific round
const aPoints = a.submission_validity[sortByRound] ? a.submissions[sortByRound].length : 0;
const bPoints = b.submission_validity[sortByRound] ? b.submissions[sortByRound].length : 0;
if (bPoints !== aPoints) {
return bPoints - aPoints;
}
// Tiebreaker: word alphabetically
const wordCompare = a.submissions[sortByRound].localeCompare(b.submissions[sortByRound]);
if (wordCompare !== 0) {
return wordCompare;
}
// Final tiebreaker: username
return a.user_name.localeCompare(b.user_name);
}
});
if (loading) {
return <p>Loading leaderboard...</p>;
}
if (entries.length === 0) {
return (
<div className="leaderboard">
<h1>Club: {clubName}</h1>
<p>Nobody in the club has played yet!</p>
</div>
);
}
const numRounds = entries[0]?.submissions.length || 0;
return (
<div className="leaderboard">
<h1>Club Leaderboard: {clubName}</h1>
<RoundIndicator
currentRound={sortByRound === null ? numRounds : sortByRound}
totalRounds={numRounds}
showTotal={true}
onRoundClick={(round) => setSortByRound(round === numRounds ? null : round)}
/>
<div className="leaderboard-table-container">
<table className="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th>Total Points</th>
<th>Words</th>
</tr>
</thead>
<tbody>
{sortedEntries.map((entry, index) => (
<tr key={entry.submission_id}>
<td>{index + 1}</td>
<td>{entry.user_name}</td>
<td>{entry.total_points}</td>
<td className="words-cell">
{entry.submissions.map((word, i) => (
<span
key={i}
className={entry.submission_validity[i] ? "valid" : "invalid"}
>
{word}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
- Update App.tsx to use Leaderboard component:
Add import:
import { Leaderboard } from "./Leaderboard.tsx";
Add a new conditional block after the postGame block:
if (gameState === "leaderboard") {
return (
<div className="app-wrapper">
<div className="game-container">
<Header />
<div className="content-area">
<Leaderboard clubName={clubName} />
<button onClick={handleStartGame} className="start-button">Play Again</button>
</div>
</div>
</div>
);
}
- Update RoundIndicator.tsx to support the leaderboard use case:
Add optional props:
interface RoundIndicatorProps {
currentRound: number;
totalRounds: number;
showTotal?: boolean; // Show an extra dot for "Total"
onRoundClick?: (round: number) => void; // Make dots clickable
}
Update the component logic to:
- Render
totalRounds + 1dots whenshowTotalis true (last dot represents "Total") - Make dots clickable if
onRoundClickis provided - Highlight the appropriate dot based on currentRound
Implementation notes:
- Default sort is by total_points (DESC), then user_name (ASC)
- When sorting by a specific round, tiebreakers are: word (alphabetically), then user_name
- RoundIndicator is reused but enhanced with click handlers
- The leaderboard table is scrollable for many entries
Update index.ts and App.tsx to support club-based routing.
Requirements:
- Update index.ts to handle club routes:
Add catch-all route before the 404:
// Serve index.html for club routes
app.get("/club/:clubName", () => serveFile("/frontend/index.html"));
app.get("/club/:clubName/", () => serveFile("/frontend/index.html"));
app.get("/club/:clubName/leaderboard", () => serveFile("/frontend/index.html"));
app.get("/club/:clubName/leaderboard/", () => serveFile("/frontend/index.html"));
- Update App.tsx useEffect for initial routing:
Add this useEffect near the top of the App component (after all useState declarations):
useEffect(() => {
const path = window.location.pathname;
const clubMatch = path.match(/^\/club\/([A-Z]+)(\/leaderboard)?/i);
if (clubMatch) {
const extractedClubName = clubMatch[1].toUpperCase();
setClubName(extractedClubName);
if (clubMatch[2]) {
// Path includes /leaderboard
setGameState("leaderboard");
}
}
}, []);
Implementation notes:
- Routes are case-insensitive but club names are normalized to uppercase
- Both trailing slash variants are supported
- Client-side routing updates the browser URL without page reload using
window.history.pushState - Direct navigation to
/club/MYCLUB/leaderboardshows the leaderboard immediately
These 5 prompts will:
- ✅ Create database schema for storing game results
- ✅ Add club/player name inputs to postGame UI
- ✅ Create API endpoints for submitting and fetching leaderboard data
- ✅ Build the leaderboard view component with sorting
- ✅ Implement URL-based routing for clubs
Each prompt is focused on a specific feature area and can be executed independently (though they build on each other in sequence).