Skip to main content

Draft room

Live drafts run on a separate backend, wss://fantasydraft.espn.com, not the REST API. Use this page to join a room, submit a pick, read picks back and tell a finished draft from a dead connection.

The short version
  • SELECT <playerId> on the joined socket submits a pick. SELECTED confirms it in about 250 ms.
  • The room is single-session: a new JOIN for the same identity evicts the old one, and there is no replay.
  • STATE 2 is ESPN's draft-complete signal.
  • Cookies are enough for the socket. The rendered draft room also needs a real login.

Joining the room​

Confirmed live on 08-16 and 08-17.

wss://fantasydraft.espn.com/game-1/league-{leagueId}/JOIN
?1=1&2={leagueId}&3={teamId}&4={memberId}&5=1:{leagueId}:{teamId}:{memberId}:-301672673
&6=false&7=false&8=KONA&nocache={random}
PartMeaning
{memberId}The SWID cookie with its braces
-301672673A fixed constant, identical across leagues and sessions. Not session-derived.
1, 6, 7, 8=KONALiteral constants copied from real traffic. Their meaning is unknown.
Encoding{, } and : stay literal. URLSearchParams would percent-encode them, so buildDraftSocketUrl builds the string by hand.

The upgrade request needs all three of these headers. A bare Cookie got a 403 from the edge before any app response.

HeaderValue
Cookieespn_s2=<espn_s2>; SWID={SWID-WITH-BRACES}
Originhttps://fantasy.espn.com
User-AgentA real desktop browser string

Frame reference​

All frames are plain text, not JSON.

FrameDirectionMeaning
INIT <base64>server → clientFull draft state, once per connection. 10–20 KB or more.
TOKEN 1:{leagueId}:{teamId}:{memberId}:-301672673server → clientEchoes the JOIN token.
STATE <n>server → client1 at connection start while the draft runs. 2 exactly once, right after the final SELECTED. Confirmed 08-25.
CLOCK <slot> <msRemaining> <teamId>server → clientCountdown update.
SELECTING <teamId> <clockMs>server → clientA team just went on the clock. The reliable "whose turn" signal.
SELECT <playerId>client → serverSubmits a pick.
SELECTED <teamId> <playerId> <round> [<memberId>]server → clientA pick landed. memberId only when a human picked. Carries a round, not an overall pick number.
ERROR <code> <message>server → clientPick refused. A pick for a drafted player returns ERROR 1 Invalid+Selection. (spaces sent as +).
AUTOSUGGEST <playerId>server → clientESPN's suggested pick, once per pick cycle. A bare player ID.
AUTODRAFT <teamId> <bool>server → clientA team's autodraft toggle changed.
DRAFT_LIST <id> <id> …bothThe full "queue" list on every change. Not a pick.
PING PING%20<unixMs>client → serverKeepalive every ~15 s from a browser. Not needed before SELECT.

Submitting a pick​

SELECT <playerId>\n on the joined socket. This was verified twice: in a passive capture of a human pick, and by draft_room_socket.js's own sendPick() in a real practice draft. Confirmation came back in about 250 ms.

OutcomeHow the worker treats it
Matching SELECTEDSuccess
ERROR framePermanentError. The player is gone, so a retry can't succeed. A rejection notice also goes to Redis.
Nothing within the timeoutAmbiguous. Retried, then permanent after repeated timeouts.

An HTTP GET …/game-1/league-{id}/SELECT?1={playerId}&token=… was seen once in a browser's Network panel. Every pick the worker has sent used the socket alone, so the HTTP form isn't implemented.

The room is single-session​

Confirmed live on 08-17: a new JOIN for the same league, team and member evicts the existing connection. The evicted side sees its socket close with no distinguishing code.

Side effectHandling
A pick evicts the manager's own open browser tabRecoverable. The pick has already landed when the tab reconnects.
A pick evicts the long-running draft syncDraft sync owns the socket. The clicker delegates picks to it through the session registry instead of joining. See Session and auth.
A watcher such as scripts/draft-watch.js gets evictedIt reconnects with a short backoff.

Draft-room DOM​

Confirmed live on 08-16 and 08-17. Unlike the roster page, which has no per-player identifier at all, the draft room puts one on its buttons.

ElementSelector
Queue buttonbutton.Button--queue.action-btn[data-player-id="{playerId}"]
Draft buttonbutton.Button--draft[data-player-id="{playerId}"]

