Skip to content

Speed up the core sim: inline sfc32 PRNG and allocation-free player updates#4233

Merged
evanpelle merged 2 commits into
mainfrom
core-perf-round3
Jun 12, 2026
Merged

Speed up the core sim: inline sfc32 PRNG and allocation-free player updates#4233
evanpelle merged 2 commits into
mainfrom
core-perf-round3

Conversation

@evanpelle

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #4230. Two more core-sim optimizations — these are behavior-affecting in controlled ways (unlike #4230, which was hash-identical), so both come with dedicated test coverage written before the change.

Combined results (npm run perf:game, same machine, before → after):

run mean tick ticks/sec p99 peak heap
default (world, 400 bots, 1800 ticks) 7.98 → 6.96 ms 125 → 144 21.2 → 19.0 ms 438 → 294 MB
giantworldmap, 600 ticks 17.4 → 15.2 ms 58 → 66 32.6 → 30.5 ms

Cumulative with #4230 vs. the original baseline: default run mean 9.04 → 6.96 ms (111 → 144 ticks/sec); giantworldmap 22.5 → 15.2 ms (44 → 66 ticks/sec, max tick 52.8 → 40.1 ms).

1. PseudoRandom: seedrandom ARC4 → inline sfc32

  • ARC4 was ~4% of profiled self time. The new engine is sfc32 with splitmix32 seed expansion and a warmup, using only 32-bit integer ops — sequences are identical across platforms. The class API is unchanged.
  • This removes the seedrandom dependency entirely, making src/core actually dependency-free (the import was the only violation of that rule).
  • ⚠️ The random stream differs, so the deterministic game-state hash changes. All clients run the same code, so cross-client sync is unaffected; the harness reproduces the same hash on repeated runs per seed. New reference hashes:
    • --map world --ticks 200 --bots 1005607618202213430
    • default run → 29309648281599524
    • --map giantworldmap --ticks 60039945089450032050
  • New tests/PseudoRandom.test.ts (15 tests) pins the engine-agnostic contract: per-seed determinism, ranges, uniformity, adjacent-seed decorrelation, and every API method. The tests were verified green against the old engine first, then the swap.
  • The stream change exposed a test that passed by RNG luck: in AiAttackBehavior.test.ts, "nation cannot attack allied player" was actually being blocked by the difficulty dice gate in shouldAttack, not the alliance check — hiding that the test's AiAttackBehavior was constructed without its NationEmojiBehavior. The test now supplies one and verifies the real protection layer (AttackExecution's alliance check), robust to any dice outcome.

2. PlayerImpl.toFullUpdate: allocation-free empty collections

  • toFullUpdate runs for every player every tick and allocated ~10 collections each (allies, embargoes Set, attacks, alliance views, …) even when all were empty — the common case for most of 472 players. Because lastSentUpdate retains each snapshot for a full tick, these objects survived minor GC, got promoted, and accumulated as old-space garbage between major GCs — that's the peak-heap drop.
  • Empty collections now reuse shared frozen module-level singletons, so diffPlayerUpdate's existing a === b fast paths skip structural comparison entirely. Non-empty collections build in single passes. Freezing makes accidental in-worker mutation throw loudly instead of silently corrupting every player; consumers across the worker boundary get mutable structured clones as before. (Set cannot be frozen — EMPTY_EMBARGOES is documented as never-mutate.)
  • Value-identical: the game-state hash is unchanged by this part (verified against the post-PRNG baseline).
  • New tests/PlayerUpdateDiff.test.ts (8 tests): full-snapshot shape, null-when-unchanged, embargo/alliance/target/attack diffs through the real tick pipeline, and the freeze contract.

Verification

  • Full suite passes: 124 files / 1408 tests (23 new) + server tests; lint and prettier clean.
  • Hash reproducibility confirmed: repeated runs with identical args produce identical hashes on all three configs.

🤖 Generated with Claude Code

…pdates

Two optimizations, both behavior-affecting in controlled ways and covered
by new tests. Together: default perf run mean tick 7.98 -> 6.96ms
(125 -> 144 ticks/sec, p99 21.2 -> 19.0ms, peak heap 438 -> 294 MB);
giantworldmap 600-tick run mean 17.4 -> 15.2ms (58 -> 66 ticks/sec).

