-
Notifications
You must be signed in to change notification settings - Fork 708
Sab #2519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Sab #2519
Conversation
Increase worker heartbeats per frame when far behind server to fast-forward simulation. Track backlog and expose catch-up status via TickMetricsEvent. Extend performance overlay to display backlog turns and indicate active catch-up mode.
- Refactor worker update handling into processPendingUpdates so multiple GameUpdateViewData objects are batched per frame. - Combine all tick updates in a batch into a single GameUpdateViewData before applying it to GameView, while still running per-tick side effects (turnComplete, hashes, backlog metrics, win saving). - Ensure layers using updatesSinceLastTick and recentlyUpdatedTiles see all events in a batch, fixing visual artifacts during fast-forward resync.
GameRunner exposes pending work via a new hasPendingTurns() so the worker can check whether more ticks need to be processed. Worker auto-runs ticks: as soon as it initializes or receives a new turn, it calls processPendingTurns() and loops executeNextTick() while hasPendingTurns() is true. No more "heartbeat" message type; the worker no longer depends on the main thread’s RAF loop to advance the simulation. Client main thread simplified: Removed CATCH_UP_HEARTBEATS_PER_FRAME, the heartbeat loop, and the lastBeatsPerFrame tracking. keepWorkerAlive now just manages frame skipping + draining. When it decides to render (based on renderEveryN), it drains pendingUpdates, merges them, updates GameView, and runs renderer.tick(). Because rendering a batch always implies draining, we restored the invariant that every GameView.update is paired with a layer tick() (no more lost incremental updates). MAX_RENDER_EVERY_N is now 5 to keep the queue from growing too large while the worker sprints.
removed: - catchUpMode and its CATCH_UP_ENTER/EXIT thresholds in ClientGameRunner - tick metrics fields and overlay UI for inCatchUpMode and beatsPerFrame - leftover worker heartbeat plumbing (message type + WorkerClient.sendHeartbeat) that was no longer used after self-clocking changed: - backlog tracking: keep serverTurnHighWater / lastProcessedTick / backlogTurns, but simplify it to just compute backlog and a backlogGrowing flag instead of driving a dedicated catch-up mode - frame skip: adaptRenderFrequency now only increases renderEveryN when backlog > 0 and still growing; when backlog is stable/shrinking or zero, it decays renderEveryN back toward 1 - render loop: uses the backlog-aware renderEveryN unconditionally (no catch-up flag), and resets skipping completely when backlog reaches 0 - metrics/overlay: TickMetricsEvent now carries backlogTurns and renderEveryN; the performance overlay displays backlog and current “render every N frames” but no longer mentions catch-up or heartbeats Learnings during branch development leading to this Once the worker self-clocks, a separate “catch-up mode” and beats-per-frame knob don’t add real control; they just complicate the model. Backlog is still a valuable signal, but it’s more effective as a quantitative input (backlog size and whether it’s growing) than as a boolean mode toggle. Frame skipping should be driven by actual backlog pressure plus frame cost: throttle only while backlog is growing and frames are heavy, and automatically relax back to full-rate rendering once the simulation catches up.
…ponsive. src/client/ClientGameRunner.ts now drains pending game updates in small chunks (max 100 updates or ~8ms per slice) via requestAnimationFrame, merging and rendering per slice, and only clears the processing flag when the queue is empty.
…log handling. Introduced dynamic slice budget scaling based on backlog size, allowing for longer processing times when necessary while maintaining UI responsiveness.
process updates in a single budgeted loop per RAF and render once track queue head with pendingStart and compact to avoid array shifts
- Refactor rendering and metrics emission in ClientGameRunner to ensure updates occur only after all processing is complete - Throttle renderGame() based on the current backlog
- Introduced new metrics in ClientGameRunner to track worker simulation ticks and render tick calls per second. - Updated TickMetricsEvent to include these new metrics. - Enhanced PerformanceOverlay to display worker and render ticks per second, improving performance monitoring capabilities. - Adjusted minimum FPS in GameRenderer
Added src/core/worker/SharedTileRing.ts, which defines a SharedArrayBuffer-backed ring buffer (SharedTileRingBuffers/SharedTileRingViews) and helpers pushTileUpdate (worker-side writer) and drainTileUpdates (main-thread reader) using Atomics. Extended GameRunner (src/core/GameRunner.ts) with an optional tileUpdateSink?: (update: bigint) => void; when provided, tile updates are sent to the sink instead of being packed into GameUpdateViewData.packedTileUpdates (those become an empty BigUint64Array in this mode). Extended the worker protocol (src/core/worker/WorkerMessages.ts) so the init message can optionally carry sharedTileRingHeader and sharedTileRingData (the two SABs for the ring). Updated WorkerClient (src/core/worker/WorkerClient.ts) to accept optional SharedTileRingBuffers in its constructor and, during initialize(), include those SABs in the init message sent to Worker.worker.ts. Updated the worker implementation (src/core/worker/Worker.worker.ts) to build sharedTileRing views from the SABs on "init" and pass a (update: bigint) => pushTileUpdate(...) sink into createGameRunner, so every tile update from the simulation goes into the shared ring instead of the message payload. Updated the client side (src/client/ClientGameRunner.ts): In createClientGame, when SharedArrayBuffer/Atomics are available and crossOriginIsolated is true, it allocates the ring (createSharedTileRingBuffers with capacity 262144), creates views, passes buffers into WorkerClient, and keeps the views in ClientGameRunner. ClientGameRunner now has a tileRingViews field and, in mergeGameUpdates, if the ring is present it ignores gu.packedTileUpdates and instead drains up to MAX_TILE_UPDATES_PER_RENDER (100 000) tile updates from the SAB ring into combinedPackedTileUpdates before constructing the merged GameUpdateViewData passed to GameView.update. If SABs are unavailable, it falls back to the original behaviour (packing gu.packedTileUpdates from messages), so the feature is opt-in and backward compatible. Net effect: when the page is cross-origin isolated, the worker no longer sends per-tick BigUint64Array tile payloads via postMessage; it streams tile updates into a shared ring buffer, and the main thread pulls and applies them at its own pace while still batching non-tile updates and rendering exactly as before. If you’d like, I can next run the existing tests/build in your environment to sanity-check everything.
- Share GameMapImpl tile state between worker and main via SharedArrayBuffer - Add SAB-backed tile update ring buffer to stream tile changes instead of postMessage payloads - Wire shared state/ring through WorkerClient, Worker.worker, GameRunner, and ClientGameRunner - Update GameView to skip updateTile when shared state is enabled and consume tile refs from the ring
Store only TileRef instead of packed tile+state values
This reverts commit 08a2ff9.
- Extend SharedTileRing to include a shared dirtyFlags buffer alongside header and data - Pass shared dirty buffer through WorkerClient/WorkerMessages and initialize views in Worker.worker - In SAB mode, mark tiles dirty via Atomics.compareExchange before enqueuing to ensure each tile is queued at most once until processed - On the main thread, clear dirty flags when draining the ring and build packedTileUpdates from distinct tile refs - Keep non-SAB behaviour unchanged while reducing ring pressure and making overflows reflect true backlog, not duplicate updates
WalkthroughImplements shared in-memory tile-ring buffers and threads them through worker and client, replaces heartbeat with explicit pending-turn processing in the worker, adds client-side batched update processing with backlog tracking/metrics, extends tick metrics and performance overlay with tile/ring stats, and adds backlog-aware renderer pacing. Changes
Sequence Diagram(s)sequenceDiagram
participant Main as Main Thread
participant Client as ClientGameRunner
participant Queue as pendingUpdates
participant Merge as mergeGameUpdates
participant View as GameView
participant TileRing as SharedTileRing
participant Render as Renderer
participant Metrics as TickMetricsEvent
Main->>Client: receive GameUpdate from worker
Client->>Queue: enqueue update
Note over Client: per-frame processing (processPendingUpdates, time-sliced)
Client->>Queue: dequeue batch
Queue->>Merge: merge updates -> combined update + tile metrics
Merge->>View: apply merged update (or push shared tile refs)
alt uses shared tile state
View->>TileRing: drain/ack tile updates
end
Client->>Metrics: emit TickMetricsEvent + BacklogStatusEvent
Client->>Render: request render if allowed
Render->>Render: check backlogTurns -> defer or render
sequenceDiagram
participant Worker as Worker
participant GameRun as GameRunner
participant TileRing as SharedTileRing
Note over Worker: after init or after processing a turn
Worker->>Worker: processPendingTurns()
loop while GameRun.hasPendingTurns()
Worker->>GameRun: execute turn
GameRun->>TileRing: pushTileUpdate(tileRef) (Atomics)
TileRing->>TileRing: write or advance read on overflow
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Areas needing extra attention:
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Pre-merge checks❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
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. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 6
🧹 Nitpick comments (3)
src/client/InputHandler.ts (1)
128-146: Consider an options object for cleaner API.The
TickMetricsEventconstructor now has 10 optional parameters. This works but can be hard to read at call sites. Consider using an options object:interface TickMetricsData { tickExecutionDuration?: number; tickDelay?: number; backlogTurns?: number; // ... etc } export class TickMetricsEvent implements GameEvent { constructor(public readonly data: Partial<TickMetricsData> = {}) {} }This makes call sites clearer and easier to extend. Not blocking, just a suggestion for maintainability.
src/core/GameRunner.ts (1)
183-196: Tile update routing looks correct.The branch logic cleanly separates the two paths: push to sink when available, otherwise pack into array. One minor note: creating
new BigUint64Array()on every tick when using the sink path allocates an empty typed array each time.Consider reusing a constant empty array:
+const EMPTY_TILE_UPDATES = new BigUint64Array(0); + // Many tiles are updated; either publish them via a shared sink or pack // them into the view data. let packedTileUpdates: BigUint64Array; const tileUpdates = updates[GameUpdateType.Tile]; if (this.tileUpdateSink !== undefined) { for (const u of tileUpdates) { const tileRef = Number(u.update >> 16n) as TileRef; this.tileUpdateSink(tileRef); } - packedTileUpdates = new BigUint64Array(); + packedTileUpdates = EMPTY_TILE_UPDATES; } else {src/client/ClientGameRunner.ts (1)
670-676: Type casting loses safety.The
as unknown as any[]pattern bypasses TypeScript's type system. Consider a more explicit approach:for (const key in gu.updates) { const type = Number(key) as GameUpdateType; - // We don't care about the specific update subtype here; just treat it - // as an array we can concatenate. - const updatesForType = gu.updates[type] as unknown as any[]; - (combinedUpdates[type] as unknown as any[]).push(...updatesForType); + const source = gu.updates[type]; + const target = combinedUpdates[type]; + for (let i = 0; i < source.length; i++) { + target.push(source[i]); + } }This avoids the double cast and spread operator overhead on large arrays.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
src/client/ClientGameRunner.ts(11 hunks)src/client/InputHandler.ts(1 hunks)src/client/graphics/GameRenderer.ts(4 hunks)src/client/graphics/layers/PerformanceOverlay.ts(6 hunks)src/core/GameRunner.ts(5 hunks)src/core/game/GameMap.ts(2 hunks)src/core/game/GameView.ts(3 hunks)src/core/game/TerrainMapLoader.ts(4 hunks)src/core/worker/SharedTileRing.ts(1 hunks)src/core/worker/Worker.worker.ts(4 hunks)src/core/worker/WorkerClient.ts(3 hunks)src/core/worker/WorkerMessages.ts(1 hunks)
🧰 Additional context used
🧠 Learnings (6)
📚 Learning: 2025-10-20T20:15:28.858Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:51-51
Timestamp: 2025-10-20T20:15:28.858Z
Learning: In src/core/execution/FakeHumanExecution.ts, game balance constants like MIRV_COOLDOWN_TICKS, MIRV_HESITATION_ODDS, VICTORY_DENIAL_TEAM_THRESHOLD, VICTORY_DENIAL_INDIVIDUAL_THRESHOLD, and STEAMROLL_CITY_GAP_MULTIPLIER are experimental tuning parameters subject to frequent change during balance testing. Do not flag changes to these values as issues or compare them against previous values.
Applied to files:
src/client/InputHandler.tssrc/core/GameRunner.tssrc/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-10-21T20:06:04.823Z
Learnt from: Saphereye
Repo: openfrontio/OpenFrontIO PR: 2233
File: src/client/HostLobbyModal.ts:891-891
Timestamp: 2025-10-21T20:06:04.823Z
Learning: For the HumansVsNations game mode in `src/client/HostLobbyModal.ts` and related files, the implementation strategy is to generate all nations and adjust their strength for balancing, rather than limiting lobby size based on the number of available nations on the map.
Applied to files:
src/core/game/TerrainMapLoader.ts
📚 Learning: 2025-10-08T17:14:49.369Z
Learnt from: Foorack
Repo: openfrontio/OpenFrontIO PR: 2141
File: src/client/ClientGameRunner.ts:228-234
Timestamp: 2025-10-08T17:14:49.369Z
Learning: In `ClientGameRunner.ts`, the `myPlayer` field is always set when `shouldPreventWindowClose()` is called, so the null check in that method is sufficient without needing to fetch it again from `gameView.playerByClientID()`.
Applied to files:
src/core/GameRunner.tssrc/client/ClientGameRunner.ts
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.
Applied to files:
src/client/ClientGameRunner.tssrc/client/graphics/GameRenderer.ts
📚 Learning: 2025-08-12T00:31:50.144Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 1752
File: src/core/game/Game.ts:750-752
Timestamp: 2025-08-12T00:31:50.144Z
Learning: In the OpenFrontIO codebase, changes to the PlayerInteraction interface (like adding canDonateGold and canDonateTroops flags) do not require corresponding updates to src/core/Schemas.ts or server serialization code.
Applied to files:
src/client/ClientGameRunner.ts
📚 Learning: 2025-10-18T11:00:57.142Z
Learnt from: NewYearNewPhil
Repo: openfrontio/OpenFrontIO PR: 2230
File: src/client/graphics/GameRenderer.ts:269-277
Timestamp: 2025-10-18T11:00:57.142Z
Learning: In src/client/graphics/GameRenderer.ts, the GameRecapCapture implementation does not use setCaptureRenderEnabled on layers. Instead, it uses RecapCaptureSurface.capture() to render capture layers (TerrainLayer, TerritoryLayer, RailroadLayer, StructureIconsLayer, UnitLayer) directly to an off-screen canvas without requiring layer-level capture mode methods.
Applied to files:
src/client/graphics/GameRenderer.ts
🧬 Code graph analysis (7)
src/client/InputHandler.ts (1)
src/core/EventBus.ts (1)
GameEvent(1-1)
src/core/game/TerrainMapLoader.ts (2)
src/core/game/GameImpl.ts (2)
map(197-199)miniMap(200-202)src/client/TerrainMapFileLoader.ts (1)
terrainMapFileLoader(4-4)
src/core/worker/SharedTileRing.ts (1)
src/core/game/GameMap.ts (1)
TileRef(3-3)
src/core/worker/Worker.worker.ts (3)
src/core/worker/SharedTileRing.ts (3)
SharedTileRingViews(9-14)createSharedTileRingViews(31-43)pushTileUpdate(45-64)src/core/GameRunner.ts (1)
createGameRunner(35-95)src/core/game/GameMap.ts (1)
TileRef(3-3)
src/core/GameRunner.ts (3)
src/core/game/GameView.ts (1)
tile(106-108)src/core/game/UnitImpl.ts (1)
tile(176-178)src/core/game/GameMap.ts (1)
TileRef(3-3)
src/core/game/GameMap.ts (3)
src/core/game/GameView.ts (2)
width(701-703)height(704-706)src/core/game/GameImpl.ts (2)
width(807-809)height(810-812)src/client/graphics/TransformHandler.ts (1)
width(48-50)
src/client/graphics/GameRenderer.ts (1)
src/client/InputHandler.ts (1)
BacklogStatusEvent(148-153)
🔇 Additional comments (23)
src/core/game/GameMap.ts (1)
78-103: Clean addition of shared state buffer support.The optional
stateBufferparameter keeps backward compatibility. The length validation is correct, and creating aUint16Arrayview on the shared buffer is the right approach.One small note: since this buffer can be shared across threads (via
SharedArrayBuffer), callers must ensure proper synchronization when reading/writing state. The current design assumes the worker owns writes and the main thread reads, which should be fine.src/core/worker/WorkerMessages.ts (1)
34-42: Clean message type extension.The optional
SharedArrayBufferfields allow the worker to operate in either shared-memory mode or fallback mode. Good design for progressive enhancement.src/core/worker/WorkerClient.ts (2)
23-29: Good constructor extension for shared buffer support.The optional parameters keep backward compatibility. Storing them as private fields and forwarding to the worker init message is clean.
72-81: Initialization payload correctly wires shared buffers.The optional chaining on
sharedTileRingBuffers?.headerhandles the undefined case properly.src/client/InputHandler.ts (1)
148-153: Simple and clear event class.
BacklogStatusEventis straightforward with required fields. Good.src/client/graphics/GameRenderer.ts (2)
356-378: Good backlog-aware frame pacing implementation.The logic reduces render FPS linearly from 60 to 10 as backlog grows from 0 to 50 turns. This allows the simulation to catch up while still showing progress. The early return with
requestAnimationFramekeeps the loop alive.One edge case: if
lastRenderTimeis 0 (first frame), the check on line 368 ensures we skip throttling, which is correct.
316-321: Event subscription correctly updates state.The
BacklogStatusEventhandler updates internal state that drives the frame pacing logic.src/core/worker/Worker.worker.ts (2)
44-65: Solid reentrancy guard for turn processing.The
isProcessingTurnsflag prevents recursive calls. The try/finally ensures the flag is reset even on error.
90-99: Clever conditional for tile update sink.The three-way ternary handles:
- Ring + dirty flags: uses atomic compare-exchange for deduplication
- Ring only: direct push (no dedup)
- No ring: undefined (fallback mode)
The
Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0pattern is correct for single-writer "mark if not already marked" logic.src/client/graphics/layers/PerformanceOverlay.ts (3)
545-548: Verify overflow accumulation logic.Line 547 does
this.ringBufferOverflows += ringBufferOverflows. This assumes each call passes an increment (0 or 1), not a cumulative count. Make sure the caller (TickMetricsEventproducer) sends delta values, not totals. Otherwise the displayed count will grow incorrectly.
440-469: Good use of Lit reactive state for new metrics.All new fields use
@state()decorator, which triggers re-renders when values change. Clean approach.
326-333: Reset properly clears new tile metrics.All new fields are zeroed, maintaining consistency with the reset behavior.
src/core/GameRunner.ts (2)
40-48: LGTM! Clean extension of the factory function.The new optional parameters
tileUpdateSinkandsharedStateBufferare well-placed and properly forwarded. This allows callers to opt-in to shared memory features without breaking existing usage.
292-295: LGTM! Simple and clear method.The
hasPendingTurns()method provides a clean way to check if there are queued turns to process.src/core/game/TerrainMapLoader.ts (2)
73-80: stateBuffer only passed for Normal map size.The
stateBufferis passed togenTerrainFromBinonly forGameMapSize.Normal(line 78), not forGameMapSize.Compact(line 80). Verify this is intentional.
115-132: LGTM! Clean parameter addition.The optional
stateBufferparameter is properly forwarded toGameMapImpl.src/client/ClientGameRunner.ts (3)
173-203: LGTM! Good setup for shared buffer infrastructure.The cross-origin isolation check and conditional buffer creation is well-structured. The ring capacity scaling with world size (
numTiles) is a sensible choice.
500-569: Solid frame-pacing logic with adaptive budget.The dynamic slice budget based on backlog size is a good approach for catching up while keeping UI responsive. The constants are well-documented.
One small note: the array compaction at lines 562-569 could be optimized by using a deque or circular buffer pattern instead of
slice(), but for typical backlog sizes this should be fine.
745-756: LGTM! Clean backlog tracking.The metrics update and event emission for backlog status is straightforward and well-structured.
src/core/worker/SharedTileRing.ts (4)
3-14: LGTM! Clean interface definitions.The separation between raw
SharedArrayBufferhandles and typedViewsis a good pattern - it allows the buffers to be transferred between workers while views are created locally.
66-85: LGTM! Drain implementation is straightforward.The drain loop correctly advances the read pointer atomically after processing. The
maxItemslimit prevents unbounded draining.
21-29: I'll help you verify the review comment aboutBacklogStatusEvent. Let me search the codebase to understand the current state of this event definition and its imports.
<function_calls>
shell
#!/bin/bashSearch for BacklogStatusEvent definition and usage
rg -n "BacklogStatusEvent" --type=ts -A3 -B3
</function_calls>Now let me check the ClientGameRunner.ts file specifically at the mentioned import line:
<function_calls>
shell
#!/bin/bashFind ClientGameRunner.ts and check imports around line 39
fd -n "ClientGameRunner.ts" --type=f | head -1 | xargs -I {} sh -c 'echo "File: {}"; head -50 {} | tail -15'
</function_calls>Let me also check the InputHandler file to see what it exports:
<function_calls>
shell
#!/bin/bashFind InputHandler file and check exports
fd -n "InputHandler" --type=f -e ts | head -5
</function_calls>
45-64: Since the repository clone is failing, I'll analyze the concern based on the code snippet provided and my understanding of concurrent ring buffer patterns.Producer-side overflow handling does create a race condition in concurrent scenarios.
Analyzing the code snippet:
- Lines 52-54: Read both
writeandreadpointers atomically (independent loads)- Lines 58-59: If buffer is full, the producer advances the
readpointer by storing toTILE_RING_HEADER_READ_INDEX- Line 60: Producer writes the value
- Line 61: Producer advances
writepointerThe race condition described is valid: if the consumer is simultaneously reading and advancing
read, and the producer also advancesreadduring overflow, they can interfere with each other's view of the buffer state. This is a known issue with producer-side overflow handling in lock-free ring buffers.However, without access to:
- How
pushTileUpdateis actually called (single producer vs. multiple producers)- The consumer implementation
- Any documentation or tests
- Whether there are additional synchronization mechanisms in place
I cannot definitively determine whether this is an actual problem or if the SPSC assumption is correctly maintained in practice.
Producer-side overflow handling can cause entries to be skipped if producer and consumer race during overflow, as described. However, verification requires examining actual usage patterns, consumer implementation, and any synchronization guarantees in the codebase.
Without access to the repository to verify the usage patterns and consumer side implementation, I cannot confirm whether the SPSC model is enforced or if alternative synchronization exists. The theoretical concern is valid, but practical impact depends on usage context.
| <div class="performance-line"> | ||
| Worker ticks/s: | ||
| <span>${this.workerTicksPerSecond.toFixed(1)}</span> | ||
| </div> | ||
| <div class="performance-line"> | ||
| Render ticks/s: | ||
| <span>${this.renderTicksPerSecond.toFixed(1)}</span> | ||
| </div> | ||
| <div class="performance-line"> | ||
| Ticks per render: | ||
| <span>${this.ticksPerRender}</span> | ||
| </div> | ||
| <div class="performance-line"> | ||
| Backlog turns: | ||
| <span>${this.backlogTurns}</span> | ||
| </div> | ||
| <div class="performance-line"> | ||
| Tile updates/render: | ||
| <span>${this.tileUpdatesPerRender}</span> | ||
| (peak: <span>${this.tileUpdatesPeak}</span>) | ||
| </div> | ||
| <div class="performance-line"> | ||
| Ring buffer: | ||
| <span>${this.ringBufferUtilization}%</span> | ||
| (${this.totalTilesUpdated} total, ${this.ringBufferOverflows} | ||
| overflows) | ||
| </div> | ||
| <div class="performance-line"> | ||
| Ring drain time: | ||
| <span>${this.ringDrainTime.toFixed(2)}ms</span> | ||
| </div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing translations for new metric labels.
The new performance lines use hardcoded English strings like "Worker ticks/s:", "Backlog turns:", etc. Other labels in this file use translateText() for localization.
Consider adding translation keys:
<div class="performance-line">
- Worker ticks/s:
+ ${translateText("performance_overlay.worker_ticks")}
<span>${this.workerTicksPerSecond.toFixed(1)}</span>
</div>This keeps consistency with the rest of the overlay.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/client/graphics/layers/PerformanceOverlay.ts around lines 707 to 737,
several new performance label strings are hardcoded in English (e.g., "Worker
ticks/s:", "Render ticks/s:", "Ticks per render:", "Backlog turns:", "Tile
updates/render:", "Ring buffer:", "Ring drain time:") instead of using the
existing translateText() localization helper; replace each hardcoded label with
a call to translateText() using appropriate translation keys (add new keys to
the i18n resource files if needed), keep any punctuation/formatting the same,
and ensure the translated strings are used consistently with surrounding labels
so the overlay supports localization.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (1)
src/client/graphics/layers/PerformanceOverlay.ts (1)
707-737: Missing translations for new metric labels.These new performance lines use hardcoded English strings instead of
translateText(), breaking localization consistency with the rest of the overlay.As noted in the previous review, wrap each label with
translateText():<div class="performance-line"> - Worker ticks/s: + ${translateText("performance_overlay.worker_ticks_per_second")} <span>${this.workerTicksPerSecond.toFixed(1)}</span> </div>Apply similar changes to all new labels: "Render ticks/s:", "Ticks per render:", "Backlog turns:", "Tile updates/render:", "Ring buffer:", and "Ring drain time:". Remember to add the corresponding keys to
en.json.Based on learnings, translation keys should be added to
en.jsononly; other locale files will be updated by dedicated translation PRs.
🧹 Nitpick comments (1)
src/client/graphics/layers/PerformanceOverlay.ts (1)
326-332: Consider resetting all new metrics for consistency.The reset clears tile/ring metrics but not
backlogTurns,ticksPerRender,workerTicksPerSecond, andrenderTicksPerSecond. For consistency, consider resetting these as well:// reset tile metrics this.tileUpdatesPerRender = 0; this.tileUpdatesPeak = 0; this.ringBufferUtilization = 0; this.ringBufferOverflows = 0; this.ringDrainTime = 0; this.totalTilesUpdated = 0; + + // reset tick/backlog metrics + this.backlogTurns = 0; + this.ticksPerRender = 0; + this.workerTicksPerSecond = 0; + this.renderTicksPerSecond = 0;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
src/client/graphics/layers/PerformanceOverlay.ts(6 hunks)src/core/game/TerrainMapLoader.ts(4 hunks)
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-06-02T14:27:37.609Z
Learnt from: andrewNiziolek
Repo: openfrontio/OpenFrontIO PR: 1007
File: resources/lang/de.json:115-115
Timestamp: 2025-06-02T14:27:37.609Z
Learning: For OpenFrontIO project: When localization keys are renamed in language JSON files, the maintainers separate technical changes from translation content updates. They wait for community translators to update the actual translation values rather than attempting to translate in the same PR. This allows technical changes to proceed while ensuring accurate translations from native speakers.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-08-16T10:52:08.292Z
Learnt from: TheGiraffe3
Repo: openfrontio/OpenFrontIO PR: 884
File: resources/lang/en.json:456-461
Timestamp: 2025-08-16T10:52:08.292Z
Learning: In OpenFrontIO, translation files in resources/lang/*.json (except en.json) should not be updated in regular PRs. Only dedicated translation PRs titled "mls" and made by Aotumori should update non-English locale files. Regular PRs should only update en.json when adding or modifying translation keys.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-10-20T20:15:28.858Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:51-51
Timestamp: 2025-10-20T20:15:28.858Z
Learning: In src/core/execution/FakeHumanExecution.ts, game balance constants like MIRV_COOLDOWN_TICKS, MIRV_HESITATION_ODDS, VICTORY_DENIAL_TEAM_THRESHOLD, VICTORY_DENIAL_INDIVIDUAL_THRESHOLD, and STEAMROLL_CITY_GAP_MULTIPLIER are experimental tuning parameters subject to frequent change during balance testing. Do not flag changes to these values as issues or compare them against previous values.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-11-26T20:49:29.110Z
Learnt from: scamiv
Repo: openfrontio/OpenFrontIO PR: 2519
File: src/core/game/GameView.ts:516-525
Timestamp: 2025-11-26T20:49:29.110Z
Learning: In GameView.ts, when usesSharedTileState is true (SAB mode), packedTileUpdates contains unpacked tile references as BigInt(tileRef) only, because all tile state lives in the shared Uint16Array. In non-SAB mode, packedTileUpdates contains packed TileUpdate bigints in the format (tileRef << 16n | state), which must be decoded via updateTile(tu). Therefore, Number(tu) is correct in SAB mode and shifting right by 16 bits would be wrong.
Applied to files:
src/core/game/TerrainMapLoader.ts
📚 Learning: 2025-10-21T20:06:04.823Z
Learnt from: Saphereye
Repo: openfrontio/OpenFrontIO PR: 2233
File: src/client/HostLobbyModal.ts:891-891
Timestamp: 2025-10-21T20:06:04.823Z
Learning: For the HumansVsNations game mode in `src/client/HostLobbyModal.ts` and related files, the implementation strategy is to generate all nations and adjust their strength for balancing, rather than limiting lobby size based on the number of available nations on the map.
Applied to files:
src/core/game/TerrainMapLoader.ts
🧬 Code graph analysis (1)
src/core/game/TerrainMapLoader.ts (1)
src/client/TerrainMapFileLoader.ts (1)
terrainMapFileLoader(4-4)
🔇 Additional comments (11)
src/core/game/TerrainMapLoader.ts (7)
9-10: LGTM! Clean type extension.Adding optional SharedArrayBuffer fields to the type definition is backwards compatible and clearly supports the new SAB-based tile state sharing.
42-55: Clarify the caching strategy.The caching logic introduces three boolean flags (
useCache,canUseSharedBuffers,shouldUseCache) to determine when to cache. The behavior is:
- Cache is skipped when
sharedStateBufferis provided (expected)- Cache is also skipped when
canUseSharedBuffersis true, even if no buffer was providedThis means when SharedArrayBuffer is available, each call without a provided buffer will create a new SharedArrayBuffer instead of reusing a cached one. If this prevents inappropriate state sharing across game instances, it's correct—but if callers are expected to reuse the same map multiple times, this could be inefficient.
Add a comment explaining why caching is disabled when
canUseSharedBuffersis true, or verify this behavior is intentional:// Don't use cache if we can create SharedArrayBuffer but none was provided // This prevents sharing state buffers across different game instances const shouldUseCache = useCache && !canUseSharedBuffers;If callers should reuse the same SAB-backed map, consider documenting that they must pass the same
sharedStateBufferon subsequent calls.
59-67: LGTM! StateBuffer creation is well-structured.The logic cleanly handles three cases: use provided buffer, create new SAB if possible, or use undefined. The size calculation correctly allocates 2 bytes per tile for Uint16 state.
71-75: LGTM! Proper stateBuffer forwarding.The
stateBufferis correctly passed only to the main game map whenmapSize === Normal. The 4x and mini maps don't receive shared state buffers, which is appropriate since they're likely used only for preview/display purposes.
95-109: LGTM! Cache consistency is now correct.The result object properly includes
sharedStateBufferwhen a SharedArrayBuffer was used, and the cache write (lines 107-109) is now gated byshouldUseCache, matching the cache read logic. This addresses the previous issue where cache was written but not read consistently.The
typeof SharedArrayBuffer !== "undefined"check on line 100 is necessary for environments where SharedArrayBuffer might not be defined at all.
116-116: LGTM! Type choice is appropriately flexible.Using
ArrayBufferLikefor thestateBufferparameter allows bothArrayBufferandSharedArrayBuffer, which gives flexibility while remaining type-safe. The optional parameter maintains backwards compatibility.
129-129: LGTM! Clean parameter forwarding.The
stateBufferis correctly forwarded to theGameMapImplconstructor without unnecessary transformation.src/client/graphics/layers/PerformanceOverlay.ts (4)
232-243: LGTM! Event forwarding is clean.The expanded TickMetricsEvent parameters are correctly forwarded to updateTickMetrics.
440-468: LGTM! State declarations follow conventions.The new metric fields are properly declared with
@state()decorators and appropriate types.
470-481: LGTM! Signature is backward-compatible.All new parameters are optional, maintaining compatibility while extending functionality.
584-591: LGTM! Snapshot includes all tile metrics.The new
tilesblock properly captures all expanded tile and ring buffer metrics for JSON export.
Atomic-SAB ArchitectureShared Tile State ( Ring Header ( Ring Data ( Dirty Flags ( |
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (3)
src/client/graphics/layers/PerformanceOverlay.ts (2)
545-548: Overflow tracking acknowledged as temporary debug flag.Per past review discussion, this is intentionally a flag (not a counter) for debugging purposes and will be removed later.
707-737: Missing translations for new metric labels.Already flagged in previous review - these hardcoded English strings should use
translateText()for consistency with other labels in the overlay.src/client/ClientGameRunner.ts (1)
706-716: Overflow flag read but not reset - acknowledged as debug feature.Per past review discussion, the overflow flag being read but not cleared is intentional for debugging purposes.
🧹 Nitpick comments (6)
src/client/graphics/GameRenderer.ts (1)
356-378: Backlog-aware frame throttling looks correct.The logic scales target FPS linearly from 60 to 10 as backlog grows from 0 to 50 turns. When
backlogTurns > 0, rendering is skipped if the frame interval hasn't elapsed. Once backlog clears, rendering resumes at full speed.One small note:
BACKLOG_MAX_TURNS = 50is a magic number. Consider extracting this to a config constant if it needs tuning later.src/core/worker/Worker.worker.ts (1)
90-99: Simplify: second branch is unreachable dead code.Looking at lines 73-83: when
sharedTileRingis created,dirtyFlagsis always set fromsharedTileRing.dirtyFlags. So ifsharedTileRingis truthy,dirtyFlagsis also truthy. The second branch (lines 96-97) can never execute.gameRunner = createGameRunner( message.gameStartInfo, message.clientID, mapLoader, gameUpdate, - sharedTileRing && dirtyFlags - ? (tile: TileRef) => { - if (Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0) { - pushTileUpdate(sharedTileRing!, tile); - } - } - : sharedTileRing - ? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile) - : undefined, + sharedTileRing + ? (tile: TileRef) => { + if (Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0) { + pushTileUpdate(sharedTileRing!, tile); + } + } + : undefined, message.sharedStateBuffer, )src/client/graphics/layers/PerformanceOverlay.ts (1)
470-481: Consider using an options object for cleaner signature.With 10 optional parameters, the function signature is getting long. An options object would make call sites more readable and self-documenting.
- updateTickMetrics( - tickExecutionDuration?: number, - tickDelay?: number, - backlogTurns?: number, - ticksPerRender?: number, - workerTicksPerSecond?: number, - renderTicksPerSecond?: number, - tileUpdatesCount?: number, - ringBufferUtilization?: number, - ringBufferOverflows?: number, - ringDrainTime?: number, - ) { + updateTickMetrics(opts: { + tickExecutionDuration?: number; + tickDelay?: number; + backlogTurns?: number; + ticksPerRender?: number; + workerTicksPerSecond?: number; + renderTicksPerSecond?: number; + tileUpdatesCount?: number; + ringBufferUtilization?: number; + ringBufferOverflows?: number; + ringDrainTime?: number; + }) { + const { + tickExecutionDuration, + tickDelay, + backlogTurns, + // ... etc + } = opts;src/core/worker/SharedTileRing.ts (1)
66-85: Drain implementation is correct, but consider pre-sizing output array.The drain logic is sound. However, using
out.push()in a tight loop can trigger array reallocations. For performance with large drains, consider pre-computing count and using array index assignment:export function drainTileUpdates( views: SharedTileRingViews, maxItems: number, out: TileRef[], ): void { const { header, buffer, capacity } = views; let read = Atomics.load(header, TILE_RING_HEADER_READ_INDEX); const write = Atomics.load(header, TILE_RING_HEADER_WRITE_INDEX); - let count = 0; - - while (read !== write && count < maxItems) { - out.push(buffer[read]); - read = (read + 1) % capacity; - count++; - } + // Pre-compute available count + const available = (write - read + capacity) % capacity; + const toRead = Math.min(available, maxItems); + const startIndex = out.length; + out.length = startIndex + toRead; + + for (let i = 0; i < toRead; i++) { + out[startIndex + i] = buffer[read]; + read = (read + 1) % capacity; + } Atomics.store(header, TILE_RING_HEADER_READ_INDEX, read); }src/client/ClientGameRunner.ts (2)
571-625: Rendering only occurs when pending queue is fully drained.The condition on line 572 (
pendingStart >= pendingUpdates.length) means rendering happens only after ALL pending updates are processed. During heavy backlog, this delays visual feedback until catch-up is complete.Consider rendering after each time-slice while still showing stale state, or rendering periodically even with pending updates:
- // Only update view and render when ALL processing is complete - if ( - this.pendingStart >= this.pendingUpdates.length && - batch.length > 0 && - lastTick !== undefined - ) { + // Render after each slice to maintain visual responsiveness + if (batch.length > 0 && lastTick !== undefined) {This would provide smoother visual feedback during catch-up, showing intermediate game states rather than waiting for full sync.
669-677: Type assertion toany[]bypasses type safety.The cast to
unknown as any[]works but loses compile-time checks. Consider a typed helper:function appendUpdates<T>(target: T[], source: T[]): void { target.push(...source); }Then use it with explicit type for each
GameUpdateType. Low priority since this is internal.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
src/client/ClientGameRunner.ts(11 hunks)src/client/InputHandler.ts(1 hunks)src/client/graphics/GameRenderer.ts(4 hunks)src/client/graphics/layers/PerformanceOverlay.ts(6 hunks)src/core/GameRunner.ts(5 hunks)src/core/game/GameMap.ts(2 hunks)src/core/game/GameView.ts(3 hunks)src/core/game/TerrainMapLoader.ts(4 hunks)src/core/worker/SharedTileRing.ts(1 hunks)src/core/worker/Worker.worker.ts(4 hunks)src/core/worker/WorkerClient.ts(3 hunks)src/core/worker/WorkerMessages.ts(1 hunks)
🧰 Additional context used
🧠 Learnings (16)
📚 Learning: 2025-11-26T20:49:29.110Z
Learnt from: scamiv
Repo: openfrontio/OpenFrontIO PR: 2519
File: src/core/game/GameView.ts:516-525
Timestamp: 2025-11-26T20:49:29.110Z
Learning: In GameView.ts, when usesSharedTileState is true (SAB mode), packedTileUpdates contains unpacked tile references as BigInt(tileRef) only, because all tile state lives in the shared Uint16Array. In non-SAB mode, packedTileUpdates contains packed TileUpdate bigints in the format (tileRef << 16n | state), which must be decoded via updateTile(tu). Therefore, Number(tu) is correct in SAB mode and shifting right by 16 bits would be wrong.
Applied to files:
src/core/worker/WorkerClient.tssrc/core/GameRunner.tssrc/core/worker/SharedTileRing.tssrc/core/game/GameMap.tssrc/core/worker/Worker.worker.tssrc/client/ClientGameRunner.tssrc/core/game/GameView.tssrc/core/game/TerrainMapLoader.ts
📚 Learning: 2025-10-20T20:15:28.858Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:51-51
Timestamp: 2025-10-20T20:15:28.858Z
Learning: In src/core/execution/FakeHumanExecution.ts, game balance constants like MIRV_COOLDOWN_TICKS, MIRV_HESITATION_ODDS, VICTORY_DENIAL_TEAM_THRESHOLD, VICTORY_DENIAL_INDIVIDUAL_THRESHOLD, and STEAMROLL_CITY_GAP_MULTIPLIER are experimental tuning parameters subject to frequent change during balance testing. Do not flag changes to these values as issues or compare them against previous values.
Applied to files:
src/client/InputHandler.tssrc/core/GameRunner.tssrc/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-10-08T17:14:49.369Z
Learnt from: Foorack
Repo: openfrontio/OpenFrontIO PR: 2141
File: src/client/ClientGameRunner.ts:228-234
Timestamp: 2025-10-08T17:14:49.369Z
Learning: In `ClientGameRunner.ts`, the `myPlayer` field is always set when `shouldPreventWindowClose()` is called, so the null check in that method is sufficient without needing to fetch it again from `gameView.playerByClientID()`.
Applied to files:
src/core/GameRunner.tssrc/client/ClientGameRunner.ts
📚 Learning: 2025-06-02T14:27:37.609Z
Learnt from: andrewNiziolek
Repo: openfrontio/OpenFrontIO PR: 1007
File: resources/lang/de.json:115-115
Timestamp: 2025-06-02T14:27:37.609Z
Learning: For OpenFrontIO project: When localization keys are renamed in language JSON files, the maintainers separate technical changes from translation content updates. They wait for community translators to update the actual translation values rather than attempting to translate in the same PR. This allows technical changes to proceed while ensuring accurate translations from native speakers.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-08-16T10:52:08.292Z
Learnt from: TheGiraffe3
Repo: openfrontio/OpenFrontIO PR: 884
File: resources/lang/en.json:456-461
Timestamp: 2025-08-16T10:52:08.292Z
Learning: In OpenFrontIO, translation files in resources/lang/*.json (except en.json) should not be updated in regular PRs. Only dedicated translation PRs titled "mls" and made by Aotumori should update non-English locale files. Regular PRs should only update en.json when adding or modifying translation keys.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-10-20T11:02:16.969Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:57-57
Timestamp: 2025-10-20T11:02:16.969Z
Learning: In src/core/execution/FakeHumanExecution.ts, the correct MIRV victory denial thresholds are VICTORY_DENIAL_TEAM_THRESHOLD = 0.8 (80% for team games) and VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65 (65% for individual players), not 0.85 and 0.7 as might be mentioned in some documentation.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-10-27T09:47:26.395Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:770-795
Timestamp: 2025-10-27T09:47:26.395Z
Learning: In src/core/execution/FakeHumanExecution.ts, the selectSteamrollStopTarget() method intentionally allows MIRV targeting when secondHighest city count is 0 (e.g., nuclear endgame scenarios where structures are destroyed). This is valid game design—"if you can afford it, you're good to go"—and should not be flagged as requiring a guard condition.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-10-18T17:54:01.311Z
Learnt from: sambokai
Repo: openfrontio/OpenFrontIO PR: 2225
File: src/core/execution/FakeHumanExecution.ts:172-173
Timestamp: 2025-10-18T17:54:01.311Z
Learning: In src/core/execution/FakeHumanExecution.ts, MIRVs and ground attacks should not be mutually exclusive. The considerMIRV() method should not short-circuit maybeAttack() - both actions can occur in the same tick.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-06-22T05:48:19.241Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 786
File: src/client/TerritoryPatternsModal.ts:337-338
Timestamp: 2025-06-22T05:48:19.241Z
Learning: In src/client/TerritoryPatternsModal.ts, the bit shifting operators (<<) used in coordinate calculations with decoder.getScale() are intentional and should not be changed to multiplication. The user scottanderson confirmed this is functioning as intended.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.tssrc/core/game/GameView.ts
📚 Learning: 2025-08-12T00:31:50.144Z
Learnt from: scottanderson
Repo: openfrontio/OpenFrontIO PR: 1752
File: src/core/game/Game.ts:750-752
Timestamp: 2025-08-12T00:31:50.144Z
Learning: In the OpenFrontIO codebase, changes to the PlayerInteraction interface (like adding canDonateGold and canDonateTroops flags) do not require corresponding updates to src/core/Schemas.ts or server serialization code.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.tssrc/client/ClientGameRunner.ts
📚 Learning: 2025-10-08T17:14:49.369Z
Learnt from: Foorack
Repo: openfrontio/OpenFrontIO PR: 2141
File: src/client/ClientGameRunner.ts:228-234
Timestamp: 2025-10-08T17:14:49.369Z
Learning: For the window close confirmation feature in `ClientGameRunner.ts`, the troop count requirement (>10,000 troops) from issue #2137 was intentionally removed because it was arbitrary and troop count can be reported as low despite having significant land. The confirmation now triggers for any alive player regardless of troop count.
Applied to files:
src/client/graphics/layers/PerformanceOverlay.ts
📚 Learning: 2025-10-18T11:00:57.142Z
Learnt from: NewYearNewPhil
Repo: openfrontio/OpenFrontIO PR: 2230
File: src/client/graphics/GameRenderer.ts:269-277
Timestamp: 2025-10-18T11:00:57.142Z
Learning: In src/client/graphics/GameRenderer.ts, the GameRecapCapture implementation does not use setCaptureRenderEnabled on layers. Instead, it uses RecapCaptureSurface.capture() to render capture layers (TerrainLayer, TerritoryLayer, RailroadLayer, StructureIconsLayer, UnitLayer) directly to an off-screen canvas without requiring layer-level capture mode methods.
Applied to files:
src/client/graphics/GameRenderer.ts
📚 Learning: 2025-06-09T02:20:43.637Z
Learnt from: VariableVince
Repo: openfrontio/OpenFrontIO PR: 1110
File: src/client/Main.ts:293-295
Timestamp: 2025-06-09T02:20:43.637Z
Learning: In src/client/Main.ts, during game start in the handleJoinLobby callback, UI elements are hidden using direct DOM manipulation with classList.add("hidden") for consistency. This includes modals, buttons, and error divs. The codebase follows this pattern rather than using component APIs for hiding elements during game transitions.
Applied to files:
src/client/graphics/GameRenderer.tssrc/client/ClientGameRunner.ts
📚 Learning: 2025-08-29T16:16:11.309Z
Learnt from: BrewedCoffee
Repo: openfrontio/OpenFrontIO PR: 1957
File: src/core/execution/PlayerExecution.ts:40-52
Timestamp: 2025-08-29T16:16:11.309Z
Learning: In OpenFrontIO PlayerExecution.ts, when Defense Posts are captured due to tile ownership changes, the intended behavior is to first call u.decreaseLevel() to downgrade them, then still transfer them to the capturing player via captureUnit(). This is not a bug - Defense Posts should be both downgraded and captured, not destroyed outright.
Applied to files:
src/core/game/GameView.ts
📚 Learning: 2025-10-26T15:37:07.732Z
Learnt from: GlacialDrift
Repo: openfrontio/OpenFrontIO PR: 2298
File: src/client/graphics/layers/TerritoryLayer.ts:200-210
Timestamp: 2025-10-26T15:37:07.732Z
Learning: In GameImpl.ts lines 124-139, team assignment logic varies by number of teams: when numPlayerTeams < 8, teams are assigned ColoredTeams values (Red, Blue, Yellow, Green, Purple, Orange, Teal); when numPlayerTeams >= 8, teams are assigned generic string identifiers like "Team 1", "Team 2", etc., which are not members of ColoredTeams.
Applied to files:
src/core/game/GameView.ts
📚 Learning: 2025-10-21T20:06:04.823Z
Learnt from: Saphereye
Repo: openfrontio/OpenFrontIO PR: 2233
File: src/client/HostLobbyModal.ts:891-891
Timestamp: 2025-10-21T20:06:04.823Z
Learning: For the HumansVsNations game mode in `src/client/HostLobbyModal.ts` and related files, the implementation strategy is to generate all nations and adjust their strength for balancing, rather than limiting lobby size based on the number of available nations on the map.
Applied to files:
src/core/game/TerrainMapLoader.ts
🧬 Code graph analysis (7)
src/core/worker/WorkerClient.ts (1)
src/core/worker/SharedTileRing.ts (1)
SharedTileRingBuffers(3-7)
src/client/InputHandler.ts (1)
src/core/EventBus.ts (1)
GameEvent(1-1)
src/core/GameRunner.ts (5)
src/core/game/GameView.ts (2)
tile(106-108)config(668-670)src/core/game/UnitImpl.ts (1)
tile(176-178)src/core/game/GameMap.ts (1)
TileRef(3-3)src/core/game/GameImpl.ts (1)
config(336-338)src/core/configuration/ConfigLoader.ts (1)
getConfig(11-27)
src/core/worker/SharedTileRing.ts (1)
src/core/game/GameMap.ts (1)
TileRef(3-3)
src/client/graphics/GameRenderer.ts (1)
src/client/InputHandler.ts (1)
BacklogStatusEvent(148-153)
src/core/game/GameMap.ts (3)
src/core/game/GameView.ts (2)
width(701-703)height(704-706)src/core/game/GameImpl.ts (2)
width(807-809)height(810-812)src/client/graphics/TransformHandler.ts (1)
width(48-50)
src/core/worker/Worker.worker.ts (4)
src/core/worker/SharedTileRing.ts (3)
SharedTileRingViews(9-14)createSharedTileRingViews(31-43)pushTileUpdate(45-64)src/core/worker/WorkerMessages.ts (2)
MainThreadMessage(115-122)InitializedMessage(50-52)src/core/GameRunner.ts (1)
createGameRunner(35-95)src/core/game/GameMap.ts (1)
TileRef(3-3)
🔇 Additional comments (24)
src/core/game/GameView.ts (2)
475-487: LGTM! Clean SAB flag wiring.The optional
usesSharedTileStateparameter with afalsedefault keeps backward compatibility. The flag is stored and used to branch tile update handling correctly.
516-525: Correct branching for SAB vs non-SAB tile updates.Based on learnings, in SAB mode
packedTileUpdatescontains unpacked tile refs (BigInt(tileRef)), soNumber(tu)is correct. In non-SAB mode, it contains packed values requiringupdateTile(tu)to decode. The logic is sound.src/core/game/GameMap.ts (1)
78-103: Well-structured external buffer support with proper validation.The constructor:
- Validates
stateBufferlength matcheswidth * height- Throws a clear error with dimensions if mismatch
- Falls back to internal allocation when no buffer provided
This enables shared memory without breaking existing code paths.
src/core/worker/WorkerClient.ts (2)
23-29: Clean constructor extension for shared buffers.Optional parameters for
sharedTileRingBuffers,sharedStateBuffer, andsharedDirtyBufferkeep the API backward compatible while enabling SAB mode when available.
72-81: Initialization payload correctly passes shared buffers.The init message extracts header/data from
sharedTileRingBuffersand includes state/dirty buffers. The optional chaining handles the non-SAB fallback case.src/core/worker/WorkerMessages.ts (1)
34-42: Clean InitMessage extension for shared buffers.Optional
SharedArrayBufferfields allow gradual adoption of SAB mode while keeping the message contract backward compatible.src/client/graphics/GameRenderer.ts (1)
298-300: Good state additions for backlog-aware rendering.These fields track backlog state from events and enable frame pacing decisions.
src/client/InputHandler.ts (2)
128-146: TickMetricsEvent extended with useful diagnostics.The new optional fields for backlog, throughput, and ring buffer stats are well-documented with comments. Optional parameters keep backward compatibility.
148-153: Clean BacklogStatusEvent definition.Simple event with
backlogTurnscount andbacklogGrowingflag. Matches usage in GameRenderer.src/core/worker/Worker.worker.ts (2)
25-27: Module-level state for turn processing and shared ring.These globals track the processing state and shared buffer views. Clean initialization.
44-65: Verification confirms:executeNextTick()is synchronous.The web search confirms that
executeNextTick()is a synchronous, single-tick game-loop method that runs the deterministic per-tick simulation and returns updates—not a Promise. This means the while loop correctly processes pending turns without await statements, and the reentrancy protection withisProcessingTurnsflag combined with thetry-finallycleanup is appropriate.src/core/GameRunner.ts (3)
40-48: Clean API extension for shared buffer support.The optional parameters
tileUpdateSinkandsharedStateBufferare well-typed and properly threaded through to dependent functions. Good use of optional parameters to maintain backward compatibility.
183-196: Tile update branching logic is correct.The extraction of
tileRefviaNumber(u.update >> 16n)correctly unpacks the upper bits from the packed format(tileRef << 16n | state). When using a shared sink, returning an emptyBigUint64Arrayis the right approach since tile state lives in the shared buffer.
292-295: Simple and clear pending turns check.Clean implementation. The method name clearly conveys its purpose.
src/client/graphics/layers/PerformanceOverlay.ts (2)
231-243: Clean event forwarding for new metrics.All new metric fields are properly extracted from the event and passed to
updateTickMetrics.
440-468: State properties well-structured.All new metrics use the
@state()decorator correctly, and defaults are sensible. Good organization.src/core/game/TerrainMapLoader.ts (3)
42-67: Cache and shared buffer logic is well-structured.The caching bypass when SAB is available and the nullish coalescing for
stateBufferare correctly implemented. Previous review concerns have been addressed.
69-76: Verify: Compact maps intentionally skip shared state buffer.Only normal-sized maps receive the
stateBuffer. Compact maps (4x variants) do not get shared state. Please confirm this is the intended behavior - if compact maps should also support SAB mode, the buffer would need to be sized appropriately and passed here as well.
95-109: Result construction handles buffer types safely.Good defensive check with
instanceof SharedArrayBufferbefore assignment. The caching logic correctly applies only when SAB path is not in use.src/core/worker/SharedTileRing.ts (2)
3-19: Clean interface definitions for shared ring buffer.Good use of typed interfaces over class hierarchy. The separation of
Buffers(raw SharedArrayBuffer) fromViews(typed array views) is a clean pattern for cross-thread data sharing.
21-43: Factory functions are straightforward.Clean separation between buffer allocation and view creation. This allows buffers to be transferred to workers while views are created on each side.
src/client/ClientGameRunner.ts (3)
173-203: Shared buffer initialization is well-structured.Clean feature detection for SAB/Atomics/crossOriginIsolated. Ring capacity sizing to
numTilesprovides a reasonable upper bound for tile updates.
258-272: Instance fields for batched processing are well-organized.Using
pendingStartas a cursor index intopendingUpdatesis a good pattern to avoid costly array shifts. The separation of worker/render tick counters enables accurate throughput metrics.
745-756: Backlog tracking is straightforward.Clean calculation of backlog turns and emission of status events. The
backlogGrowingflag provides useful trend information for downstream throttling.
evanpelle
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if there's a way to abstract the data transfer behind an interface and then we can pop in SharedArrayBufferTransfer or PostMessageTransfer. this way we can avoid the if(sabEnabled) else ... occurrances
| @state() | ||
| private totalTilesUpdated: number = 0; | ||
|
|
||
| updateTickMetrics( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should be an interface, it's too easy to mix up arguments with this many parameters.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
most of these can/should be removed after evaluation
| ); | ||
| } | ||
|
|
||
| let sharedTileRingBuffers: SharedTileRingBuffers | undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can all the buffer logic be moved to another file? ClientGameRunner is getting pretty large.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do believe that, in the current logic, this belong here. I would consider Refactoring this outside of scope.
| // Number of turns the client is behind the server (if known) | ||
| public readonly backlogTurns?: number, | ||
| // Number of simulation ticks applied since last render | ||
| public readonly ticksPerRender?: number, | ||
| // Approximate worker simulation ticks per second | ||
| public readonly workerTicksPerSecond?: number, | ||
| // Approximate render tick() calls per second | ||
| public readonly renderTicksPerSecond?: number, | ||
| // Tile update metrics | ||
| public readonly tileUpdatesCount?: number, | ||
| public readonly ringBufferUtilization?: number, | ||
| public readonly ringBufferOverflows?: number, | ||
| public readonly ringDrainTime?: number, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this data relevant to InputHandler?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Semantically, no, but it does extend TickMetricsEvent which was here before.
Most of this is only for the perf overlay and can be removed after testing.
| export class BacklogStatusEvent implements GameEvent { | ||
| constructor( | ||
| public readonly backlogTurns: number, | ||
| public readonly backlogGrowing: boolean, | ||
| ) {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this belongs in InputHandler either
| const canUseSharedBuffers = | ||
| typeof SharedArrayBuffer !== "undefined" && | ||
| typeof Atomics !== "undefined" && | ||
| typeof (globalThis as any).crossOriginIsolated === "boolean" && | ||
| (globalThis as any).crossOriginIsolated === true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this logic should be refactored into a function so it can be reused.
| sharedTileRingHeader: this.sharedTileRingBuffers?.header, | ||
| sharedTileRingData: this.sharedTileRingBuffers?.data, | ||
| sharedStateBuffer: this.sharedStateBuffer, | ||
| sharedDirtyBuffer: this.sharedDirtyBuffer, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could we group these into an interface/object?
evanpelle
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be possible to break this into multiple PRs? I can't really review a PR this large effectively
evanpelle
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The territory tiles don't seem to render when i tried running this locally
Description:
SharedArrayBuffer tile state, backlog-aware tick ingestion, and extended performance instrumentation
Summary
Feature Set 1 – Shared tile state between worker and main thread
What it does
TerrainMapLoader(src/core/game/TerrainMapLoader.ts)TerrainMapDatanow optionally carriessharedStateBufferandsharedDirtyBuffer.loadTerrainMap(...)can take an optionalsharedStateBuffer. If SAB is supported andcrossOriginIsolatedis true, it creates aSharedArrayBufferof sizewidth * height * Uint16.GameMapImpl(src/core/game/GameMap.ts)statearray (per-tile mutable state) can now be backed by an externally supplied buffer (stateBuffer?: ArrayBufferLike), which in practice is aSharedArrayBufferin SAB mode.width * heightto avoid misaligned state.GameRunner(src/core/GameRunner.ts)createGameRunner(...)takes asharedStateBufferand passes it intoloadTerrainMapso the worker’sGameMapImplviews the sameSharedArrayBufferas the client.ClientGameRunner(src/client/ClientGameRunner.ts)loadTerrainMap(...)is called without asharedStateBuffer. Inside the loader, if SAB is available, the returnedTerrainMapDataalready includes a freshSharedArrayBuffer.SharedArrayBuffer,Atomics, andcrossOriginIsolated).gameMap.sharedStateBufferif present.SharedArrayBufferinto the worker viaWorkerClientand intoGameRunner’screateGameRunner(...).GameView(src/core/game/GameView.ts) gets ausesSharedTileStateflag so it knows whetherpackedTileUpdatesare “apply this update” or “this tile has already been updated in shared state; just mark it dirty”.Interaction with the rest of the game
Game,GameImpl,Executor, etc.) is unchanged; they still manipulateGameMapthrough the existing API.Uint16Arrayfor tile state. The worker writes into it duringgame.executeNextTick().main: each thread has its ownUint16Arraystate, and tile updates continue to flow viapackedTileUpdates.Implications
BigUint64Arraytile update arrays from worker → main when SAB is available.SharedArrayBuffer+Atomics+crossOriginIsolated === true. Otherwise, the code falls back to the old, cached, non-SAB behavior.Feature Set 2 – Shared tile update ring buffer (SAB) and dirty flags
What it does
New ring buffer implementation (
src/core/worker/SharedTileRing.ts)SharedTileRingBuffers: threeSharedArrayBuffers:header:Int32Arraywith write index, read index, overflow flag.data:Uint32Arrayof tile refs.dirty:Uint8Arrayof per-tile dirty flags.createSharedTileRingBuffers(capacity, numTiles)&createSharedTileRingViews(...)set up both buffers and typed views.pushTileUpdate(...): append a tile ref into the ring, dropping oldest entries and flagging overflow if full.drainTileUpdates(...): pop up tomaxItemsinto an array, advancing the read pointer atomically.Worker integration (
src/core/worker/Worker.worker.ts)init:sharedTileRingHeader/sharedTileRingDataare provided, builds aSharedTileRingViewsand keepsdirtyFlagsfrom thedirtybuffer.tileUpdateSinktocreateGameRunner(...):dirtyFlags[tile]was 0 (usingAtomics.compareExchange).GameRunnercan be streamed into the ring instead of being serialized intopackedTileUpdates.GameRunner integration (
src/core/GameRunner.ts)updates[GameUpdateType.Tile].tileUpdateSinkexists:TileUpdatebigint and callstileUpdateSinkper tile.updates[GameUpdateType.Tile]and setspackedTileUpdatesto an emptyBigUint64Array.packedTileUpdatesexactly as inmain.Client integration (
src/client/ClientGameRunner.ts)gameMap.gameMap.width() * height().createSharedTileRingBuffersand passes header + data + dirty to the worker viaWorkerClient.SharedTileRingViewsandUint8ArrayfordirtyFlags.drainTileUpdates(...)with aMAX_TILE_UPDATES_PER_RENDERcap (~100k).Set.Atomics.store(dirtyFlags, ref, 0).packedTileUpdatesarray containing tile refs asBigIntand attaches it to the mergedGameUpdateViewData.GameView integration (
src/core/game/GameView.ts)update(...), ifusesSharedTileStateis true:packedTileUpdatesentries as tile refs and does not callupdateTile.updatedTilesso render layers can redraw changed tiles.Interaction with the rest of the game
GameUpdateViewDatastill presents apackedTileUpdatesarray toGameView.GameView.recentlyUpdatedTiles()continue to work; they just see tile refs drawn from the ring rather than from decodedTileUpdatebigints.game_updatemessages); the difference is thatpackedTileUpdatesis empty coming out of the worker in SAB mode and is populated on the client side from the ring.Implications
BigUint64Arrayon every tick” to “reuse fixed-size SAB ring and small per-frame arrays”.Feature Set 3 – Backlog-aware tick ingestion and self-clocked worker
What it does
Worker overhaul (
src/core/worker/Worker.worker.ts)"heartbeat"message type; the worker no longer depends on the main thread poking it.processPendingTurns():GameRunnerexists andhasPendingTurns(), loops throughexecuteNextTick()until there are no pending turns.isProcessingTurnsflag to avoid re-entrancy.processPendingTurns():turnmessage is added viaaddTurn(...).WorkerClient (
src/core/worker/WorkerClient.ts) & messages (src/core/worker/WorkerMessages.ts)sendHeartbeat()and the"heartbeat"message type entirely.InitMessageto carry shared ring buffers and shared state buffer to the worker.ClientGameRunner tick ingestion (
src/client/ClientGameRunner.ts)game_updatefrom the worker was applied immediately; render and simulation tick rate were tightly coupled.GameUpdateViewDataare appended topendingUpdates.processPendingUpdates():requestAnimationFrameslices.sliceBudgetMs) based on the current backlog (backlogTurns), scaling from 8ms up to 1000ms as backlog grows from ~10 to 500 turns.MAX_TICKS_PER_SLICE(1000) updates or until it runs out of budget.SendHashEventfor server hash checking.updateBacklogMetrics) based onserverTurnHighWaterand the tick number.Winupdate is present.gameView.update(...).renderer.tick().TickMetricsEventwith:currentTickDelaybetween server messages).Backlog metrics and events
ClientGameRunner.updateBacklogMetrics(processedTick)maintains:backlogTurns = max(0, serverTurnHighWater - lastProcessedTick).backlogGrowingflag (whether backlog increased since last sample).BacklogStatusEvent(backlogTurns, backlogGrowing)whenever metrics update.GameRenderer(src/client/graphics/GameRenderer.ts) listens forBacklogStatusEventand storesbacklogTurns.Interaction with the rest of the game
ServerMessagehandling is unchanged forstart,desync,error,turnmessages.Turnintents to the worker and marks them complete withtransport.turnComplete(); this now happens inside the per-frame processing loop rather than immediately on eachgame_update.ClientGameRunnerand worker; the backlog logic applies uniformly, making heavy replays and high-speed games smoother.Implications
ticksPerRendertracking.addTurnor analogous queued work soprocessPendingTurns()sees it.Feature Set 4 – Render throttling based on simulation backlog
What it does
GameRenderer.renderGame()(src/client/graphics/GameRenderer.ts)backlogTurns > 0, dynamically reduces target FPS from 60 down to 10 as backlog grows (capped at 50 turns).requestAnimationFrameand skips work for this frame.lastRenderTimeto enforce the throttle.Interaction with the rest of the game
updateFrameMetricsas before.Implications
BASE_FPS,MIN_FPS, andBACKLOG_MAX_TURNSare the primary tuning constants; they are currently hard-coded.Feature Set 5 – Extended performance overlay & instrumentation
What it does
Input events (
src/client/InputHandler.ts)TickMetricsEventgains:backlogTurnsticksPerRenderworkerTicksPerSecond,renderTicksPerSecondtileUpdatesCountringBufferUtilization,ringBufferOverflows,ringDrainTimeBacklogStatusEvent(backlogTurns, backlogGrowing)to communicate backlog status to the renderer (and any future listeners).Performance overlay (
src/client/graphics/layers/PerformanceOverlay.ts)TickMetricsEventand updates new reactive state fields:getSnapshot()under a newtilessection.Interaction with the rest of the game
UserSettings:updateTickMetricsis a no-op when overlay is disabled or hidden.Implications
Risks / Edge Cases
SharedArrayBuffer(or withoutcrossOriginIsolated), all the new shared-memory paths automatically fall back to the old behavior. It’s important to test both modes.Atomicsto synchronize indices and dirty flags; correct behavior assumes no direct writes outside of the established push/drain/reset flow.Testing Suggestions
crossOriginIsolatedtrue) and:Please complete the following:
Please put your Discord username so you can be contacted if a bug or regression is found:
DISCORD_USERNAME