Skip to content

## Fix: Add FPS limit to prevent GPU overload and watchdog crashes on large maps#3462

Open
samisbakedham wants to merge 3 commits intoopenfrontio:mainfrom
samisbakedham:fix/fps-cap-watchdog-crash
Open

## Fix: Add FPS limit to prevent GPU overload and watchdog crashes on large maps#3462
samisbakedham wants to merge 3 commits intoopenfrontio:mainfrom
samisbakedham:fix/fps-cap-watchdog-crash

Conversation

@samisbakedham
Copy link
Member

Fix: Add FPS limit to prevent GPU overload and watchdog crashes on large maps

Closes #3461

Problem

The render loop called requestAnimationFrame unconditionally on every frame with no rate limiting. On high-refresh-rate displays and powerful GPUs (e.g. RTX 4060 with a 144Hz+ monitor), this could push render throughput to several hundred or even thousands of frames per second on large maps like Giant World Map.

On Windows, sustained GPU overload at this level can trigger a TDR (Timeout Detection and Recovery) event — the driver stops responding, Windows watchdog detects the hang, and the system forces a restart. This is what was reported in #3461.

This is a known Windows behavior: the GPU driver has a default ~2 second timeout, and if the GPU doesn't respond within that window (because it's saturated), the OS kills the driver and reboots.

Fix

Added a configurable FPS cap using a timestamp delta check at the top of renderGame(). If the time since the last rendered frame is less than the target frame interval, the frame is skipped and requestAnimationFrame reschedules for the next opportunity — keeping GPU load proportional to the cap rather than running unbounded.

Default is 60 FPS. Users can adjust via Settings.

Changes

  • src/core/game/UserSettings.ts — Added fpsLimit() (default: 60, 0 = uncapped) and setFpsLimit()
  • src/client/graphics/GameRenderer.ts — Added lastFrameTime field and FPS throttle logic at the top of renderGame(); wired UserSettings into the renderer
  • src/client/graphics/layers/SettingsModal.ts — Added FPS Limit slider to Settings UI with steps: 30 / 60 / 120 / 144 / 240 / Uncapped
  • resources/lang/en.json — Added fps_limit_label, fps_limit_desc, fps_limit_uncapped translation keys

Notes

  • Setting to Uncapped restores previous behavior exactly (0 = no throttle)
  • The throttle is checked before any rendering work, so skipped frames have near-zero cost
  • This does not affect game logic or tick rate — only the visual render loop
  • Other locales will fall back to the English strings until translated via Crowdin

…e maps (openfrontio#3461)

The render loop called requestAnimationFrame unconditionally with no rate
limiting. On high-refresh-rate displays and powerful GPUs, this could push
render throughput to hundreds or thousands of frames per second on large
maps like Giant World Map, triggering a Windows TDR (Timeout Detection and
Recovery) watchdog reset.

- Add configurable FPS limit in UserSettings (default 60, 0 = uncapped)
- Throttle renderGame() using timestamp delta check before rendering work
- Add FPS Limit slider to Settings UI (30/60/120/144/240/Uncapped)
- Add translation keys for fps_limit_label, fps_limit_desc, fps_limit_uncapped

Does not affect game logic or tick rate — render loop only.
@CLAassistant
Copy link

CLAassistant commented Mar 18, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 18, 2026

Walkthrough

The TerritoryLayer rendering now clips output to the visible viewport instead of blitting the entire map to the canvas. The source rectangle is computed from screen bounds, clamped to map limits, and only that visible region is drawn. Guards prevent rendering when the clipped area has zero dimensions.

Changes

Cohort / File(s) Summary
Viewport-clipped Territory Rendering
src/client/graphics/layers/TerritoryLayer.ts
Replaces full-map blitting with viewport-clipped drawing to reduce GPU load. Computes visible source rectangle from screen bounds clamped to map edges, draws only visible region, and skips rendering for invalid clip areas.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

Canvas remembers what eyes can see,
Clipping away the rest with care and glee, 🎬
Giant maps run smooth, no watchdog's wrath,
Just viewport math on the rendering path. ✨

🚥 Pre-merge checks | ❌ 5

❌ Failed checks (4 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title describes an FPS limit feature, but the code changes only implement viewport-clipped drawing for TerritoryLayer, which is unrelated to FPS limiting. Update the PR title to accurately reflect the actual change: 'Optimize TerritoryLayer rendering with viewport-clipped canvas blitting' or similar.
Description check ⚠️ Warning The PR description details FPS limiting and settings changes, but the actual code modifications only affect TerritoryLayer viewport clipping—a completely different optimization. Replace the description to match the viewport-clipping changes in TerritoryLayer, or update the code to implement the FPS-limiting features described.
Out of Scope Changes check ⚠️ Warning The viewport-clipped TerritoryLayer changes are in-scope for GPU optimization, but the PR description claims unimplemented changes (UserSettings, GameRenderer FPS throttle, SettingsModal slider, translations). Either implement all described FPS-limiting features or remove them from the description; ensure code changes match the documented scope exactly.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive Issue #3461 requires preventing GPU overload through FPS limiting or render optimization, but only viewport-clipped TerritoryLayer drawing is implemented—a partial architectural solution. Clarify whether viewport clipping alone solves the watchdog crash or if FPS limiting is also needed; provide missing UserSettings and GameRenderer changes or update issue scope.

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

📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Tip

CodeRabbit can use Trivy to scan for security misconfigurations and secrets in Infrastructure as Code files.

Add a .trivyignore file to your project to customize which findings Trivy reports.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/client/graphics/layers/SettingsModal.ts (1)

190-208: Use one shared FPS step list and clamp slider index on both ends.

The FPS step list is duplicated, and index clamping currently has only an upper bound. A shared constant plus Math.max/Math.min keeps behavior safe and easier to maintain.

♻️ Proposed refactor
+const FPS_LIMIT_STEPS = [30, 60, 120, 144, 240, 0] as const;
+
   private onFpsLimitChange(event: Event) {
-    const raw = parseInt((event.target as HTMLInputElement).value, 10);
-    // Slider steps: 30, 60, 120, 144, 240, 0 (uncapped)
-    const steps = [30, 60, 120, 144, 240, 0];
-    const value = steps[Math.min(raw, steps.length - 1)];
+    const raw = Number((event.target as HTMLInputElement).value);
+    const safeIndex = Math.max(
+      0,
+      Math.min(
+        Number.isFinite(raw) ? Math.trunc(raw) : 1,
+        FPS_LIMIT_STEPS.length - 1,
+      ),
+    );
+    const value = FPS_LIMIT_STEPS[safeIndex];
     this.userSettings.setFpsLimit(value);
     this.requestUpdate();
   }

   private fpsLimitSliderIndex(): number {
-    const steps = [30, 60, 120, 144, 240, 0];
-    const idx = steps.indexOf(this.userSettings.fpsLimit());
+    const idx = FPS_LIMIT_STEPS.indexOf(
+      this.userSettings.fpsLimit() as (typeof FPS_LIMIT_STEPS)[number],
+    );
     return idx === -1 ? 1 : idx; // default to 60 (index 1)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/SettingsModal.ts` around lines 190 - 208, Extract
the repeated FPS steps array into a single shared constant (e.g. FPS_STEPS) and
replace the inline arrays in onFpsLimitChange and fpsLimitSliderIndex with that
constant; in onFpsLimitChange compute the step index from the parsed input and
clamp it to the valid range using Math.max(0, Math.min(rawIndex,
FPS_STEPS.length - 1)) before mapping to the FPS value and calling
this.userSettings.setFpsLimit(value); in fpsLimitSliderIndex use
FPS_STEPS.indexOf(this.userSettings.fpsLimit()) and if index === -1 clamp to a
safe default with Math.max(0, Math.min(index, FPS_STEPS.length - 1)) (or return
a default index like 1) so both upper and lower bounds are enforced and the step
list is maintained in one place.
src/core/game/UserSettings.ts (1)

224-226: Normalize FPS values in the setter before storing.

fpsLimit() enforces the contract, but setFpsLimit() currently persists raw input. Normalizing in the setter keeps stored values stable and avoids odd values leaking into UI state.

♻️ Proposed setter normalization
   setFpsLimit(value: number): void {
-    this.setFloat("settings.fpsLimit", value);
+    if (!Number.isFinite(value)) {
+      this.setFloat("settings.fpsLimit", 60);
+      return;
+    }
+    if (value === 0) {
+      this.setFloat("settings.fpsLimit", 0);
+      return;
+    }
+    const normalized = Math.max(10, Math.min(360, Math.round(value)));
+    this.setFloat("settings.fpsLimit", normalized);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/game/UserSettings.ts` around lines 224 - 226, The setter setFpsLimit
currently writes raw input via this.setFloat("settings.fpsLimit", value); change
it to normalize the value using the same logic as the getter/validator
(fpsLimit) before storing — e.g. compute the normalized/clamped/rounded FPS by
calling the existing fpsLimit normalization logic (or reuse the fpsLimit
function) and then call this.setFloat("settings.fpsLimit", normalizedValue) so
only validated values are persisted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/graphics/GameRenderer.ts`:
- Around line 405-418: The render loop in GameRenderer.renderGame is calling
this.userSettings.fpsLimit() every frame, causing synchronous localStorage
reads; fix it by introducing a cached property (e.g., this.cachedFpsLimit) used
in the renderGame FPS check instead of calling userSettings.fpsLimit() each
frame, initialize it once when GameRenderer is constructed, and update it via
the existing settings change hook or an observer (subscribe to userSettings
change event and set this.cachedFpsLimit = this.userSettings.fpsLimit()) so
changes still take effect without per-frame storage access.

In `@src/client/graphics/layers/SettingsModal.ts`:
- Around line 521-550: Prettier formatting is failing CI for the SettingsModal
component; run the project Prettier formatter and save the fixed file (e.g., npx
prettier --write) to reformat src/client/graphics/layers/SettingsModal.ts, then
stage the updated file and commit; no code logic changes are needed—just
reformat the JSX/TSX block around the SettingsModal class and its elements
(fpsLimitSliderIndex, onFpsLimitChange, fpsLimitLabel) so the file passes
Prettier checks.

---

Nitpick comments:
In `@src/client/graphics/layers/SettingsModal.ts`:
- Around line 190-208: Extract the repeated FPS steps array into a single shared
constant (e.g. FPS_STEPS) and replace the inline arrays in onFpsLimitChange and
fpsLimitSliderIndex with that constant; in onFpsLimitChange compute the step
index from the parsed input and clamp it to the valid range using Math.max(0,
Math.min(rawIndex, FPS_STEPS.length - 1)) before mapping to the FPS value and
calling this.userSettings.setFpsLimit(value); in fpsLimitSliderIndex use
FPS_STEPS.indexOf(this.userSettings.fpsLimit()) and if index === -1 clamp to a
safe default with Math.max(0, Math.min(index, FPS_STEPS.length - 1)) (or return
a default index like 1) so both upper and lower bounds are enforced and the step
list is maintained in one place.

In `@src/core/game/UserSettings.ts`:
- Around line 224-226: The setter setFpsLimit currently writes raw input via
this.setFloat("settings.fpsLimit", value); change it to normalize the value
using the same logic as the getter/validator (fpsLimit) before storing — e.g.
compute the normalized/clamped/rounded FPS by calling the existing fpsLimit
normalization logic (or reuse the fpsLimit function) and then call
this.setFloat("settings.fpsLimit", normalizedValue) so only validated values are
persisted.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 38339edd-6953-4e3f-a2f1-1dd25a8c5f7c

📥 Commits

Reviewing files that changed from the base of the PR and between afdb04a and aa4b37c.

📒 Files selected for processing (4)
  • resources/lang/en.json
  • src/client/graphics/GameRenderer.ts
  • src/client/graphics/layers/SettingsModal.ts
  • src/core/game/UserSettings.ts

Comment on lines +521 to +550
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<img
src=${settingsIcon}
alt="fpsLimit"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.fps_limit_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.fps_limit_desc")}
</div>
<input
type="range"
min="0"
max="5"
step="1"
.value=${this.fpsLimitSliderIndex()}
@input=${this.onFpsLimitChange}
class="w-full border border-slate-500 rounded-lg mt-1"
/>
</div>
<div class="text-sm text-slate-400">
${this.fpsLimitLabel()}
</div>
</div>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Prettier check is currently blocking CI for this file.

Please run Prettier on src/client/graphics/layers/SettingsModal.ts before merge (npx prettier --write src/client/graphics/layers/SettingsModal.ts).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/SettingsModal.ts` around lines 521 - 550, Prettier
formatting is failing CI for the SettingsModal component; run the project
Prettier formatter and save the fixed file (e.g., npx prettier --write) to
reformat src/client/graphics/layers/SettingsModal.ts, then stage the updated
file and commit; no code logic changes are needed—just reformat the JSX/TSX
block around the SettingsModal class and its elements (fpsLimitSliderIndex,
onFpsLimitChange, fpsLimitLabel) so the file passes Prettier checks.

@github-project-automation github-project-automation bot moved this from Triage to Development in OpenFront Release Management Mar 18, 2026
@scamiv
Copy link
Contributor

scamiv commented Mar 18, 2026

https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame
raf is vsync, this is nonsense.

limiting fps MAY make sense for tdp limited cases, this is the wrong way to do it

…openfrontio#3461)

Previously, renderLayer() called drawImage() on the full map canvas every
frame regardless of how much of the map was visible. On large maps like
Giant World Map, this meant uploading and drawing a massive texture to the
GPU each frame even when only a small viewport region was visible.

Clip both the territory and highlight canvas drawImage calls to the visible
screen bounding rect (already computed for putImageData). This can reduce
GPU work per frame by an order of magnitude when zoomed in, which addresses
the sustained GPU load that triggers Windows TDR watchdog resets.
@samisbakedham
Copy link
Member Author

https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame raf is vsync, this is nonsense.

limiting fps MAY make sense for tdp limited cases, this is the wrong way to do it

Good call, thanks for catching that. You're right, rAF is already vsync-locked so the timestamp throttle was solving the wrong problem and added nothing meaningful.

The actual bottleneck is TerritoryLayer.renderLayer() calling drawImage() on the full map canvas every frame regardless of viewport. On Giant World Map that's a massive texture upload to the GPU per frame even when only a fraction of it is visible. Updated the fix to clip both drawImage calls (territory + highlight canvas) to the visible screen bounding rect, the same bounds already computed for putImageData. Also reverted the FPS cap changes entirely since they weren't addressing the root cause.

Should meaningfully reduce per-frame GPU work when zoomed in on large maps.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/client/graphics/layers/TerritoryLayer.ts (1)

496-503: Consider extracting the viewport clip rectangle calculation into one helper.

This same math is repeated near Line 468-475 and here. A shared helper would reduce drift risk (especially off-by-one behavior) and keep future tuning simpler.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/graphics/layers/TerritoryLayer.ts` around lines 496 - 503, The
viewport clipping rectangle calculation is duplicated; extract it into a single
helper (e.g., a private method on TerritoryLayer like computeViewportClip or a
utility function) that calls this.transformHandler.screenBoundingRect(), clamps
to 0..this.game.width()-1 and 0..this.game.height()-1, and returns
{cx0,cy0,cx1,cy1,cw,ch}; replace the duplicated blocks (the one around line
468-475 and the block using topLeft/bottomRight) to call the helper so
off-by-one logic is centralized and consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/client/graphics/layers/TerritoryLayer.ts`:
- Around line 496-503: The viewport clipping rectangle calculation is
duplicated; extract it into a single helper (e.g., a private method on
TerritoryLayer like computeViewportClip or a utility function) that calls
this.transformHandler.screenBoundingRect(), clamps to 0..this.game.width()-1 and
0..this.game.height()-1, and returns {cx0,cy0,cx1,cy1,cw,ch}; replace the
duplicated blocks (the one around line 468-475 and the block using
topLeft/bottomRight) to call the helper so off-by-one logic is centralized and
consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: feec720a-2906-453d-a494-93a3d9c19b23

📥 Commits

Reviewing files that changed from the base of the PR and between aa4b37c and d9ec13b.

📒 Files selected for processing (1)
  • src/client/graphics/layers/TerritoryLayer.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

While playing on Giant World Map, caused Windows 11 WatchDog to crash my PC.

3 participants