1. PseudoRandom: replace the seedrandom ARC4 engine with an inline sfc32
   PRNG (splitmix32 seed expansion + warmup, 32-bit integer ops only, so
   sequences are identical across platforms). This removes the seedrandom
   dependency — src/core is now actually dependency-free — and was ~4% of
   profiled self time. The random stream differs, so the deterministic
   game-state hash changes; the harness still reproduces the same hash on
   repeated runs per seed. New tests/PseudoRandom.test.ts (15 tests) pins
   the engine contract: per-seed determinism, ranges, uniformity,
   adjacent-seed decorrelation, and all API behaviors.

   The stream change exposed that the "nation cannot attack allied
   player" test passed only by RNG luck: the difficulty random gate (not
   the alliance check) blocked the attack, hiding that the test's
   AiAttackBehavior was constructed without its emoji behavior. The test
   now supplies a NationEmojiBehavior and verifies the real protection
   layer (AttackExecution's alliance check) regardless of AI dice rolls.

2. PlayerImpl.toFullUpdate: empty collections now reuse shared frozen
   module-level singletons instead of allocating fresh arrays/Sets for
   every player every tick (~10 allocations per player-tick, 472 players;
   most collections are empty for most players). diffPlayerUpdate's
   existing reference fast paths make the compares O(1). Non-empty
   collections build in single passes. Frozen so accidental in-worker
   mutation throws loudly; cross-thread consumers get mutable structured
   clones. Value-identical: the game-state hash is unchanged by this
   commit's update-path changes. New tests/PlayerUpdateDiff.test.ts
   (8 tests) covers snapshot shape, null-when-unchanged, per-collection
   diffs, the tick pipeline, and the freeze contract.

New reference hashes (npm run perf:game):
- --map world --ticks 200 --bots 100: 5607618202213430
- default run: 29309648281599524
- --map giantworldmap --ticks 600: 39945089450032050

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 96181f84-5063-4a79-8b63-ff7c12a29e91

📥 Commits

Reviewing files that changed from the base of the PR and between 8ff7fd9 and 75f4ed5.

📒 Files selected for processing (2)
  • src/core/PseudoRandom.ts
  • tests/AiAttackBehavior.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/AiAttackBehavior.test.ts
  • src/core/PseudoRandom.ts

Walkthrough

Eliminate the external seedrandom dependency by implementing an internal 32-bit PRNG and optimize player snapshots to reuse frozen empty singleton collections; add tests for PRNG determinism/ranges and for player update diffing and snapshot immutability.

Changes

PRNG Refactoring

Layer / File(s) Summary
Dependency removal and PRNG implementation
package.json, src/core/PseudoRandom.ts
Remove seedrandom and @types/seedrandom. Implement internal 32-bit state (s0s3) with splitmix32-style init and warm-up. Reimplement next(), nextInt(), nextFloat(), and nextID() to use the internal state.
PRNG test suite
tests/PseudoRandom.test.ts
Add tests covering determinism, decorrelation across seeds, next() range/statistics, nextInt() and nextFloat() bounds/behavior, nextID() format, selection helpers, chance(), and shuffleArray().

Player Update Optimization

Layer / File(s) Summary
Singleton collections and toFullUpdate refactoring
src/core/game/PlayerImpl.ts
Introduce frozen EMPTY_* singletons for empty collections and refactor toFullUpdate() to return precomputed locals that reuse singletons when empty and allocate/clone only when non-empty.
Player update diffing tests and test fixes
tests/PlayerUpdateDiff.test.ts, tests/AiAttackBehavior.test.ts
Add comprehensive Player.toUpdate() diffing tests (full snapshot, null diffs, primitive-only diffs, embargo/alliance/target changes, attack propagation across ticks, frozen singleton immutability) and update AiAttackBehavior test wiring (NationEmojiBehavior + dedicated PseudoRandom, force dice gate).

Sequence Diagrams

sequenceDiagram
  participant Caller
  participant PseudoRandom
  participant InternalState
  Caller->>PseudoRandom: constructor(seed)
  PseudoRandom->>InternalState: splitmix32 mixing + warm-up
  Caller->>PseudoRandom: next()
  PseudoRandom->>InternalState: advance s0–s3 via bitwise ops
  PseudoRandom->>Caller: return normalized [0,1) float
Loading
sequenceDiagram
  participant Game
  participant Alice
  participant Bob
  Game->>Alice: toUpdate() (first call)
  Alice-->>Game: full snapshot (empty singletons)
  Game->>Alice: state change (attack/embargo/alliance)
  Alice->>Game: toUpdate() (diff with outgoing changes)
  Game->>Bob: toUpdate()
  Bob-->>Game: diff with incomingAttacks
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • openfrontio/OpenFrontIO#3967: Introduces diffPlayerUpdate and partial PlayerUpdate pipeline that benefits from reusing frozen empty singletons in toFullUpdate().

Poem

🎲 Seed unchained, four words awake,
Bits spin softly for each random take,
Empty lists stay still and lean,
Diffs whisper only what has been,
Tests hum steady, all checks green.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the two main optimizations in the changeset: replacing seedrandom with an inline sfc32 PRNG and making player updates allocation-free.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, clearly explaining both optimizations, performance improvements, test coverage, and verification details.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed due to a network error.


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/core/game/PlayerImpl.ts (1)

69-75: ⚡ Quick win

Clarify the Set freezing comment.

The comment states "Sets cannot be frozen (Set.add ignores freeze)", but this is misleading. Object.freeze() does work on Sets in strict mode (which TypeScript uses) — attempting to add to a frozen Set will throw a TypeError. The implementation is correct since it never mutates EMPTY_EMBARGOES (creating a new Set when needed on lines 204-207), but the comment should be updated to reflect the actual behavior: frozen Sets throw on mutation rather than silently ignoring it.

📝 Suggested comment revision
-// Shared singletons for empty collections in toFullUpdate. Sharing
-// references lets diffPlayerUpdate's `a === b` fast paths skip structural
-// comparison and avoids per-player-per-tick allocations. The arrays are
-// frozen so accidental in-worker mutation throws instead of silently
-// corrupting every player's updates; updates crossing to the main thread
-// are structured-cloned (clones are mutable). Sets cannot be frozen
-// (Set.add ignores freeze) — EMPTY_EMBARGOES must never be mutated.
+// Shared singletons for empty collections in toFullUpdate. Sharing
+// references lets diffPlayerUpdate's `a === b` fast paths skip structural
+// comparison and avoids per-player-per-tick allocations. All singletons are
+// frozen so accidental in-worker mutation throws instead of silently
+// corrupting every player's updates. Updates crossing to the main thread
+// are structured-cloned (clones are mutable). EMPTY_EMBARGOES is never
+// mutated—new Sets are allocated when non-empty (lines 204-207).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/game/PlayerImpl.ts` around lines 69 - 75, The comment near the top
of PlayerImpl about shared singletons is misleading about Set freezing; update
the comment to state that Object.freeze does apply to Sets in strict/TypeScript
mode and will cause a TypeError on mutation rather than silently ignoring
additions, and note that EMPTY_EMBARGOES must never be mutated (the code path in
toFullUpdate and the creation of a new Set around lines where EMPTY_EMBARGOES is
used ensures immutability); keep references to toFullUpdate, EMPTY_EMBARGOES,
and diffPlayerUpdate so readers can find the related logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/core/game/PlayerImpl.ts`:
- Around line 69-75: The comment near the top of PlayerImpl about shared
singletons is misleading about Set freezing; update the comment to state that
Object.freeze does apply to Sets in strict/TypeScript mode and will cause a
TypeError on mutation rather than silently ignoring additions, and note that
EMPTY_EMBARGOES must never be mutated (the code path in toFullUpdate and the
creation of a new Set around lines where EMPTY_EMBARGOES is used ensures
immutability); keep references to toFullUpdate, EMPTY_EMBARGOES, and
diffPlayerUpdate so readers can find the related logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0f864e9b-384d-4c90-83f6-182be8dd3cfb

📥 Commits

Reviewing files that changed from the base of the PR and between 182d008 and 8ff7fd9.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • package.json
  • src/core/PseudoRandom.ts
  • src/core/game/PlayerImpl.ts
  • tests/AiAttackBehavior.test.ts
  • tests/PlayerUpdateDiff.test.ts
  • tests/PseudoRandom.test.ts
💤 Files with no reviewable changes (1)
  • package.json

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 12, 2026
@evanpelle evanpelle added this to the v32 milestone Jun 12, 2026
@evanpelle evanpelle merged commit 2e6f70c into main Jun 12, 2026
13 of 16 checks passed
@evanpelle evanpelle deleted the core-perf-round3 branch June 12, 2026 15:15
@github-project-automation github-project-automation Bot moved this from Triage to Complete in OpenFront Release Management Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant