Hero Draft (Captain's Mode)¶
The Hero Draft system implements Dota 2's Captain's Mode for tournament matches, allowing team captains to ban and pick heroes in real-time via WebSocket.
| Captain 1 Perspective | Captain 2 Perspective |
|---|---|
![]() |
![]() |
| Full Video | Full Video |
Overview¶
Hero Draft runs through several phases:
- Waiting - Captains connect and ready up
- Rolling - Coin flip to determine who chooses first
- Choosing - Winner picks first pick OR side, loser gets remaining choice
- Drafting - 24 rounds of bans and picks with timers
- Completed - Draft finished, results saved
Data Models¶
HeroDraft¶
| Field | Type | Description |
|---|---|---|
game |
OneToOneField | Associated tournament game |
state |
CharField | Current phase (see HeroDraftState) |
roll_winner |
ForeignKey | Team that won the coin flip |
paused_at |
DateTimeField | Timestamp when draft was paused (null if not paused) |
created_at |
DateTimeField | Creation timestamp |
updated_at |
DateTimeField | Last update timestamp |
HeroDraftState¶
| State | Description |
|---|---|
waiting_for_captains |
Waiting for both captains to connect and ready |
rolling |
Coin flip phase |
choosing |
Winner/loser making pick order and side choices |
drafting |
Active ban/pick phase with timers |
paused |
Draft paused (captain disconnect or manual via captain/staff). Server stops broadcasting ticks. |
resuming |
3-second countdown between PAUSED → DRAFTING. resuming_until carries the target timestamp. |
completed |
Draft finished successfully |
abandoned |
Draft cancelled |
DraftTeam¶
| Field | Type | Description |
|---|---|---|
draft |
ForeignKey | Parent HeroDraft |
tournament_team |
ForeignKey | Associated tournament team |
is_first_pick |
Boolean | Whether team has first pick |
is_radiant |
Boolean | Whether team is Radiant side |
reserve_time_remaining |
Integer | Reserve time in milliseconds (default 90000) |
is_ready |
Boolean | Whether captain has readied up |
is_connected |
Boolean | Whether captain is currently connected |
HeroDraftRound¶
| Field | Type | Description |
|---|---|---|
draft |
ForeignKey | Parent HeroDraft |
draft_team |
ForeignKey | Team making this pick/ban |
round_number |
Integer | Sequential round number (1-24) |
action_type |
CharField | ban or pick |
hero_id |
Integer | Selected hero ID (null until chosen) |
state |
CharField | planned, active, or completed |
grace_time_ms |
Integer | Grace time for this round (default 30000) |
started_at |
DateTimeField | When round became active |
completed_at |
DateTimeField | When round was completed |
WebSocket Architecture¶
Connection¶
Clients connect via WebSocket to /api/herodraft/<draft_id>/. The HeroDraftConsumer handles:
- Connection tracking (captain vs spectator)
- Initial state delivery
- Event broadcasting
- Pause/resume logic
Events (Server → Client)¶
| Event Type | Description | Payload |
|---|---|---|
initial_state |
Full draft state on connect | {draft_state: {...}} |
captain_connected |
Captain joined | {draft_team: {...}} |
captain_disconnected |
Captain left | {draft_team: {...}} |
captain_ready |
Captain readied up | {draft_team: {...}} |
draft_paused |
Draft paused (captain disconnect) | {metadata: {reason: "captain_disconnected"}} |
resume_countdown |
Countdown before resume | {metadata: {countdown_seconds: 3}} |
draft_resumed |
Draft resumed | {} |
roll_triggered |
Coin flip started | {} |
roll_result |
Coin flip result | {draft_team: {...}} |
choice_made |
Pick order/side chosen | {draft_team: {...}} |
round_started |
New round began | {draft_state: {...}} |
hero_selected |
Hero picked/banned | {draft_state: {...}} |
draft_completed |
Draft finished | {draft_state: {...}} |
Tick Updates¶
During drafting and resuming phases, the server broadcasts herodraft_tick at 1Hz. Ticks carry anchors (server timestamps + durations), not pre-computed remainders — the client derives the countdown locally via requestAnimationFrame so the displayed timer stays smooth even when ticks are delayed, batched, or dropped.
DRAFTING tick:
{
"type": "herodraft_tick",
"draft_state": "drafting",
"server_time": "2026-05-21T18:42:13.421Z",
"current_round": 0,
"active_team_id": 123,
"round_started_at": "2026-05-21T18:42:08.000Z",
"round_grace_time_ms": 30000,
"team_a_id": 123,
"team_a_reserve_ms": 90000,
"team_b_id": 456,
"team_b_reserve_ms": 85000
}
RESUMING tick (during the 3s countdown after pause):
{
"type": "herodraft_tick",
"draft_state": "resuming",
"server_time": "2026-05-21T18:43:11.005Z",
"resuming_until": "2026-05-21T18:43:14.000Z"
}
team_a_reserve_ms / team_b_reserve_ms are the team's cumulative reserve at round start (stable for the duration of the round). The active team's display value is computed client-side as max(0, reserve - max(0, elapsed - grace)). The server debits actual usage at pick-submit time, so the next round's tick carries the lower value forward.
Client-Side Countdown¶
The store captures serverClockOffsetMs = server_time - Date.now() once on every tick receive. useDraftCountdown runs a requestAnimationFrame loop and recomputes the display values each frame against Date.now() + serverClockOffsetMs and the current tick's anchors. This decouples timer smoothness from broadcast cadence — even a 5-second tick gap doesn't freeze the UI.
While paused, the server stops broadcasting ticks, so the cached tick keeps reading drafting with a stale round_started_at. The hook reads the live draft.state from the store and skips its setState call while paused, freezing the displayed values until the next tick arrives (during RESUMING).
Outbound timestamps use getServerNowISO() (also derived from the offset). submitPick sends client_picked_at so the server can credit reserve against the picker's local clock rather than the server-receive time. The server applies a 2-second sanity window: stamps older than 2s or in the future fall back to server-receive.
Pause/Resume System¶
Trigger Conditions¶
The draft pauses immediately when:
- Any captain's heartbeat goes stale (>9s without keepalive) during drafting
- A captain or tournament staff member POSTs to /api/herodraft/<pk>/pause/ (manual pause; sets is_manual_pause=true)
The draft resumes when:
- POST to /api/herodraft/<pk>/resume/ by a captain or tournament staff
- Server transitions PAUSED → RESUMING with a 3-second countdown anchor (resuming_until)
- After 3s the tick loop transitions RESUMING → DRAFTING
Backend Logic¶
On Pause (heartbeat-stale or manual):
- Set
draft.state = PAUSED - Store
draft.paused_at = now() - Broadcast
draft_pausedevent to all clients - Tick broadcaster stops on next iteration (
should_continue_tickingreturns False)
On Resume (manual):
- Calculate
pause_duration = now() - draft.paused_at - Adjust active round's
started_atforward bypause_duration + 3 secondsandsave(update_fields=["started_at"]) - Set
draft.state = RESUMING,resuming_until = now() + 3s, clearpaused_atandis_manual_pause - Restart tick broadcaster — first ticks carry the RESUMING anchor; clients display "Resuming in N…"
- After
resuming_untilpasses, the tick loop'scheck_resume_countdownflips the draft toDRAFTINGand broadcastsdraft_resumed. Subsequent ticks carry the newround_started_at
Timer Adjustment:
The key insight is that elapsed time is calculated as:
By pushing started_at forward by the pause duration + countdown, the elapsed time calculation automatically excludes the pause period.
Edge Case: Disconnect During Countdown¶
If a captain disconnects during the "Resuming in 3...2...1..." countdown:
- Backend is already in DRAFTING state
- Disconnect triggers pause logic again
- Frontend receives draft_paused, cancels countdown, shows pause overlay
Timing Tolerances¶
For responsive UX: - Pause overlay should appear within 100ms of disconnect - Resume countdown should start within 100ms of all captains connected - Timer should resume exactly where it paused (no drift)
Observability¶
Telemetry plumbing is shared across the whole backend (see dev/telemetry/). This section documents what herodraft-specific signals you can expect and how to query them.
Spans (Tempo)¶
Every WS-lifecycle span carries ws.conn_id (plus ws.draft_id and user.id when known) so Tempo correlation can stitch a session together with { .ws.conn_id = "abc..." }.
| Span | Emitted by | Useful attributes |
|---|---|---|
ws.connect, ws.disconnect, ws.heartbeat, ws.message |
consumers_base.py |
ws.conn_id, ws.draft_id, user.id, ws.consumer |
ws.captain_register, ws.captain_unregister |
consumers_base.py |
same base attrs; unregister also tags ws.captain_unregistered (bool) and ws.skipped_reason |
herodraft.submit_pick |
functions/herodraft.py |
ws.draft_id, draft.team_id, draft.hero_id, draft.client_picked_at, draft.round_number, draft.action_type, draft.elapsed_ms, draft.grace_used_ms, draft.reserve_used_ms, draft.reserve_before_ms, draft.reserve_after_ms, draft.pick_time_source (client or server_now), draft.pick_time_age_ms, draft.next_round_number, draft.completed |
herodraft.tick.iteration |
tasks/herodraft_tick.py |
ws.draft_id, tick.iteration, tick.duration_s, tick.outcome, tick.stop_reason |
herodraft.tick.<step> |
tasks/herodraft_tick.py |
child of tick.iteration. Steps: resume_countdown, captain_heartbeats, broadcast_tick, check_timeout, extend_lock. Each carries tick.step_duration_s |
herodraft.submit_pick runs inside the parent ws.message span, so a single trace shows: client message arrives → handler dispatches → pick logic → DB commit → broadcast. Look for the trace by ws.draft_id or via the traceID derived field on any associated log line.
Sample TraceQL (Tempo Explore):
# Every pick in a draft
{ name = "herodraft.submit_pick" && .ws.draft_id = 123 }
# Slow tick iterations
{ name = "herodraft.tick.iteration" && duration > 1500ms }
# Picks where the server fell back from client_picked_at
{ name = "herodraft.submit_pick" && .draft.pick_time_source = "server_now" }
# Picks that ran into reserve time (grace_used_ms == grace_time_ms means the team burned reserve)
{ name = "herodraft.submit_pick" && .draft.reserve_used_ms > 0 }
Log events (Loki)¶
All logs carry system="herodraft" plus a subsystem discriminator. Filter by service_name="backend" for the relevant stream.
subsystem |
Event | Level | Trigger |
|---|---|---|---|
state |
draft_paused |
info | Manual pause (captain/staff POST to /pause/) |
state |
draft_resumed |
info | Manual resume; includes pause_duration_s, round_started_at_adjustment_s |
state |
draft_completed |
info | Final pick committed and draft state flipped to COMPLETED |
round |
round_started |
info | A new round transitioned to state="active" (carries round_number, action_type) |
timing |
round_started_at_adjusted |
info | Round anchor pushed forward by pause duration + 3s countdown |
heartbeat |
heartbeat_missing |
warning | No heartbeat key in Redis for a connected captain |
heartbeat |
heartbeat_stale |
warning | Heartbeat older than 9s |
heartbeat |
heartbeat_triggered_pause |
info | Heartbeat-stale forced a state → PAUSED transition |
timer |
tick_loop_started / tick_loop_stopping / tick_loop_ended |
info | Broadcaster lifecycle. tick_loop_stopping.reason is no_connections, draft_state_paused, draft_state_completed, etc. |
timer |
tick_loop_healthy |
info | Heartbeat log every 30 ticks with duration_s |
timer |
tick_loop_stalled |
error | Gap between consecutive ticks >1.5s (event loop blocked) |
timer |
tick_skipped |
info / warning | reason ∈ {wrong_state, no_active_round, not_found} |
timer |
tick_step_slow / tick_slow |
warning | Sub-step >300ms or whole iteration >1.5s |
timer |
timeout_auto_pick / timeout_auto_pick_broadcast |
info | Auto-random fired (over grace + reserve) |
pick |
pick_submitted |
info | Includes elapsed_ms, grace_used_ms, reserve_used_ms, reserve_before_ms, reserve_after_ms, pick_time_source, pick_time_age_ms |
pick |
pick_time_too_old / pick_time_in_future / pick_time_naive_datetime |
warning | Server rejected client_picked_at and fell back to receive-time |
pick |
pick_reserve_exhausted |
warning | Active team finished pick with reserve_time_remaining ≤ 0 |
pick |
pick_elapsed_negative |
error | now - round_started_at < 0 (clock skew or DB clock drift) |
Sample LogQL queries:
# Full lifecycle of a specific draft
{service_name="backend"} | json | draft_id="123"
# Every pause/resume event across the cluster, with timing
{service_name="backend"} | json | event_name=~"draft_paused|draft_resumed|round_started_at_adjusted"
# Tick health for a draft (cadence, slow steps, stalls)
{service_name="backend"} | json | system="herodraft" | subsystem="timer" | draft_id="123"
# Captains whose heartbeat went stale (potential disconnect leading to pause)
{service_name="backend"} | json | event_name=~"heartbeat_(stale|missing|triggered_pause)"
# Picks that crossed grace and burned reserve, sorted by burn amount
{service_name="backend"} | json | event_name="pick_submitted" | reserve_used_ms > 0
Derived fields → one-click navigation¶
The Loki datasource is configured (see docs/dev/telemetry/datasources/grafanacloud-logs.json) so the following IDs in any log line become clickable:
| Field | Click action |
|---|---|
trace_id (any spelling: traceID, trace_id, body or label) |
Open the trace in Tempo |
ws_conn_id |
Internal Loki query showing every event for that WebSocket from connect → disconnect |
user_id |
Internal Loki query for all events involving that user |
draft_id |
Internal Loki query for the entire herodraft session |
When debugging a pause complaint, the typical loop is: open Loki → filter by draft_id → find the draft_paused event → click its trace_id → see the ws.message → herodraft.submit_pick (or pause-endpoint) span in Tempo.
API Endpoints¶
Get Draft State¶
Returns full draft state including teams, rounds, and current phase.
Submit Pick/Ban¶
Request:
Ready Up¶
Flip Coin¶
Make Choice (Pick Order/Side)¶
Request:
Frontend Implementation¶
File Structure¶
frontend/app/
├── components/herodraft/
│ ├── HeroDraftModal.tsx # Main modal container (612 lines)
│ ├── DraftTopBar.tsx # Team info, avatars, timers
│ ├── HeroGrid.tsx # Hero selection with search
│ ├── DraftPanel.tsx # Draft timeline visualization
│ ├── HeroDraftHistoryModal.tsx # Event history display
│ ├── hooks/
│ │ └── useHeroDraftWebSocket.ts # WebSocket connection hook
│ ├── api.ts # HTTP API functions
│ ├── schemas.ts # Zod runtime validation
│ └── types.ts # TypeScript type definitions
├── store/
│ └── heroDraftStore.ts # Zustand state store
└── pages/herodraft/
└── HeroDraftPage.tsx # Page component
Key Components¶
| Component | Purpose |
|---|---|
HeroDraftModal |
Main container for draft UI |
DraftTopBar |
Timer display, team info, turn indicator |
HeroGrid |
Hero selection grid with filtering |
DraftPanel |
Pick/ban slots display |
WebSocket Hook (useHeroDraftWebSocket)¶
Manages WebSocket connection with automatic reconnection:
interface UseHeroDraftWebSocketOptions {
draftId: number | null;
enabled?: boolean; // Only connect when enabled
onStateUpdate?: (draft: HeroDraft) => void;
onTick?: (tick: HeroDraftTick) => void;
onEvent?: (event: HeroDraftEvent) => void;
}
interface UseHeroDraftWebSocketReturn {
isConnected: boolean;
connectionError: string | null;
reconnectAttempts: number;
reconnect: () => void;
}
Reconnection Strategy: - Base delay: 1000ms - Max delay: 30,000ms (30s) - Max attempts: 10 - Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s...
Message Types Handled:
- initial_state - Full state on connect/reconnect
- herodraft_event - Events with optional state update
- herodraft_tick - Timer updates every second
State Management (Zustand)¶
interface HeroDraftState {
draft: HeroDraft | null;
tick: HeroDraftTick | null;
selectedHeroId: number | null;
searchQuery: string;
// Actions
setDraft: (draft: HeroDraft) => void;
setTick: (tick: HeroDraftTick) => void;
setSelectedHeroId: (heroId: number | null) => void;
setSearchQuery: (query: string) => void;
reset: () => void;
// Computed helpers
getCurrentTeam: () => DraftTeam | null;
getOtherTeam: () => DraftTeam | null;
isMyTurn: (userId: number) => boolean;
getUsedHeroIds: () => number[];
}
Event Handling¶
Events are processed in HeroDraftModal.handleEvent():
switch (event.event_type) {
case "captain_ready":
toast.info(`${captainName} is ready`);
break;
case "captain_connected":
toast.success(`${captainName} connected`);
break;
case "captain_disconnected":
toast.warning(`${captainName} disconnected`);
break;
case "draft_paused":
toast.warning("Draft paused - waiting for captain to reconnect");
setResumeCountdown(null); // Cancel any active countdown
break;
case "resume_countdown":
const seconds = event.metadata?.countdown_seconds ?? 3;
setResumeCountdown(seconds);
toast.info(`Resuming in ${seconds}...`);
break;
case "draft_resumed":
toast.success("Draft resumed - all captains connected");
setResumeCountdown(null);
break;
// ... more event types
}
Pause Overlay Implementation¶
The pause overlay shows in two states:
1. Waiting - When draft.state === "paused" and no countdown
2. Countdown - When resumeCountdown is active
{(draft.state === "paused" || resumeCountdown !== null) && (
<div className="absolute inset-0 bg-black/70 flex items-center justify-center">
{resumeCountdown !== null && resumeCountdown > 0 ? (
// Countdown state - green text
<>
<h2 className="text-3xl font-bold text-green-400">
Resuming in {resumeCountdown}...
</h2>
<p>All captains connected</p>
</>
) : (
// Waiting state - yellow text
<>
<h2 className="text-3xl font-bold text-yellow-400">
Draft Paused
</h2>
<p>Waiting for captain to reconnect...</p>
<Button onClick={reconnect}>Reconnect</Button>
</>
)}
</div>
)}
Countdown Timer Effect:
useEffect(() => {
if (resumeCountdown === null || resumeCountdown <= 0) return;
const timer = setTimeout(() => {
setResumeCountdown((prev) =>
prev !== null && prev > 0 ? prev - 1 : null
);
}, 1000);
return () => clearTimeout(timer);
}, [resumeCountdown]);
Pause Overlay Behavior¶
| Event | Action |
|---|---|
draft_paused |
Show overlay with "Waiting..." message, cancel any countdown |
resume_countdown |
Start countdown from received seconds (usually 3) |
draft_resumed |
Clear countdown, overlay hides when state changes to "drafting" |
draft_paused during countdown |
Cancel countdown, show waiting message |
Test IDs¶
All components have data-testid attributes for Playwright testing:
| Test ID | Element |
|---|---|
herodraft-modal |
Main modal container |
herodraft-paused-overlay |
Pause/countdown overlay |
herodraft-paused-title |
"Draft Paused" heading |
herodraft-countdown-title |
"Resuming in X..." heading |
herodraft-reconnect-btn |
Reconnect button |
herodraft-reconnecting |
Connection status indicator |
herodraft-hero-{id} |
Hero buttons in grid |
herodraft-confirm-dialog |
Pick confirmation dialog |
Testing¶
Backend Tests¶
Key test: test_pause_resume_timing_adjustment - verifies that pause duration + countdown is correctly added to started_at.
Frontend E2E Tests (Playwright)¶
Fuzz tests verify: - Timer pauses immediately on disconnect - Timer resumes correctly after reconnection - State is maintained through connection drops - Rapid reconnect cycles don't cause issues

