Add rules page, rewrite how-to-play, and fix scoring display (#20)

- Add new /rules route with official league rules covering rosters,
  scoring, major-based QP scoring, tiebreakers, draft, and season rules
- Rewrite how-to-play page with a tutorial/marketing tone, highlighting
  the Fischer increment draft clock as a novel feature
- Add Rules link to navbar (desktop and mobile)
- Align QP values in both pages with DEFAULT_QP_VALUES in code
- Fix tiebreaker description to match placement-count logic in code
- Update all fantasy point displays from toFixed(1) to toFixed(2) across
  StandingsTable, TeamScoreBreakdown, and PointProgressionChart
- Update tests to match new two-decimal-place point display format

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-21 21:58:51 -08:00 committed by GitHub
parent 7a8ea60e77
commit e9798263b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 721 additions and 166 deletions

View file

@ -136,7 +136,7 @@ export function StandingsTable({
)}
<TableCell className="font-medium">{row.teamName}</TableCell>
<TableCell className="text-right font-semibold">
{row.totalPoints.toFixed(1)}
{row.totalPoints.toFixed(2)}
</TableCell>
{showPlacementBreakdown && (
<>

View file

@ -45,6 +45,11 @@ export function Navbar({ isAdmin }: NavbarProps) {
<Link to="/how-to-play">How To Play</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/rules">Rules</Link>
</NavigationMenuLink>
</NavigationMenuItem>
{isAdmin && (
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
@ -110,6 +115,13 @@ export function Navbar({ isAdmin }: NavbarProps) {
>
How To Play
</Link>
<Link
to="/rules"
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Rules
</Link>
{isAdmin && (
<Link
to="/admin"

View file

@ -84,7 +84,7 @@ export function PointProgressionChart({ chartData, teams }: PointProgressionChar
borderRadius: '6px',
}}
labelFormatter={(label) => formatDate(label as string)}
formatter={(value: number) => [value.toFixed(1), '']}
formatter={(value: number) => [value.toFixed(2), '']}
/>
<Legend
wrapperStyle={{ paddingTop: '20px' }}

View file

@ -64,18 +64,18 @@ export function StandingsTable({
</TableCell>
<TableCell className="text-right font-semibold">
{standing.actualPoints !== null && standing.actualPoints !== undefined
? standing.actualPoints.toFixed(1)
: standing.totalPoints.toFixed(1)}
? standing.actualPoints.toFixed(2)
: standing.totalPoints.toFixed(2)}
</TableCell>
<TableCell className="text-right">
{standing.projectedPoints !== null && standing.projectedPoints !== undefined ? (
<div className="flex flex-col">
<span className="font-semibold text-primary">
{standing.projectedPoints.toFixed(1)}
{standing.projectedPoints.toFixed(2)}
</span>
{standing.participantsRemaining > 0 && (
<span className="text-xs text-muted-foreground">
+{(standing.projectedPoints - (standing.actualPoints ?? standing.totalPoints)).toFixed(1)} EV
+{(standing.projectedPoints - (standing.actualPoints ?? standing.totalPoints)).toFixed(2)} EV
</span>
)}
</div>

View file

@ -93,14 +93,14 @@ export function TeamScoreBreakdown({
</div>
<div className="text-right">
<div className="text-4xl font-bold text-primary">
{breakdown.actualPoints.toFixed(1)}
{breakdown.actualPoints.toFixed(2)}
</div>
<div className="text-sm text-muted-foreground">
Actual Points
</div>
{breakdown.projectedPoints > breakdown.actualPoints && (
<div className="text-xl font-semibold text-electric mt-1">
{breakdown.projectedPoints.toFixed(1)}
{breakdown.projectedPoints.toFixed(2)}
<span className="text-xs text-muted-foreground ml-1">projected</span>
</div>
)}
@ -122,7 +122,7 @@ export function TeamScoreBreakdown({
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{breakdown.actualPoints.toFixed(1)}
{breakdown.actualPoints.toFixed(2)}
</div>
<p className="text-xs text-muted-foreground mt-1">
From {breakdown.completedCount} finished
@ -138,10 +138,10 @@ export function TeamScoreBreakdown({
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-primary">
{breakdown.projectedPoints.toFixed(1)}
{breakdown.projectedPoints.toFixed(2)}
</div>
<p className="text-xs text-muted-foreground mt-1">
+{(breakdown.projectedPoints - breakdown.actualPoints).toFixed(1)} expected value
+{(breakdown.projectedPoints - breakdown.actualPoints).toFixed(2)} expected value
</p>
</CardContent>
</Card>
@ -250,7 +250,7 @@ export function TeamScoreBreakdown({
</CardDescription>
</div>
<div className="text-right">
<div className="text-2xl font-bold">{sportTotal.toFixed(1)}</div>
<div className="text-2xl font-bold">{sportTotal.toFixed(2)}</div>
<div className="text-xs text-muted-foreground">points</div>
</div>
</div>
@ -291,13 +291,13 @@ export function TeamScoreBreakdown({
<TableCell className="text-right">
{pick.isComplete ? (
<span className="font-semibold">
{pick.points > 0 ? pick.points.toFixed(1) : '0.0'}
{pick.points > 0 ? pick.points.toFixed(2) : '0.0'}
</span>
) : pick.projectedPoints !== null ? (
<div className="flex flex-col items-end">
<span className="text-xs text-muted-foreground">Projected:</span>
<span className="font-semibold text-primary">
{pick.projectedPoints.toFixed(1)}
{pick.projectedPoints.toFixed(2)}
</span>
</div>
) : (

View file

@ -93,9 +93,9 @@ describe("StandingsTable", () => {
it("should display total points for each team", () => {
renderWithRouter(<StandingsTable standings={mockStandings} leagueId={mockLeagueId} seasonId={mockSeasonId} />);
expect(screen.getByText("250.5")).toBeInTheDocument();
expect(screen.getByText("245.0")).toBeInTheDocument();
expect(screen.getByText("200.0")).toBeInTheDocument();
expect(screen.getByText("250.50")).toBeInTheDocument();
expect(screen.getByText("245.00")).toBeInTheDocument();
expect(screen.getByText("200.00")).toBeInTheDocument();
});
it("should show empty state when no standings", () => {

View file

@ -152,7 +152,7 @@ describe("TeamScoreBreakdown", () => {
);
expect(screen.getByText("Test Team")).toBeInTheDocument();
expect(screen.getAllByText("150.0").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("150.00").length).toBeGreaterThanOrEqual(1);
});
it("should show team rank badge", () => {
@ -244,7 +244,7 @@ describe("TeamScoreBreakdown", () => {
// NFL total should be 100 (Team A)
// F1 total should be 50 (Driver B)
const pointCells = screen.getAllByText(/^\d+\.\d$/)
const pointCells = screen.getAllByText(/^\d+\.\d\d$/)
.map((el) => parseFloat(el.textContent!));
expect(pointCells).toContain(100.0);
@ -323,9 +323,9 @@ describe("TeamScoreBreakdown", () => {
/>
);
// Should have 100.0 and 50.0 for completed picks, and "-" for pending
// Should have 100.00 and 50.00 for completed picks, and "-" for pending
const pointValues = screen.getAllByRole("cell")
.filter((cell) => cell.textContent?.match(/^\d+\.\d$|^-$/));
.filter((cell) => cell.textContent?.match(/^\d+\.\d\d$|^-$/));
expect(pointValues.length).toBeGreaterThan(0);
});
@ -432,7 +432,7 @@ describe("TeamScoreBreakdown", () => {
// Should still show team name and points
expect(screen.getByText("Test Team")).toBeInTheDocument();
expect(screen.getAllByText("150.0").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("150.00").length).toBeGreaterThanOrEqual(1);
// But not show rank badge
expect(screen.queryByText(/Rank #/)).not.toBeInTheDocument();

View file

@ -43,6 +43,7 @@ export default [
route("api/autodraft/update", "routes/api/autodraft.update.ts"),
route("user-profile", "routes/user-profile.tsx"),
route("how-to-play", "routes/how-to-play.tsx"),
route("rules", "routes/rules.tsx"),
route("test-socket", "routes/test-socket.tsx"),
// Admin routes

View file

@ -1,3 +1,5 @@
import { Link } from "react-router";
export function meta() {
return [
{ title: "How To Play - Brackt" },
@ -12,185 +14,344 @@ export function meta() {
export default function HowToPlay() {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<h1 className="text-4xl font-bold mb-8">How To Play</h1>
<h1 className="text-4xl font-bold mb-2">How To Play</h1>
<p className="text-lg text-muted-foreground mb-10">
Brackt is a multi-sport fantasy league where you draft athletes and
teams across dozens of sports, then watch your roster compete all year
long. Here's everything you need to know to get started.
</p>
<div className="space-y-8">
<div className="space-y-10">
{/* The Big Picture */}
<section>
<h2 className="text-2xl font-semibold mb-4">🎯 The Goal</h2>
<p className="text-lg text-muted-foreground">
Draft teams and athletes across multiple sports and earn points
based on how well they perform. The player with the most points at
the end of the season wins!
<h2 className="text-2xl font-semibold mb-3">The Big Picture</h2>
<p className="text-muted-foreground">
Before the season starts, every participant in your league holds a
draft picking real athletes and teams across a set of sports
chosen by your commissioner. Over the following months, those picks
compete in their actual seasons. When it's all said and done,
whoever earned the most points from their picks wins.
</p>
</section>
{/* Building Your Roster */}
<section>
<h2 className="text-2xl font-semibold mb-4">
📋 Building Your Roster
</h2>
<p className="text-lg text-muted-foreground mb-4">
Your roster has spots for different sports. You'll draft teams or
individual athletes depending on the sport:
<h2 className="text-2xl font-semibold mb-3">Building Your Roster</h2>
<p className="text-muted-foreground mb-4">
Your league includes a set of sports selected by the commissioner
think NFL, NBA, golf, Formula 1, tennis, and more. Your roster has
one dedicated spot for each sport, so you need to draft at least
one pick from every sport in the league.
</p>
<ul className="list-disc list-inside space-y-2 text-muted-foreground ml-4">
<li>Each sport has at least one required spot on your roster</li>
<li>
You'll also get flex spots that can be filled with any sport you
choose
</li>
<li>
Pick wisely - you're stuck with your roster for the whole season!
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">🏆 How Scoring Works</h2>
<p className="text-lg text-muted-foreground mb-4">
Points are awarded based on final standings. The better your pick
finishes, the more points you earn:
<p className="text-muted-foreground mb-4">
Extra draft rounds beyond the number of sports create{" "}
<span className="font-semibold text-foreground">flex spots</span>.
Flex picks can come from any sport great for grabbing a second
player in a sport where you see value, or doubling down on a safe
bet.
</p>
<div className="bg-muted p-6 rounded-lg">
<div className="grid grid-cols-2 gap-x-8 gap-y-3">
<div className="flex justify-between items-center">
<span className="font-semibold">🥇 1st Place</span>
<span className="text-xl font-bold">100 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">🥈 2nd Place</span>
<span className="text-xl font-bold">70 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">🥉 3rd Place</span>
<span className="text-xl font-bold">50 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">4th Place</span>
<span className="text-xl font-bold">40 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">5th Place</span>
<span className="text-xl font-bold">25 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">6th Place</span>
<span className="text-xl font-bold">25 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">7th Place</span>
<span className="text-xl font-bold">15 pts</span>
</div>
<div className="flex justify-between items-center">
<span className="font-semibold">8th Place</span>
<span className="text-xl font-bold">15 pts</span>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground mt-4 italic">
Note: If nobody drafted a team that finishes in the top 8, those
points aren't awarded to anyone.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Special Scoring for Golf, Tennis & Counter-Strike
</h2>
<p className="text-lg text-muted-foreground mb-4">
Golf, tennis, and Counter-Strike work a bit differently since they
each have multiple major tournaments throughout the year:
</p>
<ul className="list-disc list-inside space-y-2 text-muted-foreground ml-4">
<li>
Players/teams earn qualifying points at each major tournament
throughout the year
</li>
<li>
After all majors are complete, we add up everyone's qualifying
points
</li>
<li>
The top 8 by total qualifying points then earn the regular scoring
points (100, 70, 50, 40, 25, 25, 15, 15)
</li>
<li>This rewards consistency across all the major tournaments!</li>
</ul>
<div className="bg-muted p-4 rounded-lg mt-4">
<p className="font-semibold mb-2">Qualifying points per major:</p>
<div className="grid grid-cols-2 gap-1 text-sm text-muted-foreground">
<span>1st Place</span><span className="font-medium">8 QP</span>
<span>2nd Place</span><span className="font-medium">5 QP</span>
<span>3rd Place</span><span className="font-medium">3 QP</span>
<span>4th Place</span><span className="font-medium">2 QP</span>
<span>5th Place</span><span className="font-medium">1 QP</span>
</div>
<div className="bg-muted rounded-lg p-4 text-sm text-muted-foreground">
<p>
<span className="font-semibold text-foreground">Example:</span>{" "}
Your league has 20 sports and 24 draft rounds. That means you
fill 20 dedicated sport spots and have 4 flex picks to use
however you like.
</p>
</div>
</section>
{/* How Points Work */}
<section>
<h2 className="text-2xl font-semibold mb-4">🎲 The Draft</h2>
<p className="text-lg text-muted-foreground mb-4">
Before the season starts, you'll participate in a snake draft:
<h2 className="text-2xl font-semibold mb-3">How Points Work</h2>
<p className="text-muted-foreground mb-4">
Points are awarded based on where your picks finish 1st through
8th. The better they finish, the more you earn. Your commissioner
can adjust point values before the draft, but here's what a typical
league looks like:
</p>
<div className="bg-muted rounded-lg overflow-hidden mb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">
Finish
</th>
<th className="text-right p-3 font-semibold text-foreground">
Points
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">1st Place</td>
<td className="p-3 text-right font-semibold text-foreground">
100
</td>
</tr>
<tr>
<td className="p-3">2nd Place</td>
<td className="p-3 text-right font-semibold text-foreground">
70
</td>
</tr>
<tr>
<td className="p-3">3rd Place</td>
<td className="p-3 text-right font-semibold text-foreground">
50
</td>
</tr>
<tr>
<td className="p-3">4th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
40
</td>
</tr>
<tr>
<td className="p-3">5th6th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
25
</td>
</tr>
<tr>
<td className="p-3">7th8th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
15
</td>
</tr>
</tbody>
</table>
</div>
<p className="text-sm text-muted-foreground">
If nobody in your league drafted a team that finishes in the top 8,
those points aren't awarded to anyone. Drafting rare picks can pay
off.
</p>
<ul className="list-disc list-inside space-y-2 text-muted-foreground ml-4">
<li>
<strong>Snake draft</strong> means the order reverses each round
(if you pick last in round 1, you pick first in round 2)
</li>
<li>You'll have a time limit to make each pick</li>
<li>
Once you draft a team or athlete, they're yours for the entire
season
</li>
<li>No trading picks or players - choose carefully!</li>
</ul>
</section>
{/* Major-Based Sports */}
<section>
<h2 className="text-2xl font-semibold mb-4">📅 During the Season</h2>
<p className="text-lg text-muted-foreground mb-4">
Once the season starts, it's all about watching your picks compete:
<h2 className="text-2xl font-semibold mb-3">
Golf, Tennis, CS2 Sports with Majors
</h2>
<p className="text-muted-foreground mb-4">
Some sports don't have a single championship they have multiple
major tournaments spread throughout the year. For these sports,
players earn{" "}
<span className="font-semibold text-foreground">
qualifying points (QP)
</span>{" "}
at each major:
</p>
<div className="bg-muted rounded-lg overflow-hidden mb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">
Major Finish
</th>
<th className="text-right p-3 font-semibold text-foreground">
Qualifying Points
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">1st</td>
<td className="p-3 text-right font-semibold text-foreground">
20 QP
</td>
</tr>
<tr>
<td className="p-3">2nd</td>
<td className="p-3 text-right font-semibold text-foreground">
14 QP
</td>
</tr>
<tr>
<td className="p-3">3rd</td>
<td className="p-3 text-right font-semibold text-foreground">
10 QP
</td>
</tr>
<tr>
<td className="p-3">4th</td>
<td className="p-3 text-right font-semibold text-foreground">
8 QP
</td>
</tr>
<tr>
<td className="p-3">5th6th</td>
<td className="p-3 text-right font-semibold text-foreground">
5 QP
</td>
</tr>
<tr>
<td className="p-3">7th8th</td>
<td className="p-3 text-right font-semibold text-foreground">
3 QP
</td>
</tr>
<tr>
<td className="p-3">9th12th</td>
<td className="p-3 text-right font-semibold text-foreground">
2 QP
</td>
</tr>
<tr>
<td className="p-3">13th16th</td>
<td className="p-3 text-right font-semibold text-foreground">
1 QP
</td>
</tr>
</tbody>
</table>
</div>
<p className="text-muted-foreground">
QP don't go straight to your score they determine the final
ranking within that sport. Once all the majors are done, total QP
are added up, and whoever's ranked 1st through 8th earns the
standard league points. Consistency across majors is the key.
</p>
<ul className="list-disc list-inside space-y-2 text-muted-foreground ml-4">
<li>No trades - your roster is locked in</li>
<li>No waivers or free agent pickups</li>
<li>Just sit back and cheer for your teams and athletes!</li>
</ul>
</section>
{/* The Draft */}
<section>
<h2 className="text-2xl font-semibold mb-3">The Draft</h2>
<p className="text-muted-foreground mb-6">
The draft is a snake draft pick order reverses every round. If
you pick last in round 1, you pick first in round 2. No trading
picks, and once you make a selection it's yours for the whole
season.
</p>
{/* Chess Clock */}
<div className="border rounded-lg p-5 mb-6 space-y-3">
<h3 className="text-lg font-semibold">
The Chess Clock A Brackt Original
</h3>
<p className="text-muted-foreground">
Most fantasy drafts use a simple countdown: you get X seconds per
pick and that's it. Brackt does it differently with a{" "}
<span className="font-semibold text-foreground">
Fischer increment timer
</span>
, borrowed from competitive chess.
</p>
<p className="text-muted-foreground">
Every participant starts the draft with a personal time bank.
Your clock counts down while it's your turn. When you make a
pick,{" "}
<span className="font-semibold text-foreground">
time is added back
</span>{" "}
to your bank. Any time you don't use carries over to your next
pick.
</p>
<p className="text-muted-foreground">
Pick quickly early on and you'll build up a bigger bank for the
later rounds when decisions get harder. Deliberate too long and
your bank shrinks. It rewards decisiveness without penalizing you
for taking a moment when you really need it.
</p>
<div className="bg-muted rounded-md p-3 text-sm text-muted-foreground">
<span className="font-semibold text-foreground">Example:</span>{" "}
On Standard speed, you start with 2 minutes. You make your first
pick in 30 seconds 15 seconds are added back, leaving you with
1 minute 45 seconds for your next turn. Keep picking fast and
that bank grows.
</div>
</div>
{/* Draft Queue & Autodraft */}
<div className="space-y-3">
<h3 className="text-lg font-semibold">Draft Queue & Autodraft</h3>
<p className="text-muted-foreground">
Before and during the draft you can build a{" "}
<span className="font-semibold text-foreground">draft queue</span>{" "}
an ordered list of picks you want to make. When it's your turn,
you can pick manually or let your queue guide you.
</p>
<p className="text-muted-foreground">
If your time bank runs out,{" "}
<span className="font-semibold text-foreground">autodraft</span>{" "}
kicks in automatically. It'll grab the first available pick from
your queue. If your queue is empty or everything in it has
already been taken, it'll grab from the top of the available player pool.
Keep your queue updated and you'll never get stuck with a random
pick.
</p>
</div>
</section>
{/* During the Season */}
<section>
<h2 className="text-2xl font-semibold mb-3">After the Draft</h2>
<p className="text-muted-foreground mb-4">
Once the draft ends, your roster is locked. No trades, no waivers,
no pickups. Just watch your picks compete across the year and see
how your draft decisions play out. Points accumulate as sports
seasons wrap up, and standings update in real time.
</p>
<p className="text-muted-foreground">
If there's a tie in the final standings, it's broken by who has
more 1st-place finishes. If that's still a tie, it goes to
2nd-place finishes, then 3rd, and so on.
</p>
</section>
{/* Tips */}
<section className="bg-primary/5 p-6 rounded-lg border border-primary/20">
<h2 className="text-2xl font-semibold mb-4">💡 Quick Tips</h2>
<ul className="space-y-2 text-muted-foreground">
<h2 className="text-xl font-semibold mb-4">Quick Tips</h2>
<ul className="space-y-3 text-muted-foreground">
<li>
<strong>Diversify:</strong> Don't put all your eggs in one
basket - spread your picks across different sports
<span className="font-semibold text-foreground">
Know your sports.
</span>{" "}
Understanding how each sport determines its top 8 (playoffs vs.
regular season vs. bracket) is the biggest edge you can have.
</li>
<li>
<strong>Know the format:</strong> Some sports score based on
playoffs, others on regular season performance
<span className="font-semibold text-foreground">
Build your queue early.
</span>{" "}
The draft moves fast. Having a ranked queue ready means you'll
always get a pick you're happy with, even if you step away.
</li>
<li>
<strong>Draft early:</strong> The best teams and athletes go
fast!
<span className="font-semibold text-foreground">
Pick fast early on.
</span>{" "}
The chess clock rewards quick decisions. Bank extra time for the
tricky later rounds.
</li>
<li>
<strong>Have fun:</strong> This is about enjoying sports across
the calendar year
<span className="font-semibold text-foreground">
Flex spots are strategic.
</span>{" "}
Use them to double up on sports you feel confident about, not
just to fill the roster.
</li>
</ul>
</section>
<section className="text-center pt-8">
<p className="text-xl text-muted-foreground mb-6">
Ready to compete?
{/* Links */}
<section className="flex flex-col sm:flex-row gap-4 items-center justify-between pt-2">
<p className="text-muted-foreground">
Want the full breakdown? Read the{" "}
<Link to="/rules" className="underline underline-offset-4 hover:text-foreground transition-colors">
official rules
</Link>
.
</p>
<a
href="/leagues/new"
className="inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-11 px-8 text-base"
<Link
to="/leagues/new"
className="inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-11 px-8 text-base whitespace-nowrap"
>
Create a League
</a>
</Link>
</section>
</div>
</div>
);

381
app/routes/rules.tsx Normal file
View file

@ -0,0 +1,381 @@
export function meta() {
return [
{ title: "Rules - Brackt" },
{
name: "description",
content: "Official rules for Brackt multi-sport fantasy leagues",
},
];
}
export default function Rules() {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<h1 className="text-4xl font-bold mb-2">Official Rules</h1>
<p className="text-muted-foreground mb-10">
These rules govern all Brackt leagues. Commissioners may adjust scoring
values and draft settings within the options available in league
settings, but the core structure below applies to every league.
</p>
<div className="space-y-10">
{/* Object */}
<section>
<h2 className="text-2xl font-semibold mb-1">Object</h2>
<div className="border-b mb-4" />
<p className="text-muted-foreground">
The goal is to draft athletes and teams across multiple sports, then
earn points based on how they finish at the end of their respective
seasons. The participant with the most points at the end of the
league season wins.
</p>
</section>
{/* Rosters */}
<section>
<h2 className="text-2xl font-semibold mb-1">Rosters</h2>
<div className="border-b mb-4" />
<div className="space-y-3 text-muted-foreground">
<p>
The commissioner sets which sports are included in the league and
how many draft rounds are held. The number of draft rounds must be
at least equal to the number of sports in the league.
</p>
<p>
Every participant must draft at least one team or individual from
each sport included in the league.
</p>
<p>
Any draft rounds beyond the number of sports are considered{" "}
<span className="font-semibold text-foreground">flex rounds</span>
. Picks made in flex rounds can be from any sport, including
sports that already have a dedicated roster spot filled.
</p>
</div>
</section>
{/* Scoring */}
<section>
<h2 className="text-2xl font-semibold mb-1">Scoring</h2>
<div className="border-b mb-4" />
<div className="space-y-6 text-muted-foreground">
<div>
<p className="mb-3">
Points are awarded based on where a drafted team or individual
finishes in their sport. The default point values are listed
below. Commissioners may adjust these values before the draft
begins.
</p>
<div className="bg-muted rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">
Finish
</th>
<th className="text-right p-3 font-semibold text-foreground">
Points
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">1st Place</td>
<td className="p-3 text-right font-semibold text-foreground">
100
</td>
</tr>
<tr>
<td className="p-3">2nd Place</td>
<td className="p-3 text-right font-semibold text-foreground">
70
</td>
</tr>
<tr>
<td className="p-3">3rd Place</td>
<td className="p-3 text-right font-semibold text-foreground">
50
</td>
</tr>
<tr>
<td className="p-3">4th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
40
</td>
</tr>
<tr>
<td className="p-3">5th6th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
25
</td>
</tr>
<tr>
<td className="p-3">7th8th Place</td>
<td className="p-3 text-right font-semibold text-foreground">
15
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p>
If a team or individual finishes in a point-scoring position but
was not drafted by anyone, those points are not awarded to anyone.
A participant who drafted the next-highest finisher still earns
zero points for that position.
</p>
<div>
<p className="mb-3">
When two or more teams or individuals tie for a point-scoring
position within a sport, the points for all tied positions are
combined and split equally among them, rounded to the nearest
whole point.
</p>
<div className="bg-muted rounded-lg p-4 text-sm space-y-2">
<p className="font-semibold text-foreground">Examples:</p>
<p>
Tie between 2nd and 3rd: (70 + 50) ÷ 2 ={" "}
<span className="font-semibold text-foreground">
60 points each
</span>
</p>
<p>
Three-way tie for 6th, 7th, and 8th: (25 + 15 + 15) ÷ 3 ={" "}
<span className="font-semibold text-foreground">
18 points each
</span>
</p>
</div>
</div>
</div>
</section>
{/* Major-Based Sports Scoring */}
<section>
<h2 className="text-2xl font-semibold mb-1">
Major-Based Sports Scoring
</h2>
<div className="border-b mb-4" />
<div className="space-y-4 text-muted-foreground">
<p>
Some sports such as golf, tennis, and Counter-Strike are
scored across multiple major tournaments throughout the year
rather than a single season-ending event. These sports use a
qualifying point (QP) system.
</p>
<p>
At each major tournament, participants earn qualifying points
based on their finish:
</p>
<div className="bg-muted rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">
Finish
</th>
<th className="text-right p-3 font-semibold text-foreground">
Qualifying Points
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">1st</td>
<td className="p-3 text-right font-semibold text-foreground">
20 QP
</td>
</tr>
<tr>
<td className="p-3">2nd</td>
<td className="p-3 text-right font-semibold text-foreground">
14 QP
</td>
</tr>
<tr>
<td className="p-3">3rd</td>
<td className="p-3 text-right font-semibold text-foreground">
10 QP
</td>
</tr>
<tr>
<td className="p-3">4th</td>
<td className="p-3 text-right font-semibold text-foreground">
8 QP
</td>
</tr>
<tr>
<td className="p-3">5th6th</td>
<td className="p-3 text-right font-semibold text-foreground">
5 QP
</td>
</tr>
<tr>
<td className="p-3">7th8th</td>
<td className="p-3 text-right font-semibold text-foreground">
3 QP
</td>
</tr>
<tr>
<td className="p-3">9th12th</td>
<td className="p-3 text-right font-semibold text-foreground">
2 QP
</td>
</tr>
<tr>
<td className="p-3">13th16th</td>
<td className="p-3 text-right font-semibold text-foreground">
1 QP
</td>
</tr>
</tbody>
</table>
</div>
<p>
Qualifying points are not added to a participant's total league
score directly. They are used only to determine final rankings
within that sport.
</p>
<p>
Once the final major tournament is complete, qualifying points
from all majors are totaled. Those cumulative totals determine
the sport's final rankings, and the standard point values from
the Scoring section are then awarded accordingly.
</p>
<p>
Ties in qualifying points are split equally and rounded to the
nearest hundredth of a qualifying point before final rankings are
determined. The same tie-splitting logic from the Scoring section
then applies when awarding league points.
</p>
</div>
</section>
{/* Season Standings Tiebreakers */}
<section>
<h2 className="text-2xl font-semibold mb-1">
Season Standings Tiebreakers
</h2>
<div className="border-b mb-4" />
<div className="space-y-3 text-muted-foreground">
<p>
If two or more participants finish the season with the same total
score, the following tiebreakers are applied in order:
</p>
<ol className="list-decimal list-inside space-y-2 ml-4">
<li>Most 1st-place finishes across all sports</li>
<li>Most 2nd-place finishes</li>
<li>Most 3rd-place finishes</li>
<li>
This pattern continues down through each place until the tie is
broken
</li>
</ol>
</div>
</section>
{/* The Draft */}
<section>
<h2 className="text-2xl font-semibold mb-1">The Draft</h2>
<div className="border-b mb-4" />
<div className="space-y-3 text-muted-foreground">
<p>
The draft is a snake draft. Draft order reverses at the start of
each new round if you pick last in round 1, you pick first in
round 2, and so on.
</p>
<p>
Brackt uses a{" "}
<span className="font-semibold text-foreground">
Fischer increment timer
</span>{" "}
for all drafts. Each participant has a personal time bank. Your
clock counts down while it's your turn, and a fixed amount of
time is added back to your bank each time you make a pick. Time
not used on one pick carries over to future picks.
</p>
<p>
The commissioner selects the draft speed, which determines both
the starting bank and the per-pick increment:
</p>
<div className="bg-muted rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-semibold text-foreground">
Speed
</th>
<th className="text-right p-3 font-semibold text-foreground">
Starting Bank
</th>
<th className="text-right p-3 font-semibold text-foreground">
Per-Pick Increment
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="p-3">Fast</td>
<td className="p-3 text-right">1 minute</td>
<td className="p-3 text-right">+10 seconds</td>
</tr>
<tr>
<td className="p-3">Standard</td>
<td className="p-3 text-right">2 minutes</td>
<td className="p-3 text-right">+15 seconds</td>
</tr>
<tr>
<td className="p-3">Slow</td>
<td className="p-3 text-right">8 hours</td>
<td className="p-3 text-right">+1 hour</td>
</tr>
<tr>
<td className="p-3">Very Slow</td>
<td className="p-3 text-right">12 hours</td>
<td className="p-3 text-right">+1 hour</td>
</tr>
</tbody>
</table>
</div>
<p>
If a participant's time bank runs out, the system will
automatically draft on their behalf. Autodraft picks from the
participant's pre-set draft queue first. If the queue is empty or
all queued picks have already been taken, the system selects from
the top of the available player pool.
</p>
<p>Pick trading is not allowed.</p>
</div>
</section>
{/* During the Season */}
<section>
<h2 className="text-2xl font-semibold mb-1">During the Season</h2>
<div className="border-b mb-4" />
<div className="space-y-3 text-muted-foreground">
<p>
Once the draft is complete, rosters are locked for the entire
season. There is no trading of picks or athletes between
participants.
</p>
<p>
There are no waivers or free-agent pickups. Every participant
competes with the roster they drafted.
</p>
</div>
</section>
</div>
</div>
);
}