Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,11 @@ body {
}
}

/* Standings: rows keep their identity, so the list re-orders as a move, not a repaint. */
.rank-move {
transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
}

/* The avatar strip says "this scrolls" with faces cut off at the gutter, so
the bar underneath is noise. */
.no-scrollbar {
Expand Down
196 changes: 147 additions & 49 deletions frontend/src/pages/Host.vue
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,88 @@
</div>
</template>

<!-- Scoreboard: the points land, then the rows climb to their new places -->
<template v-else-if="phase === 'scoreboard'">
<div
class="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-5 sm:gap-8 sm:p-8"
>
<div class="text-center">
<p class="font-mono text-xs uppercase tracking-[0.28em] text-paper/40">
After question {{ (scoreboard?.q_index ?? 0) + 1 }} of
{{ scoreboard?.total }}
</p>
<h1 class="mt-2 font-display text-4xl font-extrabold text-paper sm:text-6xl">
Scoreboard
</h1>
</div>

<TransitionGroup
tag="ol"
name="rank"
class="flex min-h-0 w-full max-w-3xl flex-col gap-2.5 overflow-y-auto p-1"
>
<li
v-for="(entry, place) in standings"
:key="entry.nickname"
class="flex items-center gap-4 rounded-2xl border bg-dusk px-4 py-3 sm:px-5 sm:py-4"
:class="settled && entry.rank === 1 ? 'border-accent' : 'border-haze'"
>
<!-- until the rows land, the number is where the row sits, not the rank it
came from: a top five missing a player who fell out of it would gap -->
<span class="w-6 shrink-0 font-mono text-lg tabular-nums text-paper/35">
{{ settled ? entry.rank : place + 1 }}
</span>
<AvatarPic :id="entry.avatar" :nickname="entry.nickname" :size="44" />
<span
class="min-w-0 flex-1 truncate font-display text-xl font-bold text-paper sm:text-2xl"
>
{{ entry.nickname }}
</span>
<span
v-if="entry.gained"
class="font-mono text-base font-bold text-ok transition-opacity duration-500 sm:text-lg"
:class="settled ? 'opacity-0' : 'opacity-100'"
>
+{{ entry.gained }}
</span>
<span
class="w-24 shrink-0 text-right font-mono text-xl font-bold tabular-nums text-accent sm:text-2xl"
>
{{ shownScores[entry.nickname] ?? entry.score }}
</span>
</li>
</TransitionGroup>

<ul
v-if="streaks.length"
class="flex flex-wrap justify-center gap-x-6 gap-y-2 text-base text-paper/70 sm:text-lg"
>
<li
v-for="entry in streaks"
:key="entry.nickname"
class="flex items-center gap-2"
>
<AvatarPic :id="entry.avatar" :nickname="entry.nickname" :size="28" />
🔥 {{ entry.nickname }} is on a {{ entry.streak }} answer streak
</li>
</ul>

<div class="flex flex-wrap items-center justify-center gap-3">
<button class="ctl ctl-go" @click="next">Next question</button>
<button
v-if="showHostControls"
class="ctl"
:data-on="autoAdvance"
@click="toggleAutoAdvance"
>
Auto-advance {{ autoAdvance ? "on" : "off" }}
</button>
<button v-if="showHostControls" class="ctl" @click="end">End game</button>
<p v-if="error" class="text-alert">{{ error }}</p>
</div>
</div>
</template>

<!-- Read time: question only, no answers yet -->
<template v-else-if="phase === 'get_ready'">
<div
Expand Down Expand Up @@ -285,7 +367,7 @@
/>
<div class="flex flex-wrap items-center justify-center gap-3">
<button class="ctl ctl-go" @click="next">
{{ explanation?.before_stats ? "Show results" : "Next question" }}
{{ explanation?.before_stats ? "Show results" : afterQuestionLabel }}
</button>
<button
v-if="showHostControls"
Expand Down Expand Up @@ -369,48 +451,6 @@
</div>
</div>
</div>

<div class="flex flex-wrap items-start justify-between gap-6 sm:gap-8">
<ol class="w-full flex-1 sm:min-w-64">
<li
v-for="(entry, index) in top5"
:key="entry.nickname"
class="flex items-center justify-between gap-3 border-b border-haze py-2 text-base text-paper/70 sm:text-lg"
>
<span class="flex min-w-0 items-center gap-3">
<span
class="w-5 shrink-0 font-mono text-xs tabular-nums text-paper/35"
>
{{ index + 1 }}
</span>
<AvatarPic
:id="entry.avatar"
:nickname="entry.nickname"
:size="28"
/>
<span class="truncate">{{ entry.nickname }}</span>
</span>
<span class="shrink-0 font-mono tabular-nums">{{
entry.score
}}</span>
</li>
</ol>
<ul class="w-full flex-1 space-y-2 text-base text-paper/70 sm:text-lg">
<li
v-for="entry in streaks"
:key="entry.nickname"
class="flex items-center gap-2"
>
<AvatarPic
:id="entry.avatar"
:nickname="entry.nickname"
:size="28"
/>
🔥 {{ entry.nickname }} is on a {{ entry.streak }} answer
streak
</li>
</ul>
</div>
</template>

<div class="flex flex-wrap items-center gap-3">
Expand All @@ -422,7 +462,7 @@
Skip
</button>
<button v-if="phase === 'closed'" class="ctl ctl-go" @click="next">
{{ explanationNext ? "Show explanation" : "Next question" }}
{{ explanationNext ? "Show explanation" : afterQuestionLabel }}
</button>
<button
v-if="showHostControls"
Expand All @@ -442,7 +482,7 @@
</template>

<script setup>
import { computed, inject, onMounted, ref, watch } from "vue";
import { computed, inject, onMounted, onUnmounted, ref, watch } from "vue";
import QRCode from "qrcode";
import { call, readError } from "@/api";
import { confirm } from "@/confirm";
Expand All @@ -457,8 +497,12 @@ import { initSound, muted, playCue, toggleMute } from "@/sound";
const PODIUM_FILL = { 1: "bg-gold", 2: "bg-lagoon", 3: "bg-orchid" };
// remembered so a reload on the podium restores it: get_host_state only auto-finds live sessions
const HOSTED_SESSION_KEY = "qz_hosted_session";
// long enough to read the old order before it moves, and to watch the points climb
const CLIMB_DELAY_MS = 700;
const TALLY_MS = 900;

const socket = inject("$socket");
let climbTimer = null;
const {
remaining,
total: windowSeconds,
Expand All @@ -480,8 +524,11 @@ const distribution = ref({});
const explanation = ref(null);
const explanationNext = ref(false);
const correctOption = ref(null);
const top5 = ref([]);
const streaks = ref([]);
const scoreboard = ref(null);
const standings = ref([]);
const shownScores = ref({});
const settled = ref(false);
const leaderboard = ref([]);
const qrDataUrl = ref("");
const qrFullscreen = ref(false);
Expand Down Expand Up @@ -522,6 +569,13 @@ const barHeight = (optionId) => {
return Math.max(3, ((distribution.value[optionId] || 0) / max) * 100);
};

// After the last question there is nothing left to stand on but the podium.
const afterQuestionLabel = computed(() =>
(question.value?.q_index ?? 0) >= (question.value?.total ?? 1) - 1
? "Final results"
: "Show scores"
);

// 2nd, 1st, 3rd — the winner stands in the middle
const podiumOrder = computed(() =>
[leaderboard.value[1], leaderboard.value[0], leaderboard.value[2]].filter(Boolean)
Expand Down Expand Up @@ -556,9 +610,10 @@ function onSessionEvent(message) {
explanationNext.value = Boolean(message.explanation_next);
distribution.value = message.distribution;
correctOption.value = message.correct_option;
top5.value = message.top_5;
streaks.value = message.streaks;
phase.value = "closed";
} else if (message.type === "scoreboard") {
stopCountdown();
showScoreboard(message);
} else if (message.type === "podium") {
stopCountdown();
leaderboard.value = message.leaderboard;
Expand All @@ -567,6 +622,46 @@ function onSessionEvent(message) {
}
}

// The screen opens on the standings the room already knows, then the points land and
// the rows race to where they belong. `animate` is off on a reload: nothing to replay.
function showScoreboard(message, animate = true) {
const entries = message.standings || [];
clearTimeout(climbTimer);
scoreboard.value = message;
streaks.value = message.streaks || [];
standings.value = byRank(entries, animate ? "previous_rank" : "rank");
shownScores.value = scoresAt(
entries,
animate ? (entry) => entry.score - entry.gained : (entry) => entry.score
);
settled.value = !animate;
phase.value = "scoreboard";
if (!animate) return;
climbTimer = setTimeout(() => {
standings.value = byRank(entries, "rank");
tallyScores(entries);
}, CLIMB_DELAY_MS);
}

const byRank = (entries, key) => [...entries].sort((a, b) => a[key] - b[key]);

const scoresAt = (entries, score) =>
Object.fromEntries(entries.map((entry) => [entry.nickname, score(entry)]));

function tallyScores(entries) {
const start = performance.now();
const step = (now) => {
const progress = Math.min(1, (now - start) / TALLY_MS);
const eased = 1 - Math.pow(1 - progress, 3);
shownScores.value = scoresAt(entries, (entry) =>
Math.round(entry.score - entry.gained * (1 - eased))
);
if (progress < 1) requestAnimationFrame(step);
else settled.value = true;
};
requestAnimationFrame(step);
}

// Level H redundancy is what buys the room to punch the logo over the middle.
async function renderQr(url) {
const canvas = document.createElement("canvas");
Expand Down Expand Up @@ -610,7 +705,6 @@ async function applyState(state) {
lobbyLocked.value = Boolean(state.lobby_locked);
autoAdvance.value = Boolean(state.auto_advance);
showHostControls.value = Boolean(state.show_host_controls);
top5.value = state.top_5 || [];
qrDataUrl.value = await renderQr(joinUrl.value);

if (state.status === "Lobby") {
Expand All @@ -631,6 +725,8 @@ async function applyState(state) {
correctOption.value = state.question.correct_option;
phase.value = "explanation";
if (autoAdvance.value) startCountdown(state.remaining_seconds);
} else if (state.phase === "scoreboard") {
showScoreboard(state.scoreboard, false);
} else if (state.phase === "closed") {
question.value = state.question;
distribution.value = state.distribution || {};
Expand Down Expand Up @@ -744,6 +840,8 @@ async function end() {
if ((await hostCall("quizzly.api.end_session")) && inLobby) reset();
}

onUnmounted(() => clearTimeout(climbTimer));

function reset() {
localStorage.removeItem(HOSTED_SESSION_KEY);
window.location.reload();
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Play.vue
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ async function restore() {
phase.value = "locked";
stopCountdown();
}
} else if (state.phase === "closed" || state.phase === "explanation") {
} else if (["closed", "explanation", "scoreboard"].includes(state.phase)) {
explanation.value = state.explanation || null;
await showResult({ question_row: state.question.question_row });
} else {
Expand Down
42 changes: 42 additions & 0 deletions progress.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Progress

## Phase 12: Scoreboard screen (2026-08-22)

Spec: `specs/phase-12-scoreboard-screen.md`. The screen that ends a question is
now two: the answer split, then the standings.

### Done

- `engine.py`: new `scoreboard` phase, always the last beat before the next
question, whichever side the explanation sits on. `close_question` builds the
standings (score, points won, new rank, previous rank) and parks them in Redis
state; `carry_over` hands them through the phases in between untouched. The
last question skips the screen: the podium is the standings.
- The top five and the streak callouts move off the stats payload onto the
scoreboard one. The stats screen is the distribution alone now.
- `get_host_state` returns the parked standings for a reload during the phase,
and drops the `top_5` nothing read any more.
- `Host.vue`: standings screen. Rows open in the old order with the old scores,
then the points count up on every row while the rows move to their new places.
`TransitionGroup` keyed by nickname does the move, so row identity survives the
re-sort. The rank column reads as list position until the rows land, or a
player who fell out of the top five leaves a gap in the numbering.
- Reload during the phase paints the settled board with no replay.

### Verified

E2E on `quizzly.localhost` at 1920x1080, six players answering over the API, a
real worker driving the loop:

- Question closes to the distribution bars alone, `Show scores` moves on.
- Standings open in the previous order at the previous scores, `+974` chips land
and the rows overtake: ann and eve climb from 4th and 5th past cid, bob, dee.
- Host reload mid-standings comes back to the same five rows, settled.
- Last question's button reads `Final results` and goes straight to the podium.

### Notes

- Engine tests cover the new phase both ways round the explanation and the
last-question skip: 31 green in `tests/test_engine.py`, plus api/game_ux green.
- A dev-bench aside, not this app: the single bench worker serves `long` after
`default`, so a five-minute job from another site can starve the ticker and the
game abandons itself. The host reload settles it, as designed.

## Phase 11: Quiz preview (2026-08-20)

Spec: `specs/phase-11-quiz-preview.md`. Preview in the editor plays the quiz
Expand Down
7 changes: 3 additions & 4 deletions quizzly/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,9 @@ def get_host_state(session: str | None = None) -> dict:
if session_doc.status == "Lobby":
return result

leaderboard = get_leaderboard(session_doc.name)
result["top_5"] = leaderboard[:5]

state = engine.get_state(session_doc.name)
if not state:
result["leaderboard"] = leaderboard
result["leaderboard"] = get_leaderboard(session_doc.name)
return result

question = get_question_row(session_doc, state["question_row"])
Expand Down Expand Up @@ -116,6 +113,8 @@ def get_host_state(session: str | None = None) -> dict:
distribution[str(answer.selected_option)] += 1
result["distribution"] = distribution
result["explanation_next"] = bool(state.get("explanation_after"))
if state["status"] == "scoreboard":
result["scoreboard"] = state["scoreboard"]
return result


Expand Down
Loading
Loading