Skip to content

add bar battle animation to possession pitch visualization#26

Merged
ntalakhadze merged 1 commit into
mainfrom
feature/bar-battle-animation
May 12, 2026
Merged

add bar battle animation to possession pitch visualization#26
ntalakhadze merged 1 commit into
mainfrom
feature/bar-battle-animation

Conversation

@ntalakhadze

@ntalakhadze ntalakhadze commented May 12, 2026

Copy link
Copy Markdown
Contributor

Animated "bar battle" plays on the pitch after each round to visualize how points affect possession. When players earn points, +N text appears near their avatar, then flies into their zone and spawns bars (1 bar = 10 points). Bars march toward the center, cancel one-by-one, and remaining bars push the possession line. Works for all question types (MC, countdown, clues, putInOrder).

Summary by CodeRabbit

  • New Features

    • Added an animated "bar battle" overlay that visualizes scoring dynamics during possession matches with smooth transitions between phases.
  • Chores

    • Adjusted animation timing parameters to optimize the visual flow of possession match displays.

Review Change Stack

Animated "bar battle" plays on the pitch after each round to visualize how points affect possession. When players earn points, +N text appears near their avatar, then flies into their zone and spawns bars (1 bar = 10 points). Bars march toward the center, cancel one-by-one, and remaining bars push the possession line. Works for all question types (MC, countdown, clues, putInOrder).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented May 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
quizball-web Ready Ready Preview, Comment May 12, 2026 5:43pm

Request Review

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR adds a multi-phase bar-battle animation system to possession match UI. A new BarBattleOverlay component renders animated bars representing player and opponent scores marching toward a divider with collision effects. The useBarBattle hook orchestrates phase transitions triggered by realtime match events, and the feature integrates into the realtime controller and pitch visualization. Timing constants are adjusted to accommodate the animation sequence, and player score splash animations are enhanced with round data.

Changes

Bar Battle Feature

Layer / File(s) Summary
Bar Battle Animation Component
src/features/possession/components/BarBattleOverlay.tsx
BarBattlePhase and BarBattleState types define the animation contract. ScoreText renders animated score labels. Bar renders gradient bars with phase-dependent spawn, march, cancel-flash, and push behaviors. CollisionFlash provides timed glow and spark effects. BarBattleOverlay computes bar positions, renders gradient defs and mapped bar instances, and early-exits when phase is done.
Bar Battle State Machine
src/features/possession/hooks/useBarBattle.ts
Manages multi-phase orchestration driven by answer ack, opponent scoring, and round result events. Tracks shown qIndex per score type, computes bar counts and cancellation delta from earned points, initializes battle state with divider snapshot, and schedules timed transitions through both-score → convert → bars → battle → result → done. Clears timeouts on unmount and resets state on new question arrival. Exports BAR_BATTLE_TOTAL_MS timing constant.
Realtime Controller Integration & Timing
src/features/possession/hooks/useRealtimePossessionMatchController.ts, src/features/possession/realtimePossession.helpers.ts
Computes SVG-coordinate dividerX based on mirrored flag, calls useBarBattle with match/answer/phase/divider state, and passes barBattle into pitchProps. Updates FIELD_RESULT_COMPARE_MS (900→2400ms) and FIELD_POSSESSION_CUE_MS (300→600ms) to provide sufficient time for the bar-battle animation sequence.
PitchVisualization Rendering
src/features/possession/components/PitchVisualization.tsx
Adds optional barBattle?: BarBattleState | null prop to PitchVisualizationProps, destructures it from props, and conditionally renders BarBattleOverlay with mirrored orientation when barBattle is provided.
Score Splash Enhancement
src/features/possession/hooks/usePossessionScoreSplashes.ts, src/features/possession/hooks/__tests__/usePossessionScoreSplashes.test.ts
UsePossessionScoreSplashesParams adds myRound: MatchRoundResultPlayer | null parameter. New useEffect watches roundResult and myRound to conditionally display player points splash for non-MC questions when phase is normal/last_attack, selectedAnswer is null, and points are earned; records shown qIndex to prevent duplicates. Test scenarios updated to pass myRound parameter.

Sequence Diagram

