Skip to content

Refactor: extract components from monolithic App.tsx, fix 60fps re-render bug#545

Merged
pethers merged 4 commits intomainfrom
copilot/improve-game-experience
Apr 5, 2026
Merged

Refactor: extract components from monolithic App.tsx, fix 60fps re-render bug#545
pethers merged 4 commits intomainfrom
copilot/improve-game-experience

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 5, 2026

  • Analyze codebase architecture, tests, and code quality
  • Extract game components from monolithic App.tsx into separate files (ParticleExplosion, TargetSphere, BackgroundParticles, GameScene, HUD, GameOverlay)
  • Fix performance issue: remove setPulseScale state update inside useFrame (causes 60 re-renders/sec), use ref instead
  • Fix lint warnings (missing return types, function annotations)
  • Clean up duplicated CSS declarations in App.css
  • Add JSDoc documentation to extracted components
  • Add unit tests for extracted components to improve coverage (43 new tests)
  • Clean up vitest.config.ts (remove outdated PixiJS references)
  • Address PR review: TargetSphere always applies scale even when inactive
  • Address PR review: ParticleExplosion uses ref for visibility, prevents replay
  • Address PR review: Remove no-op vi.mock in HUD.test.tsx
  • Address PR review: Register test:targetClick listener once using refs

Copilot AI and others added 3 commits April 5, 2026 10:30
…ings

- Extract ParticleExplosion, TargetSphere, BackgroundParticles, GameScene,
  HUD, GameControls, GameInstructions, PauseOverlay, GameOverOverlay into
  src/components/ with barrel export
- Fix performance bug: replace setPulseScale useState in useFrame with ref
  (was causing 60 React re-renders/sec)
- Use delta time for TargetSphere rotation animations (frame-rate independent)
- Fix all lint warnings: add return types to useAudioManager callbacks
- Clean up duplicated CSS declarations in App.css
- Add JSDoc documentation to all extracted components

Agent-Logs-Url: https://github.com/Hack23/game/sessions/f008753c-31f7-4146-bbda-e6b53fa43687

Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
…Scene, BackgroundParticles)

- HUD: 10 tests covering timer, score, level, accuracy, combo, high score
- GameOverlay: 17 tests for GameControls, GameInstructions, PauseOverlay, GameOverOverlay
- GameScene: 6 tests for scene rendering with mocked Three.js
- BackgroundParticles: 1 test for sparkle configuration
- Fix lint errors in test files
- Total: 212 tests passing, 83.82% statement coverage

Agent-Logs-Url: https://github.com/Hack23/game/sessions/f008753c-31f7-4146-bbda-e6b53fa43687

Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
@pethers pethers requested a review from Copilot April 5, 2026 10:40
@pethers pethers marked this pull request as ready for review April 5, 2026 10:40
@github-actions github-actions Bot added infrastructure CI/CD and build infrastructure ui User interface improvements config Configuration changes testing Testing improvements test-coverage-impact Impacts test coverage needs-tests Needs test coverage component-app App component changes high-coverage High test coverage areas labels Apr 5, 2026
Comment thread src/components/TargetSphere.tsx Fixed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors the previously monolithic App.tsx into a set of focused UI and scene components, while addressing a 60fps re-render performance issue in the interactive target animation and expanding component-level test coverage.

