Resolve all 23 racing audit gaps: gameplay, polish, and infrastructure#3
Resolve all 23 racing audit gaps: gameplay, polish, and infrastructure#3
Conversation
Banana hazard system (#12), countdown traffic light (#13), speed slider tactile feedback (#14), rumble strips + reflective barriers (#15), live minimap with AI positions (#16), speed-scaled particles (#17), difficulty progression per lap (#18), procedural engine SFX (#19), post-race stats screen (#20), camera shake on impact (#21), lap change notification overlay (#22), position change alerts (#23). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Error boundaries for all routes, global error handler, PWA manifest and service worker, CI workflow, adaptive quality utility, audio manager, fashion game store, motion provider, and additional tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request delivers a significant upgrade to the racing game experience by integrating a suite of new gameplay mechanics, visual enhancements, and dynamic audio feedback. It also substantially improves the application's resilience and user experience through the implementation of robust error handling, progressive web app features for offline access, and an adaptive quality system to ensure optimal performance across various devices. Highlights
Changelog
Ignored Files
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces comprehensive updates to the racing game, including dynamic track elevation for AI and player trucks, new collision and banana slip mechanics with corresponding visual and sound effects, and an enhanced HUD with lap times, position changes, and a redesigned speed slider. It also adds a new procedural audio engine (RacingAudio class and useRacingAudio hook), implements new error boundary components for various routes, and sets up PWA features like a manifest, sitemap, robots.txt, offline page, and service worker. Review comments highlight the need to correct the service worker's manifest path, add more icon sizes to the PWA manifest for better user experience, and address the use of new Date() for lastModified in the sitemap to prevent unnecessary crawling. Additionally, a question was raised regarding the integration of the new RacingAudio class, as it appears to be unused in the current PR.
| const PRECACHE_URLS = [ | ||
| '/', | ||
| '/offline', | ||
| '/manifest.webmanifest', |
There was a problem hiding this comment.
The service worker is trying to pre-cache /manifest.webmanifest. However, Next.js's metadata files API typically generates this file as /manifest.json. Please verify the correct path for the generated manifest file and update it here to ensure it's cached correctly.
| '/manifest.webmanifest', | |
| '/manifest.json', |
| icons: [ | ||
| { | ||
| src: "/favicon.ico", | ||
| sizes: "48x48", | ||
| type: "image/x-icon", | ||
| }, | ||
| ], |
There was a problem hiding this comment.
The PWA manifest only defines a single 48x48 icon. For a better user experience across different devices and platforms, it's recommended to provide a range of icon sizes. Common sizes include 192x192 (for general use and splash screens) and 512x512 (for larger screens and app stores). You should also consider adding a maskable icon for better presentation on Android.
icons: [
{
src: "/favicon.ico",
sizes: "48x48",
type: "image/x-icon",
},
{
src: "/icon-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512x512.png",
sizes: "512x512",
type: "image/png",
},
],| { url: "/", lastModified: new Date(), changeFrequency: "weekly", priority: 1 }, | ||
| { url: "/race", lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 }, | ||
| { url: "/stadium", lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 }, | ||
| { url: "/fashion", lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 }, | ||
| { url: "/learn", lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 }, | ||
| { url: "/rewards", lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 }, | ||
| { url: "/settings", lastModified: new Date(), changeFrequency: "monthly", priority: 0.3 }, |
There was a problem hiding this comment.
Using new Date() for lastModified on every sitemap generation will signal to search engines that every page has been updated, even when they haven't. This can lead to unnecessary crawling and may not be an effective use of your crawl budget. It's better to use the actual last modification date of the content or a static date representing the last significant update. If you don't have a reliable way to get the actual modification date, it's often better to omit the lastModified field entirely.
| { url: "/", lastModified: new Date(), changeFrequency: "weekly", priority: 1 }, | |
| { url: "/race", lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 }, | |
| { url: "/stadium", lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 }, | |
| { url: "/fashion", lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 }, | |
| { url: "/learn", lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 }, | |
| { url: "/rewards", lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 }, | |
| { url: "/settings", lastModified: new Date(), changeFrequency: "monthly", priority: 0.3 }, | |
| { url: "/", changeFrequency: "weekly", priority: 1 }, | |
| { url: "/race", changeFrequency: "monthly", priority: 0.8 }, | |
| { url: "/stadium", changeFrequency: "monthly", priority: 0.8 }, | |
| { url: "/fashion", changeFrequency: "monthly", priority: 0.8 }, | |
| { url: "/learn", changeFrequency: "monthly", priority: 0.7 }, | |
| { url: "/rewards", changeFrequency: "monthly", priority: 0.5 }, | |
| { url: "/settings", changeFrequency: "monthly", priority: 0.3 }, |
| /** | ||
| * Procedural audio engine for the racing game using Web Audio API. | ||
| * Generates all sounds synthetically — no audio files required. | ||
| */ | ||
|
|
||
| export class RacingAudio { | ||
| private ctx: AudioContext | null = null; | ||
| private masterGain: GainNode | null = null; | ||
|
|
||
| // Engine sound nodes | ||
| private engineOsc: OscillatorNode | null = null; | ||
| private engineGain: GainNode | null = null; | ||
| private engineRunning = false; | ||
|
|
||
| // Settings | ||
| private sfxEnabled = true; | ||
|
|
||
| /** Lazily create AudioContext (must happen after user gesture) */ | ||
| private ensureContext(): AudioContext { | ||
| if (!this.ctx) { | ||
| this.ctx = new AudioContext(); | ||
| this.masterGain = this.ctx.createGain(); | ||
| this.masterGain.gain.value = 0.3; // keep overall volume kid-friendly | ||
| this.masterGain.connect(this.ctx.destination); | ||
| } | ||
| if (this.ctx.state === 'suspended') { | ||
| this.ctx.resume().catch(() => {}); | ||
| } | ||
| return this.ctx; | ||
| } | ||
|
|
||
| private get master(): GainNode { | ||
| this.ensureContext(); | ||
| return this.masterGain!; | ||
| } | ||
|
|
||
| // ── Settings ──────────────────────────────────────────── | ||
|
|
||
| setSfxEnabled(enabled: boolean): void { | ||
| this.sfxEnabled = enabled; | ||
| if (!enabled) this.stopEngine(); | ||
| } | ||
|
|
||
| // ── Engine sound ──────────────────────────────────────── | ||
|
|
||
| startEngine(): void { | ||
| if (this.engineRunning || !this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
|
|
||
| this.engineGain = ctx.createGain(); | ||
| this.engineGain.gain.value = 0.15; | ||
| this.engineGain.connect(this.master); | ||
|
|
||
| this.engineOsc = ctx.createOscillator(); | ||
| this.engineOsc.type = 'sawtooth'; | ||
| this.engineOsc.frequency.value = 60; | ||
| this.engineOsc.connect(this.engineGain); | ||
| this.engineOsc.start(); | ||
|
|
||
| this.engineRunning = true; | ||
| } | ||
|
|
||
| /** Update engine pitch/volume based on speed (0–1 normalized) */ | ||
| updateEngine(speedNorm: number): void { | ||
| if (!this.engineRunning || !this.engineOsc || !this.engineGain) return; | ||
| // Idle ~60Hz, full speed ~220Hz | ||
| this.engineOsc.frequency.value = 60 + speedNorm * 160; | ||
| // Louder when moving | ||
| this.engineGain.gain.value = 0.08 + speedNorm * 0.14; | ||
| } | ||
|
|
||
| stopEngine(): void { | ||
| if (!this.engineRunning) return; | ||
| try { | ||
| this.engineOsc?.stop(); | ||
| } catch { /* already stopped */ } | ||
| this.engineOsc?.disconnect(); | ||
| this.engineGain?.disconnect(); | ||
| this.engineOsc = null; | ||
| this.engineGain = null; | ||
| this.engineRunning = false; | ||
| } | ||
|
|
||
| // ── One-shot SFX ──────────────────────────────────────── | ||
|
|
||
| /** Short ascending chirp for coin collection */ | ||
| playCoinCollect(): void { | ||
| if (!this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
| const now = ctx.currentTime; | ||
|
|
||
| const osc = ctx.createOscillator(); | ||
| const gain = ctx.createGain(); | ||
| osc.type = 'sine'; | ||
| osc.frequency.setValueAtTime(880, now); | ||
| osc.frequency.linearRampToValueAtTime(1320, now + 0.08); | ||
| gain.gain.setValueAtTime(0.25, now); | ||
| gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15); | ||
|
|
||
| osc.connect(gain).connect(this.master); | ||
| osc.start(now); | ||
| osc.stop(now + 0.15); | ||
| } | ||
|
|
||
| /** Two-note jingle for item box pickup */ | ||
| playItemCollect(): void { | ||
| if (!this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
| const now = ctx.currentTime; | ||
|
|
||
| // Note 1 | ||
| const osc1 = ctx.createOscillator(); | ||
| const g1 = ctx.createGain(); | ||
| osc1.type = 'square'; | ||
| osc1.frequency.value = 523; // C5 | ||
| g1.gain.setValueAtTime(0.2, now); | ||
| g1.gain.exponentialRampToValueAtTime(0.001, now + 0.12); | ||
| osc1.connect(g1).connect(this.master); | ||
| osc1.start(now); | ||
| osc1.stop(now + 0.12); | ||
|
|
||
| // Note 2 (higher) | ||
| const osc2 = ctx.createOscillator(); | ||
| const g2 = ctx.createGain(); | ||
| osc2.type = 'square'; | ||
| osc2.frequency.value = 784; // G5 | ||
| g2.gain.setValueAtTime(0.2, now + 0.1); | ||
| g2.gain.exponentialRampToValueAtTime(0.001, now + 0.25); | ||
| osc2.connect(g2).connect(this.master); | ||
| osc2.start(now + 0.1); | ||
| osc2.stop(now + 0.25); | ||
| } | ||
|
|
||
| /** Swoosh/sweep for boost activation */ | ||
| playBoost(): void { | ||
| if (!this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
| const now = ctx.currentTime; | ||
|
|
||
| const osc = ctx.createOscillator(); | ||
| const gain = ctx.createGain(); | ||
| osc.type = 'sawtooth'; | ||
| osc.frequency.setValueAtTime(200, now); | ||
| osc.frequency.exponentialRampToValueAtTime(800, now + 0.15); | ||
| osc.frequency.exponentialRampToValueAtTime(400, now + 0.4); | ||
| gain.gain.setValueAtTime(0.18, now); | ||
| gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4); | ||
|
|
||
| osc.connect(gain).connect(this.master); | ||
| osc.start(now); | ||
| osc.stop(now + 0.4); | ||
| } | ||
|
|
||
| /** Countdown beep (high pitch for GO) */ | ||
| playCountdownBeep(isGo: boolean): void { | ||
| if (!this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
| const now = ctx.currentTime; | ||
|
|
||
| const osc = ctx.createOscillator(); | ||
| const gain = ctx.createGain(); | ||
| osc.type = 'sine'; | ||
| osc.frequency.value = isGo ? 880 : 440; | ||
| const duration = isGo ? 0.3 : 0.15; | ||
| gain.gain.setValueAtTime(0.3, now); | ||
| gain.gain.exponentialRampToValueAtTime(0.001, now + duration); | ||
|
|
||
| osc.connect(gain).connect(this.master); | ||
| osc.start(now); | ||
| osc.stop(now + duration); | ||
| } | ||
|
|
||
| /** Ascending scale for lap completion */ | ||
| playLapComplete(): void { | ||
| if (!this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
| const now = ctx.currentTime; | ||
|
|
||
| const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6 | ||
| notes.forEach((freq, i) => { | ||
| const osc = ctx.createOscillator(); | ||
| const gain = ctx.createGain(); | ||
| osc.type = 'sine'; | ||
| osc.frequency.value = freq; | ||
| const t = now + i * 0.1; | ||
| gain.gain.setValueAtTime(0.2, t); | ||
| gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2); | ||
| osc.connect(gain).connect(this.master); | ||
| osc.start(t); | ||
| osc.stop(t + 0.2); | ||
| }); | ||
| } | ||
|
|
||
| /** Victory fanfare for race finish */ | ||
| playRaceFinish(): void { | ||
| if (!this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
| const now = ctx.currentTime; | ||
|
|
||
| // Triumphant ascending arpeggio | ||
| const notes = [523, 659, 784, 1047, 1319, 1568]; // C5 E5 G5 C6 E6 G6 | ||
| notes.forEach((freq, i) => { | ||
| const osc = ctx.createOscillator(); | ||
| const gain = ctx.createGain(); | ||
| osc.type = i < 3 ? 'sine' : 'triangle'; | ||
| osc.frequency.value = freq; | ||
| const t = now + i * 0.12; | ||
| gain.gain.setValueAtTime(0.22, t); | ||
| gain.gain.exponentialRampToValueAtTime(0.001, t + 0.4); | ||
| osc.connect(gain).connect(this.master); | ||
| osc.start(t); | ||
| osc.stop(t + 0.4); | ||
| }); | ||
| } | ||
|
|
||
| /** Banana throw / hit sound */ | ||
| playBananaHit(): void { | ||
| if (!this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
| const now = ctx.currentTime; | ||
|
|
||
| // Descending slide (comic "slip") | ||
| const osc = ctx.createOscillator(); | ||
| const gain = ctx.createGain(); | ||
| osc.type = 'sine'; | ||
| osc.frequency.setValueAtTime(600, now); | ||
| osc.frequency.exponentialRampToValueAtTime(150, now + 0.3); | ||
| gain.gain.setValueAtTime(0.2, now); | ||
| gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35); | ||
|
|
||
| osc.connect(gain).connect(this.master); | ||
| osc.start(now); | ||
| osc.stop(now + 0.35); | ||
| } | ||
|
|
||
| /** Rising shimmer for shield activation */ | ||
| playShield(): void { | ||
| if (!this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
| const now = ctx.currentTime; | ||
|
|
||
| // Shimmering dual-tone | ||
| [523, 784].forEach((freq) => { | ||
| const osc = ctx.createOscillator(); | ||
| const gain = ctx.createGain(); | ||
| osc.type = 'triangle'; | ||
| osc.frequency.setValueAtTime(freq, now); | ||
| osc.frequency.linearRampToValueAtTime(freq * 1.5, now + 0.3); | ||
| gain.gain.setValueAtTime(0.18, now); | ||
| gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4); | ||
| osc.connect(gain).connect(this.master); | ||
| osc.start(now); | ||
| osc.stop(now + 0.4); | ||
| }); | ||
| } | ||
|
|
||
| /** Crackling zap for lightning strike */ | ||
| playLightning(): void { | ||
| if (!this.sfxEnabled) return; | ||
| const ctx = this.ensureContext(); | ||
| const now = ctx.currentTime; | ||
|
|
||
| // High-pitched zap descending rapidly | ||
| const osc = ctx.createOscillator(); | ||
| const gain = ctx.createGain(); | ||
| osc.type = 'square'; | ||
| osc.frequency.setValueAtTime(2000, now); | ||
| osc.frequency.exponentialRampToValueAtTime(100, now + 0.25); | ||
| gain.gain.setValueAtTime(0.15, now); | ||
| gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3); | ||
| osc.connect(gain).connect(this.master); | ||
| osc.start(now); | ||
| osc.stop(now + 0.3); | ||
|
|
||
| // Second crackle | ||
| const osc2 = ctx.createOscillator(); | ||
| const g2 = ctx.createGain(); | ||
| osc2.type = 'sawtooth'; | ||
| osc2.frequency.setValueAtTime(1500, now + 0.1); | ||
| osc2.frequency.exponentialRampToValueAtTime(80, now + 0.35); | ||
| g2.gain.setValueAtTime(0.12, now + 0.1); | ||
| g2.gain.exponentialRampToValueAtTime(0.001, now + 0.4); | ||
| osc2.connect(g2).connect(this.master); | ||
| osc2.start(now + 0.1); | ||
| osc2.stop(now + 0.4); | ||
| } | ||
|
|
||
| // ── Lifecycle ─────────────────────────────────────────── | ||
|
|
||
| destroy(): void { | ||
| this.stopEngine(); | ||
| if (this.ctx && this.ctx.state !== 'closed') { | ||
| this.ctx.close().catch(() => {}); | ||
| } | ||
| this.ctx = null; | ||
| this.masterGain = null; | ||
| } | ||
| } |
There was a problem hiding this comment.
This new RacingAudio class and the corresponding useRacingAudio hook seem to be a more robust, class-based implementation of the procedural audio logic found in the RaceSFX component. However, they don't appear to be used anywhere in this pull request; RaceGame3D still uses the RaceSFX component. While this is a good refactoring, including unused code can be confusing. Was the intention to replace RaceSFX with this new audio engine in this PR? If not, it might be better to introduce this in a separate PR where it's actually integrated.
Summary
Test plan
🤖 Generated with Claude Code