The Draft button only renders while you're on the clock. draft_recon.js captures the DOM the instant a SELECTING frame names your team.

Board selectors are unverified

The pick-history board has never been captured with picks on it. draft_dom_observer.js tries a list of candidate selectors, ending in a structure-free [data-player-id][data-team-id] scan, and logs every miss with match counts and a sample of the board HTML. One look at pod logs from a real draft is enough to fix it.

Reading picks: three tiers​

Each source has a blind spot, and the WebSocket and REST tiers fail together during a practice draft.

TierSourceStrengthFailure mode
1WebSocket SELECTEDFastest, exact IDsMisses every pick while evicted. No replay.
2REST mDraftDetailOnly source of overallPickNumberZero picks for a whole practice draft.
3Rendered board (DOM)Full history whenever you lookSelector-dependent. Needs a real login.

All three feed draft_pick_ledger.js:

  • Precedence is REST, then DOM, then init, then WebSocket. The WebSocket publishes first and higher tiers only correct it.
  • The dedup key is player_id, because a player is drafted once and pick numbers don't agree across sources.
  • A pick is committed only after the Redis write lands, so a Redis failure can't mark an unpublished pick as seen.
  • On startup the ledger reseeds from draft:{leagueId}:{seasonId}:picks, so a restarted pod doesn't republish the whole board.

The Draft sync page lists the Redis keys.

Detecting draft completion​

A finished draft looks like a dead connection at the transport layer: REST goes empty and the board disappears. Confirmed live on 08-25, the socket does not close when the draft ends. It stays open with only keepalives.

SignalStatus
STATE 2 frameConfirmed live. Sent once, right after the final SELECTED.
Total picks reachedmSettings + mTeam give teams × draftable slots, excluding IR slot 21. One practice league had 17 slots including IR, so 10 × 16 = 160 picks, not 170. Confirmed working mid-draft. After the draft it returns LEAGUE_NOT_FOUND_DELETED, so the total learned earlier is kept.
N cycles with no picksNot a completion signal. An outage looks the same. It only escalates the log and leads to a failure exit if nothing else fires.

The heartbeat on draft:{leagueId}:health carries terminal, terminal_reason, consecutive_empty_cycles and expected_total_picks. Every frame type other than INIT and SELECTED is counted in ws.other_frames, which is how STATE 2 was found.

Practice and mock draft quirks​

QuirkWhat to do
"Practice Draft" sometimes opens the room in a new tab.startFreshPracticeDraft races both and returns { url, page }. Use the returned page.
getByText(/practice draft/i) matches a section heading first, and clicking it silently does nothing.Use getByRole("button", { name: /practice draft/i }), which matches only the button.
Headless auto-join never navigates, even with the right button.Invisible reCAPTCHA Enterprise blocks it. Have a human join once and pass DRAFT_ROOM_URL.
A queued player (DRAFT_LIST) looks like a pick.Only SELECT and SELECTED are picks.
How the headless auto-join block was traced (08-21)
  1. The lobby always embeds an invisible reCAPTCHA Enterprise iframe next to the Disney identity iframe.
  2. Without a permission grant, clicking "Practice Draft" makes that iframe call document.requestStorageAccess(). Headless Chromium denies it, and the page's React app crashes.
  3. Granting it over raw CDP stops the crash: Browser.setPermission({ permission: { name: "storage-access" }, setting: "granted", origin: "https://www.google.com", embeddingOrigin: "https://fantasy.espn.com" }). The name is kebab-case. Playwright's grantPermissions() doesn't cover it.
  4. Even then, the click never navigates, with navigator.webdriver already false. A silent no-op is reCAPTCHA Enterprise scoring the session as automated.

More permissions or Chromium flags won't fix this. Don't spend more time on it.

Debugging a missing pick​

Filter pod logs on component=:

ComponentShows
draft_wsSocket open and close with frame and pick counts. A close logs at warn because it's usually an eviction.
draft_dom_observerScan results and selector misses with a DOM sample.
draft_ingestEvery publish, correct or drop decision. "DOM tier recovered N pick(s) the WebSocket never delivered" means the socket is being evicted.
draft_healthFixed-cadence heartbeat with ws_connected, picks_known, pick_silence_ms and per-tier counters. It fires even when nothing happens, because silence is ambiguous.