sequenceDiagram
  participant Controller as Realtime Controller
  participant Hook as useBarBattle
  participant Pitch as PitchVisualization
  participant Overlay as BarBattleOverlay

  Controller->>Hook: answerAck & opponent score events
  Hook->>Hook: track shown scores, update to<br/>both-score phase if ready
  
  Controller->>Hook: roundResult arrives with point deltas
  Hook->>Hook: compute bar counts & cancellation
  Hook->>Hook: init battle state, schedule transitions
  
  loop Phase transitions (80ms → 120ms → 80ms → 400ms → 100ms)
    Hook->>Pitch: barBattle.phase updated
    Pitch->>Overlay: render with current phase
    Overlay->>Overlay: animate bars per phase<br/>(spawn → march → collision → push)
  end
  
  Hook->>Hook: cleanup & reset after done phase
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

  • Quizball-trivia/web#22: Introduces the timing constants and helpers that this PR adjusts for bar-battle animation pacing.
  • Quizball-trivia/web#12: Modifies PitchVisualization.tsx with SVG overlay logic, sharing code-level integration patterns with bar-battle rendering.
  • Quizball-trivia/web#9: Adds possession UI components and hooks that this PR builds upon for score feedback.

Poem

🎯 Bars march to battle with Framer's grace,
Two scores collide in animated space.
Phases orchestrate, splashes ignite,
The possession feast: a visual delight! 🎨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly summarizes the main changeset: introducing a bar battle animation feature to the possession pitch visualization component. It is clear, specific, and matches the primary purpose of all changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/bar-battle-animation

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/features/possession/hooks/__tests__/usePossessionScoreSplashes.test.ts (1)

42-137: ⚡ Quick win

Add a test for the new non-MC myRound splash path.