Changes:

  • Extracted Three.js scene, HUD, overlays, and effects into dedicated src/components/* modules and updated App.tsx to orchestrate them.
  • Refactored target pulse animation to avoid React state updates inside useFrame (60fps re-render issue) and moved effects into component-local logic.
  • Updated Vitest configuration and added/expanded component tests for extracted UI/scene pieces.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
vitest.config.ts Removes Pixi-related config and focuses dependency optimization on Three/Fiber/Drei.
src/hooks/useAudioManager.ts Tightens callback typings (explicit param and return types).
src/components/TargetSphere.tsx New extracted interactive target component with ref-based animation updates.
src/components/ParticleExplosion.tsx New extracted particle explosion effect component driven by useFrame.
src/components/index.ts Barrel export for extracted components/constants.
src/components/HUD.tsx New extracted HUD overlay with derived accuracy.
src/components/HUD.test.tsx Adds HUD rendering/behavior tests.
src/components/GameScene.tsx New extracted scene graph (lighting, targets, shake, controls, effects).
src/components/GameScene.test.tsx Adds mocked rendering tests for scene composition.
src/components/GameOverlay.tsx New extracted controls/instructions/pause/game-over overlays.
src/components/GameOverlay.test.tsx Adds tests for overlay rendering and control callbacks.
src/components/BackgroundParticles.tsx New extracted ambient sparkles component.
src/components/BackgroundParticles.test.tsx Adds configuration test for background particles.
src/App.tsx Slimmed root component wiring together state, audio, overlays, and scene.
src/App.css Deduplicates and normalizes fullscreen/app layout styling.

Comment thread src/components/TargetSphere.tsx Outdated
Comment on lines +51 to +66
if (!isActive || !meshRef.current) return;

meshRef.current.rotation.y += delta * 1.2;
meshRef.current.rotation.x += delta * 0.6;

// Animate rings
if (outerRingRef.current) {
outerRingRef.current.rotation.z += delta * 0.9;
}
if (middleRingRef.current) {
middleRingRef.current.rotation.z -= delta * 1.2;
}

// Pulsing effect for hover state - use ref to avoid re-renders
if (hovered) {
pulseScaleRef.current = 1 + Math.sin(state.clock.elapsedTime * 5) * 0.1;
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

useFrame returns early when isActive is false, so the inactive scale branch (: 0.6) is never applied. If the game transitions from active → paused/game-over while the target remains mounted, the mesh can keep its last active scale/rotation state. Consider only gating the rotation/pulse logic on isActive, but always apply the target scale when meshRef.current exists (or set the inactive scale in an effect when isActive changes).

Suggested change
if (!isActive || !meshRef.current) return;
meshRef.current.rotation.y += delta * 1.2;
meshRef.current.rotation.x += delta * 0.6;
// Animate rings
if (outerRingRef.current) {
outerRingRef.current.rotation.z += delta * 0.9;
}
if (middleRingRef.current) {
middleRingRef.current.rotation.z -= delta * 1.2;
}
// Pulsing effect for hover state - use ref to avoid re-renders
if (hovered) {
pulseScaleRef.current = 1 + Math.sin(state.clock.elapsedTime * 5) * 0.1;
if (!meshRef.current) return;
if (isActive) {
meshRef.current.rotation.y += delta * 1.2;
meshRef.current.rotation.x += delta * 0.6;
// Animate rings
if (outerRingRef.current) {
outerRingRef.current.rotation.z += delta * 0.9;
}
if (middleRingRef.current) {
middleRingRef.current.rotation.z -= delta * 1.2;
}
// Pulsing effect for hover state - use ref to avoid re-renders
if (hovered) {
pulseScaleRef.current = 1 + Math.sin(state.clock.elapsedTime * 5) * 0.1;
} else {
pulseScaleRef.current = 1;
}

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +110
if (elapsed < duration) {
const scale = 1 + elapsed * EXPLOSION_SCALE_RATE;
particlesRef.current.scale.set(scale, scale, scale);

const geometry = particlesRef.current.geometry;
const positionAttr = geometry.attributes.position;

if (positionAttr?.array) {
const positions = positionAttr.array as Float32Array;

for (let i = 1; i < positions.length; i += 3) {
const current = positions[i];
if (current !== undefined) {
positions[i] = current + PARTICLE_RISE_SPEED;
}
}
positionAttr.needsUpdate = true;
}

const opacity = 1 - elapsed / duration;
(particlesRef.current.material as THREE.PointsMaterial).opacity = opacity;
} else {
setVisible(false);
startTimeRef.current = 0;
}
});

if (!visible && !active) return null;

Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

When the explosion duration ends, the code sets visible to false and resets startTimeRef to 0, but the component still renders as long as active is true (if (!visible && !active) return null). Because active stays true until App's timeout fires, this can cause the explosion to restart/replay for a few frames (or indefinitely if active remains true). Consider rendering null whenever visible is false, and/or stop the useFrame update loop when visible is false, and avoid resetting the start time in a way that re-triggers the animation while active is still true.

Copilot uses AI. Check for mistakes.
Comment thread src/components/HUD.test.tsx Outdated
Comment on lines +7 to +14
// Mock calculateAccuracy to control output
vi.mock("../utils/gameConfig", async () => {
const actual = await vi.importActual<typeof import("../utils/gameConfig")>("../utils/gameConfig");
return {
...actual,
};
});

Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

The test comment says "Mock calculateAccuracy to control output", but the mock currently just re-exports the real module without overriding anything. This adds noise and can mask accidental reliance on module side effects. Either remove this mock entirely or explicitly stub calculateAccuracy to the desired value(s).

Suggested change
// Mock calculateAccuracy to control output
vi.mock("../utils/gameConfig", async () => {
const actual = await vi.importActual<typeof import("../utils/gameConfig")>("../utils/gameConfig");
return {
...actual,
};
});

Copilot uses AI. Check for mistakes.
Comment thread src/App.tsx Outdated
Comment on lines 91 to 102
// Expose test API for E2E tests to trigger target clicks.
// This bypasses Three.js raycasting which doesn't work reliably in headless CI.
useEffect(() => {
const handleTestTargetClick = () => {
const handleTestTargetClick = (): void => {
if (gameState.isPlaying && gameState.timeLeft > 0 && gameState.targets.length > 0) {
handleTargetClick(gameState.targets[0]?.id ?? 0);
}
};

window.addEventListener('test:targetClick', handleTestTargetClick);
return () => window.removeEventListener('test:targetClick', handleTestTargetClick);
window.addEventListener("test:targetClick", handleTestTargetClick);
return (): void => window.removeEventListener("test:targetClick", handleTestTargetClick);
}, [handleTargetClick, gameState.isPlaying, gameState.timeLeft, gameState.targets]);
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

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

This useEffect re-registers the test:targetClick window event listener whenever handleTargetClick / gameState.targets change. Since targets are updated via requestAnimationFrame in useGameState, this can cause add/removeEventListener to run every frame while playing. Consider registering the listener once (empty deps) and using refs to read the latest gameState / handler inside the event callback.

Copilot uses AI. Check for mistakes.
@pethers
Copy link
Copy Markdown
Member

pethers commented Apr 5, 2026

@copilot apply changes based on the comments in this thread

… event listener

1. TargetSphere: always apply mesh scale even when inactive so the 0.6
   scale branch is correctly reached on active→inactive transitions
2. ParticleExplosion: use ref instead of useState for visibility, render
   null immediately when animation completes to prevent replay while
   parent's active prop timeout hasn't fired yet
3. HUD.test.tsx: remove no-op vi.mock that re-exported the real module
4. App.tsx: register test:targetClick listener once with empty deps,
   use refs for latest gameState/handler to avoid re-registration on
   every frame when targets change

Agent-Logs-Url: https://github.com/Hack23/game/sessions/c83b73ac-454e-42e5-a6db-a7b8192c55d2

Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 5, 2026

@copilot apply changes based on the comments in this thread

Applied all 4 review comments in fb07da4:

  1. TargetSphere: useFrame now only gates rotation/pulse logic on isActive, but always applies the target scale — so the 0.6 inactive scale is correctly set on active→paused/game-over transitions.
  2. ParticleExplosion: Replaced useState(visible) with a useRef, and render null as soon as the animation completes. This prevents the replay issue where active stays true from the parent's setTimeout. Also resets startTimeRef on completion for clean state.
  3. HUD.test.tsx: Removed the no-op vi.mock that just re-exported the real module.
  4. App.tsx: The test:targetClick listener is now registered once (empty deps) using refs (handleTargetClickRef, gameStateRef) to read the latest values, avoiding re-registration every frame when targets change.

Copilot AI requested a review from pethers April 5, 2026 11:02
@pethers pethers merged commit 5f73578 into main Apr 5, 2026
18 checks passed
@pethers pethers deleted the copilot/improve-game-experience branch April 5, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component-app App component changes config Configuration changes high-coverage High test coverage areas infrastructure CI/CD and build infrastructure needs-tests Needs test coverage test-coverage-impact Impacts test coverage testing Testing improvements ui User interface improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants