18 KiB
Brackt as a Draftable Sport — Design Reference
What It Is
"Brackt" is an optional meta-sport commissioners can add to any league. Each team drafts exactly one manager from their own league. At season end, that manager's final overall fantasy ranking (1st–8th across all other sports) determines how many points their drafter earns. Resolution is commissioner-triggered once all other sports finalize. Self-drafting is allowed and valid strategy.
Schema
sportsSeasons — new column
fantasySeasonId: uuid("fantasy_season_id").references(() => seasons.id)
When set, this sports season was auto-generated for a specific fantasy season. It is league-private — never shared across leagues, never shown in the sports picker or the league creation wizard.
simulatorTypeEnum
Add "brackt" to the existing enum.
auditActionEnum
Add "brackt_resolved" to the existing enum.
Migration: npm run db:generate && npm run db:migrate. The new column defaults to null — backward-compatible.
Admin One-Time Setup
An admin creates a single global Brackt sport and a template sports season via the existing admin UI. These are created once and reused across all leagues.
Sport (/admin/sports/new):
- Name:
Brackt, Slug:brackt, SimulatorType:brackt
Template sports season (/admin/sports-seasons/new):
- Sport: Brackt, Year: 2026, Status:
upcoming draftOn:1970-01-01,draftOff:2099-12-31— never expiresfantasySeasonId: null — this is the template, not a league copy- No participants — the template is just a selectable placeholder
The template appears in the league creation wizard's sport picker. Per-league copies (see below) are excluded from it via isNull(fantasySeasonId).
Data Architecture
Per-League Brackt Season
When a league includes Brackt, createBracktSportsSeasonForLeague() runs during league creation:
- Inserts a new
sportsSeasonsrow withfantasySeasonId = season.id - Replaces the template's
seasonSportslink with this new per-league copy - Bulk-inserts
NseasonParticipantsrows — one per team:name: team nameexternalId: team ID ← critical: used for robust team→participant matching at resolution; immune to name changesexpectedValue:"0",vorpValue:"0"(will be updated by Harville immediately after)
- Calls
runBracktHarvilleForFantasySeason()immediately so initial EVs are uniform (~27.5 for 8 teams) rather than 0
Use db.insert() directly — do not use createParticipant(), which auto-creates canonical participant rows. Brackt participants are manager-participants with no canonical counterpart.
seasonParticipants.vorpValue is the source of truth for autopick
getTopAvailableParticipant() sorts all undrafted participants across all eligible sports by vorpValue DESC. Brackt participants must have correct, up-to-date vorpValue so they rank appropriately relative to other sport participants. This is achieved by running the Harville simulation after every pick.
EV Pipeline — Critical Architecture Rule
All EV calculations must use a single
dbinstance threaded through the entire call chain. Never calldatabase()inside model functions that are reachable from the timer context.
Why
database() uses AsyncLocalStorage. It is scoped to HTTP request handlers. The draft timer (server/timer.ts) fires outside any HTTP request — calling database() there throws "DatabaseContext not set". This breaks the EV pipeline in the timer-triggered autopick path.
The Rule
Every function in the EV pipeline must accept a db parameter and pass it through:
runBracktHarvilleForFantasySeason(seasonId, db)
→ BracktSimulator.simulate(bracktSportsSeasonId, db)
→ batchUpsertParticipantEVs(inputs, db)
→ syncVorpForSeason(sportsSeasonId, db)
No function in this chain may call database() directly. They receive db from the caller.
The timer passes db from server/db.ts (the global singleton). HTTP request handlers pass db from database(). Both are valid Drizzle instances connected to the same Postgres database.
Where to Run the Harville
Run runBracktHarvilleForFantasySeason(seasonId, db) in three places:
executeAutoPick— awaited, before participant selection. This ensures autopick sees current VORPs.make-pick.ts(manual pick handler) — fire-and-forget after the pick commits. Updates EVs for all clients after a human picks.createBracktSportsSeasonForLeague— awaited at league creation. Seeds uniform initial EVs.
After running Harville, emit the updated EVs to all draft room clients via socket:
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", {
evs: result.evs, // { participantId, expectedValue }[]
});
BracktSimulator Algorithm
File: app/services/simulations/brackt-simulator.ts
simulate(bracktSportsSeasonId, db):
1. Look up bracktSportsSeason → get fantasySeasonId
2. Get all seasonSports for this fantasy season; separate brackt vs non-brackt
3. Get all bracktParticipants (externalId = teamId)
4. Get all non-brackt draftPicks for this fantasy season with their participant.expectedValue
5. For each non-brackt sport, compute averageAvailableEV:
- mean expectedValue of undrafted participants
- fallback 0 if no EVs set
6. For each team:
projectedTotal = sum(EV of drafted non-brackt picks)
+ sum(averageAvailableEV for each non-brackt sport not yet picked)
7. weights = max(0, projectedTotal) for each team
8. placementMatrix = computeHarville(weights, 8)
9. Map back to bracktParticipants → SimulationResult[]
Edge case: All weights = 0 (pre-draft) → Harville produces uniform 1/N per placement. This is correct — uniform EVs → uniform VORPs → Brackt picks are not disproportionately valued early in the draft.
Registration: Add "brackt" to SIMULATOR_TYPES in app/services/simulations/registry.ts and map it to BracktSimulator.
runBracktHarvilleForFantasySeason
File: app/lib/brackt-ev-updater.server.ts
export type BracktEvUpdate = {
participantId: string;
expectedValue: number;
vorpValue: number;
};
export async function runBracktHarvilleForFantasySeason(
fantasySeasonId: string,
db: DbInstance // required — never optional
): Promise<{ updated: false } | { updated: true; evs: BracktEvUpdate[] }>
Steps:
- Find the Brackt sports season via
sportsSeasons WHERE fantasySeasonId = ? AND sport.slug = 'brackt' - Call
simulator.simulate(bracktSportsSeasonId, db) - If results empty, return
{ updated: false } - Call
batchUpsertParticipantEVs(inputs, db)— writes toseasonParticipantExpectedValuesand triggerssyncVorpForSeason(db)which writes toseasonParticipants.vorpValue - Re-read the updated
vorpValuefor all Brackt participants from DB (one query onseasonParticipantsfiltered bysportsSeasonId) - Compute
evs[]:expectedValuefrom probabilities × scoring tiers;vorpValuefrom the DB read in step 5 - Return
{ updated: true, evs }
Reading vorpValue from DB after syncVorpForSeason ensures the value is exactly what the server stored — no approximation, no client-side re-derivation.
Client-Side Display ($leagueId.draft.$seasonId.tsx)
The client never computes VORP. The server is the sole source of truth — syncVorpForSeason writes vorpValue to seasonParticipants after every Harville run, and the socket pushes those values to all clients immediately.
Socket Payload
brackt-evs-updated must include both expectedValue and vorpValue so clients can sort correctly in real time without waiting for a revalidation:
// Server emits (after runBracktHarvilleForFantasySeason):
"brackt-evs-updated": (data: {
evs: { participantId: string; expectedValue: number; vorpValue: number }[]
}) => void;
runBracktHarvilleForFantasySeason should read the updated vorpValue back from the DB after batchUpsertParticipantEVs completes (since syncVorpForSeason writes it) and include it in the returned evs.
Initial Load
Seed serverBracktVorps from loader data:
const [serverBracktVorps, setServerBracktVorps] = useState<Map<string, number>>(
() => new Map(
availableParticipants
.filter((p) => p.sportsSeasonId === bracktSportsSeasonId)
.map((p) => [p.id, parseFloat(p.vorpValue ?? "0")])
)
);
Socket Updates
Listen for brackt-evs-updated and patch the VORP map:
useEffect(() => {
const handler = (data: { evs: { participantId: string; vorpValue: number }[] }) => {
setServerBracktVorps((prev) => {
const next = new Map(prev);
for (const { participantId, vorpValue } of data.evs) {
next.set(participantId, vorpValue);
}
return next;
});
};
on("brackt-evs-updated", handler);
return () => off("brackt-evs-updated", handler);
}, [on, off]);
Sorting
All participants sort by vorpValue — server-computed for every sport including Brackt:
const sortedAvailableParticipants = useMemo(() => {
const effectiveVorp = (p) => {
if (p.sportsSeasonId === bracktSportsSeasonId) {
return serverBracktVorps.get(p.id) ?? parseFloat(p.vorpValue ?? "0");
}
return parseFloat(p.vorpValue ?? "0");
};
return availableParticipants.toSorted((a, b) => {
const diff = effectiveVorp(b) - effectiveVorp(a);
return diff !== 0 ? diff : a.name.localeCompare(b.name);
});
}, [availableParticipants, bracktSportsSeasonId, serverBracktVorps]);
serverBracktVorps overrides stale loader values whenever the server pushes an update. When no socket update has arrived yet, the loader's vorpValue is used as the fallback — both reflect the same server-computed value.
League Creation Flow (app/routes/leagues/new.tsx)
Sports Selection Step
Brackt appears in the picker like any other sport. When selected, show a callout:
"Each team drafts one manager from your league. At season end, their total fantasy points (across all other sports) earns you placement points — 1st through 8th. You can draft yourself!"
Action
After createSeason() + createManyTeams():
const bracktTemplateId = await findBracktTemplateIdInSelection(selectedSportsSeasonIds, bracktSport.id);
if (bracktTemplateId) {
// Remove template from the regular sport linking; brackt-setup handles it
const otherSportsSeasonIds = selectedSportsSeasonIds.filter(id => id !== bracktTemplateId);
await linkMultipleSportsToSeason(season.id, otherSportsSeasonIds);
await createBracktSportsSeasonForLeague(bracktTemplateId, season.id, leagueName, year, createdTeams);
} else {
await linkMultipleSportsToSeason(season.id, selectedSportsSeasonIds);
}
findDraftableSportsSeasons() filter
In app/models/sports-season.ts, add isNull(ss.fantasySeasonId) to exclude per-league Brackt copies from appearing in the picker.
League Settings — Team Changes ($leagueId.settings.tsx)
Whenever teams are added or removed, call:
await syncBracktParticipants(season.id);
syncBracktParticipants guards against deleting participants that have already been drafted. If a team is removed after the draft has started, only undrafted Brackt participants are deleted; drafted ones remain.
Draft Room Considerations
- Self-drafting: Explicitly allowed. No restriction needed.
- Eligibility:
calculateDraftEligibility()already enforces one pick per sport per team. Brackt is treated as any other sport — one required slot. No changes needed. - Display: Brackt participants show with the Brackt sport icon and manager name. The existing participant card handles this without changes.
- Draft queue: Auto-pick respects the queue. If a team queues a Brackt participant, it will be picked from the queue like any other queued player. The VORP-based fallback applies only when the queue is empty.
Autopick Ordering
getTopAvailableParticipant() sorts all undrafted participants from all eligible sports by vorpValue DESC. For this to work correctly with Brackt:
vorpValueon BracktseasonParticipantsmust be populated and current at pick timerunBracktHarvilleForFantasySeason()must be awaited insideexecuteAutoPick()before callingautoPickForTeam()- The
dbinstance passed torunBracktHarvilleForFantasySeason()must be the same instance used by all downstream model functions (batchUpsertParticipantEVs,syncVorpForSeason)
In the timer path, db is server/db.ts's singleton. In the HTTP path, db is database(). Both are fine as long as they're threaded through — not bypassed with a fresh database() call inside model functions.
Season Resolution
Readiness Check — isBracktResolvable(seasonId)
Returns { ready: boolean; pendingSports: string[] }.
A sport is "pending" if:
- Its
sportsSeasons.status !== "completed", OR - Any of its participants has
seasonParticipantResults.isPartialScore = true
Commissioner UI — BracktResolutionCard
Show on the standings page when:
isBracktIncluded(seasonId)is true- Brackt
sportsSeasons.status !== "completed"
Shows current projected rankings (from last Harville run). If ready → "Finalize Brackt" button. If not → "Waiting for: [NBA Playoffs, ...]".
Resolution — resolveBrackt(seasonId, actorUserId)
- Read
teamStandings.totalPointsfor all teams (this is purely non-Brackt points since Brackt isn't scored yet) - Sort by
totalPoints DESC; tiebreak: 1st-place counts → 2nd → etc. - For each Brackt participant (matched via
externalId = teamId): upsertseasonParticipantResultswithfinalPosition = rank, isPartialScore = false - Mark Brackt
sportsSeasons.status = "completed" - Call
recalculateAffectedLeagues(bracktSportsSeasonId)— applies Brackt placement points to team totals - Log to
commissionerAuditLogwith action"brackt_resolved"
No circular dependency: resolution reads totalPoints before Brackt is scored, so Brackt points don't feed into their own resolution.
Admin Sports-Seasons Listing
Add a filter toggle to hide rows where fantasySeasonId IS NOT NULL. Per-league Brackt seasons are internal and should not clutter the admin listing by default.
Rules & How to Play
Both files are hardcoded React components.
app/routes/rules.tsx — New "Brackt — Draft a Manager" section:
- Draft exactly one manager from your league (self-drafting allowed)
- Points based on final overall ranking across all other sports (1st = 100 pts … 8th = 15 pts)
- Commissioner triggers Brackt resolution after all other sports conclude
- One required Brackt round is added to every league that includes it
app/routes/how-to-play.tsx — New "The Meta Game: Brackt" section:
- Strategic angle: pick a manager who will perform well overall
- Draft timing matters — strong early drafters show higher projected EV
- Revealed at season end after all other sports finalize
- You are a valid pick
Gotchas
| Risk | Mitigation |
|---|---|
database() unavailable in timer context |
Pass db through the entire EV pipeline; never call database() inside model functions |
createParticipant() auto-creates canonical participant rows |
Use db.insert(schema.seasonParticipants) directly with participantId: null |
| Per-league Brackt seasons leak into sports picker | isNull(fantasySeasonId) filter in findDraftableSportsSeasons() |
| Admin listings cluttered with per-league Brackt seasons | Filter toggle in /admin/sports-seasons |
BracktSimulator reads stale EVs (all-zero pre-draft) |
Harville produces uniform distribution from zero weights — valid behavior |
| Brackt VORP starts at 0 before any Harville run | Call runBracktHarvilleForFantasySeason() immediately after createBracktSportsSeasonForLeague() |
syncVorpForSeason uses replacement level for 12-14 players; Brackt has 8 |
calculateReplacementLevel clamps to Math.min(idx, length - 1), so it uses position 8 (last) for all three. VORP = EV − minEV. This is correct. |
Resolution uses totalPoints which could include prior Brackt points (if re-running) |
Guard: if Brackt is already resolved, require explicit override; otherwise this is correct since Brackt points are 0 until resolution |
| Team removed after draft — Brackt participant already drafted | syncBracktParticipants checks pickedIds and only removes undrafted participants |
| Participant-to-team matching at resolution breaks if team renamed | Store team.id in externalId at creation; match by externalId at resolution |
recalculateAffectedLeagues after resolution fires Discord notification |
Acceptable — it announces Brackt final scoring |
isSeasonComplete() returns false until Brackt resolved |
Intentional — commissioner finalizing Brackt is the explicit season-close action |
File Map
| File | Purpose |
|---|---|
database/schema.ts |
fantasySeasonId on sportsSeasons, enum additions |
app/lib/brackt-constants.ts |
BRACKT_SPORT_SLUG = "brackt" |
app/lib/brackt-setup.server.ts |
All Brackt lifecycle model functions |
app/lib/brackt-ev-updater.server.ts |
runBracktHarvilleForFantasySeason() — EV pipeline entry point |
app/services/simulations/brackt-simulator.ts |
BracktSimulator — Harville projection |
app/services/simulations/registry.ts |
Register "brackt" simulator type |
app/models/sports-season.ts |
isNull(fantasySeasonId) filter in findDraftableSportsSeasons() |
app/models/draft-utils.ts |
runBracktHarvilleForFantasySeason() in executeAutoPick(); Harville + socket emit in autodraft chain |
app/routes/api/draft.make-pick.ts |
Fire-and-forget Harville + brackt-evs-updated emit after manual pick |
server/socket.ts |
brackt-evs-updated in ServerToClientEvents |
app/routes/leagues/new.tsx |
Auto-create per-league Brackt season; callout in sport picker |
app/routes/leagues/$leagueId.settings.tsx |
syncBracktParticipants() on team count changes |
app/routes/leagues/$leagueId.standings.$seasonId.tsx |
BracktResolutionCard for commissioner |
app/routes/rules.tsx |
Brackt rules section |
app/routes/how-to-play.tsx |
Brackt how-to section |
app/routes/admin/sports-seasons.tsx |
Filter toggle for per-league copies |