Current updates only pass myRound: null; they don’t assert the new behavior added in the hook (roundResult + myRound, selectedAnswer === null). Please add one case to verify splash appears with myRound.pointsEarned > 0 and is deduped per qIndex.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/possession/hooks/__tests__/usePossessionScoreSplashes.test.ts`
around lines 42 - 137, Add a new test in usePossessionScoreSplashes.test.ts that
passes a non-null myRound (with myRound.pointsEarned > 0) and a non-null
roundResult into usePossessionScoreSplashes while selectedAnswer is null and
selectedAnswerQIndex set (e.g. 5), then assert showPlayerSplash is true and
playerSplashPoints equals the combination of roundResult and
myRound.pointsEarned; also verify deduping by calling the hook twice with the
same selectedAnswerQIndex (splash only once) and then with a different
selectedAnswerQIndex to confirm a new splash is produced. Ensure the new test
references usePossessionScoreSplashes, myRound, roundResult, selectedAnswer, and
selectedAnswerQIndex so reviewers can locate the relevant logic.
src/features/possession/components/BarBattleOverlay.tsx (1)

68-76: ⚡ Quick win

Use shared game typography token for score labels

Line 75 hardcodes Poppins. Prefer the shared font-fun style for in-game text consistency.

As per coding guidelines, src/{components,features,app}/**/*.{ts,tsx}: “Use font-fun class for game typography (Nunito font)”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/possession/components/BarBattleOverlay.tsx` around lines 68 -
76, The SVG text element in BarBattleOverlay (motion.text) hardcodes Poppins via
fontFamily; replace that with the shared game typography by removing the inline
fontFamily and applying the `font-fun` token (e.g. add className="font-fun" to
the motion.text) so score labels use the project's Nunito game font; keep the
other props (x, y, textAnchor, fill, fontSize, fontWeight, style) intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/features/possession/hooks/useBarBattle.ts`:
- Around line 125-131: The effect in useBarBattle.ts computes qIndex only from
roundResult or answerAck so when the opponent answers first qIndex can be null
and the effect returns early; update the hook signature (useBarBattle) to accept
a currentQuestionIndex param and use it as a final fallback when deriving qIndex
(e.g. qIndex = roundResult?.qIndex ?? answerAck?.qIndex ?? currentQuestionIndex
?? null) so the effect runs for opponent-first answers, and update all call
sites to pass the current question index into useBarBattle.
- Around line 31-35: BAR_BATTLE_TOTAL_MS is set too low relative to the declared
phase timings and MAX_BARS (12), causing orchestrator locks to expire early;
update BAR_BATTLE_TOTAL_MS so it reflects the true worst-case timeline (≈4700ms)
or replace the hardcoded export with a computed value derived from the per-phase
durations and MAX_BARS (eg. add fixed overhead + per-bar durations * MAX_BARS)
and export that computed value or a getter (reference symbols:
BAR_BATTLE_TOTAL_MS, MAX_BARS, POINTS_PER_BAR, useBarBattle) so
callers/orchestrator consume an accurate bound.

In `@src/features/possession/hooks/usePossessionScoreSplashes.ts`:
- Around line 90-105: This branch can display a splash for a stale question
because it never verifies roundResult.qIndex is the active question; add a guard
(similar to the MC path) that checks roundResult.qIndex matches the current
active question index/state before queuing the splash and updating
shownSplashQRef; update the useEffect to return early if roundResult.qIndex !==
<the component's active question index/state> (use the same variable used in the
MC path) so that setPlayerSplashVariant, setPlayerSplashPoints,
setShowPlayerSplash and shownSplashQRef.current.player only run for the current
question.

---

Nitpick comments:
In `@src/features/possession/components/BarBattleOverlay.tsx`:
- Around line 68-76: The SVG text element in BarBattleOverlay (motion.text)
hardcodes Poppins via fontFamily; replace that with the shared game typography
by removing the inline fontFamily and applying the `font-fun` token (e.g. add
className="font-fun" to the motion.text) so score labels use the project's
Nunito game font; keep the other props (x, y, textAnchor, fill, fontSize,
fontWeight, style) intact.

In `@src/features/possession/hooks/__tests__/usePossessionScoreSplashes.test.ts`:
- Around line 42-137: Add a new test in usePossessionScoreSplashes.test.ts that
passes a non-null myRound (with myRound.pointsEarned > 0) and a non-null
roundResult into usePossessionScoreSplashes while selectedAnswer is null and
selectedAnswerQIndex set (e.g. 5), then assert showPlayerSplash is true and
playerSplashPoints equals the combination of roundResult and
myRound.pointsEarned; also verify deduping by calling the hook twice with the
same selectedAnswerQIndex (splash only once) and then with a different
selectedAnswerQIndex to confirm a new splash is produced. Ensure the new test
references usePossessionScoreSplashes, myRound, roundResult, selectedAnswer, and
selectedAnswerQIndex so reviewers can locate the relevant logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 65bf5b3d-03da-4c27-a9a7-a77abeb6890e

📥 Commits

Reviewing files that changed from the base of the PR and between 7a54fba and dc8945f.

📒 Files selected for processing (7)
  • src/features/possession/components/BarBattleOverlay.tsx
  • src/features/possession/components/PitchVisualization.tsx
  • src/features/possession/hooks/__tests__/usePossessionScoreSplashes.test.ts
  • src/features/possession/hooks/useBarBattle.ts
  • src/features/possession/hooks/usePossessionScoreSplashes.ts
  • src/features/possession/hooks/useRealtimePossessionMatchController.ts
  • src/features/possession/realtimePossession.helpers.ts

Comment on lines +31 to +35
/** Total time from convert start. Export so orchestrator can size its lock window. */
export const BAR_BATTLE_TOTAL_MS = 2800; // Conservative upper bound

const POINTS_PER_BAR = 10;
const MAX_BARS = 12;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

BAR_BATTLE_TOTAL_MS is below the configured worst-case timeline

Line 32 sets 2800ms, but the declared phase timings with MAX_BARS = 12 can exceed that by a lot (~4.7s end-to-end). Any orchestrator lock using this bound can release early and desync/clip the animation.

Suggested fix
-/** Total time from convert start. Export so orchestrator can size its lock window. */
-export const BAR_BATTLE_TOTAL_MS = 2800; // Conservative upper bound
-
 const POINTS_PER_BAR = 10;
 const MAX_BARS = 12;
+
+/** Worst-case total timeline (includes hold/cleanup). */
+export const BAR_BATTLE_TOTAL_MS =
+  BOTH_SCORE_HOLD_MS +
+  CONVERT_DURATION +
+  (BARS_SPAWN_BASE_MS + MAX_BARS * BARS_PER_STAGGER_MS) +
+  (BATTLE_BASE_MS + MAX_BARS * BATTLE_PER_BAR_MS) +
+  RESULT_HOLD_MS +
+  DONE_LINGER_MS;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Total time from convert start. Export so orchestrator can size its lock window. */
export const BAR_BATTLE_TOTAL_MS = 2800; // Conservative upper bound
const POINTS_PER_BAR = 10;
const MAX_BARS = 12;
const POINTS_PER_BAR = 10;
const MAX_BARS = 12;
/** Worst-case total timeline (includes hold/cleanup). */
export const BAR_BATTLE_TOTAL_MS =
BOTH_SCORE_HOLD_MS +
CONVERT_DURATION +
(BARS_SPAWN_BASE_MS + MAX_BARS * BARS_PER_STAGGER_MS) +
(BATTLE_BASE_MS + MAX_BARS * BATTLE_PER_BAR_MS) +
RESULT_HOLD_MS +
DONE_LINGER_MS;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/possession/hooks/useBarBattle.ts` around lines 31 - 35,
BAR_BATTLE_TOTAL_MS is set too low relative to the declared phase timings and
MAX_BARS (12), causing orchestrator locks to expire early; update
BAR_BATTLE_TOTAL_MS so it reflects the true worst-case timeline (≈4700ms) or
replace the hardcoded export with a computed value derived from the per-phase
durations and MAX_BARS (eg. add fixed overhead + per-bar durations * MAX_BARS)
and export that computed value or a getter (reference symbols:
BAR_BATTLE_TOTAL_MS, MAX_BARS, POINTS_PER_BAR, useBarBattle) so
callers/orchestrator consume an accurate bound.

Comment on lines +125 to +131
useEffect(() => {
if (!opponentAnswered && !roundResult) return;

// Determine qIndex from whatever source is available
const qIndex = roundResult?.qIndex ?? answerAck?.qIndex ?? null;
if (qIndex === null) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Opponent-first answers can miss immediate score rendering

At Line 129, qIndex falls back only to roundResult/answerAck. If opponent answers first, both can be null, so the effect exits at Line 130 and opponent score text is delayed.

Pass current question index into the hook and use it as a fallback when deriving qIndex.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/possession/hooks/useBarBattle.ts` around lines 125 - 131, The
effect in useBarBattle.ts computes qIndex only from roundResult or answerAck so
when the opponent answers first qIndex can be null and the effect returns early;
update the hook signature (useBarBattle) to accept a currentQuestionIndex param
and use it as a final fallback when deriving qIndex (e.g. qIndex =
roundResult?.qIndex ?? answerAck?.qIndex ?? currentQuestionIndex ?? null) so the
effect runs for opponent-first answers, and update all call sites to pass the
current question index into useBarBattle.

Comment on lines +90 to +105
useEffect(() => {
if (!roundResult || !myRound) return;
const resolvedPhaseKind = roundResult.phaseKind ?? phaseKind;
if (resolvedPhaseKind !== 'normal' && resolvedPhaseKind !== 'last_attack') return;
if (shownSplashQRef.current.player === roundResult.qIndex) return;
// Only for non-MC: if selectedAnswer was set, the MC path above already handled it
if (selectedAnswer !== null) return;
if (!myRound.isCorrect || myRound.pointsEarned <= 0) return;

queueMicrotask(() => {
setPlayerSplashVariant('points');
setPlayerSplashPoints(myRound.pointsEarned);
setShowPlayerSplash(true);
});
shownSplashQRef.current.player = roundResult.qIndex;
}, [roundResult, myRound, phaseKind, selectedAnswer]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard non-MC splash against stale roundResult events.

Line [94]-[104] can show a player splash for an old question because this branch never verifies that roundResult.qIndex is still the active question (unlike the MC path). Late realtime packets can therefore animate points on the wrong round.

Suggested fix
   useEffect(() => {
     if (!roundResult || !myRound) return;
     const resolvedPhaseKind = roundResult.phaseKind ?? phaseKind;
     if (resolvedPhaseKind !== 'normal' && resolvedPhaseKind !== 'last_attack') return;
+    const activeQIndex = localQuestion?.qIndex ?? roundResult.qIndex;
+    if (activeQIndex !== roundResult.qIndex) return;
     if (shownSplashQRef.current.player === roundResult.qIndex) return;
     // Only for non-MC: if selectedAnswer was set, the MC path above already handled it
     if (selectedAnswer !== null) return;
     if (!myRound.isCorrect || myRound.pointsEarned <= 0) return;
@@
-  }, [roundResult, myRound, phaseKind, selectedAnswer]);
+  }, [localQuestion?.qIndex, roundResult, myRound, phaseKind, selectedAnswer]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/possession/hooks/usePossessionScoreSplashes.ts` around lines 90
- 105, This branch can display a splash for a stale question because it never
verifies roundResult.qIndex is the active question; add a guard (similar to the
MC path) that checks roundResult.qIndex matches the current active question
index/state before queuing the splash and updating shownSplashQRef; update the
useEffect to return early if roundResult.qIndex !== <the component's active
question index/state> (use the same variable used in the MC path) so that
setPlayerSplashVariant, setPlayerSplashPoints, setShowPlayerSplash and
shownSplashQRef.current.player only run for the current question.

@ntalakhadze ntalakhadze merged commit 8aeb104 into main May 12, 2026
3 checks passed
This was referenced May 16, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant