TypeScript server `ctx.random()`: first draw of every transaction is heavily biased (signed shift in `pcg32` seeding)
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 72/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- javascript, typescript
- Domain
- backend
Research direction
The bug is in pcg32 in crates/bindings-typescript/src/server/rng.ts, around line 71, where the rotation uses a signed >> on a 32-bit value. Start by running the standalone repro in the issue to confirm the biased first draw. Done means switching to an unsigned shift, adding a distribution test over timestamp seeds to the RNG tests, and confirming the repro's with >>> numbers match.
Written by the indexing model from the issue text.
Description
Summary
In the TypeScript server bindings, the first value ctx.random() returns in a transaction is far from uniform. It lands below 0.001 about 28% of the time instead of 0.1%, and below 0.01 about 34% of the time instead of 1%. The second draw is closer but still off (7.7% instead of 10% below 0.1).
Any module that makes a single low-probability roll at the start of a reducer gets that outcome far too often. We noticed because a 1-in-1,000 boss drop in our game was dropping more than half the time.
Verified on spacetimedb 2.10.2, 2.11.0 and 2.12.0 (npm, dist/server/index.mjs).
Cause
pcg32 in crates/bindings-typescript/src/server/rng.ts rotates with a signed shift:
return (xorshifted >> rot) | (xorshifted << (32 - rot));
xorshifted is an unsigned 32-bit value held in a JS number. >> sign-extends it, so the rotation smears the top bit across the seed handed to xoroshiro128plus. Seeds derived from nearby timestamps then start the generator in correlated states, and the first outputs pile up near 0.
An unsigned shift fixes it:
return ((xorshifted >>> rot) | (xorshifted << (32 - rot))) >>> 0;
Reproduction
Standalone, mirroring rng.ts with the bundled pure-rand (npm install pure-rand@7 && node repro.mjs):
import { xoroshiro128plus, unsafeUniformIntDistribution } from "pure-rand";
function pcg32(state, fixed) {
const MUL = 6364136223846793005n, INC = 11634580027462260723n;
state = BigInt.asUintN(64, state * MUL + INC);
const xorshifted = Number(BigInt.asUintN(32, ((state >> 18n) ^ state) >> 27n));
const rot = Number(BigInt.asUintN(32, state >> 59n));
return fixed ? ((xorshifted >>> rot) | (xorshifted << (32 - rot))) >>> 0 : (xorshifted >> rot) | (xorshifted << (32 - rot));
}
function makeRandom(micros, fixed) {
const rng = xoroshiro128plus(pcg32(micros, fixed));
return () => {
const g1 = unsafeUniformIntDistribution(0, (1 << 26) - 1, rng);
const g2 = unsafeUniformIntDistribution(0, (1 << 27) - 1, rng);
return (g1 * 2 ** 27 + g2) * 2 ** -53;
};
}
const N = 1_000_000, base = 1_791_964_800_000_000n; // microsecond timestamps, as ReducerCtx seeds them
for (const fixed of [false, true]) {
const thresholds = [0.001, 0.01, 0.1, 0.5];
const first = thresholds.map(() => 0), second = thresholds.map(() => 0);
for (let i = 0; i < N; i++) {
const random = makeRandom(base + BigInt(i) * 7919n, fixed);
const a = random(), b = random();
thresholds.forEach((t, k) => { if (a < t) first[k]++; if (b < t) second[k]++; });
}
console.log(fixed ? "with >>>" : "as shipped");
thresholds.forEach((t, k) => console.log(` P(draw < ${t}): first ${(first[k] / N * 100).toFixed(2)}%, second ${(second[k] / N * 100).toFixed(2)}%`));
}
Output:
as shipped
P(draw < 0.001): first 28.20%, second 0.07%
P(draw < 0.01): first 33.71%, second 0.77%
P(draw < 0.1): first 43.48%, second 7.67%
P(draw < 0.5): first 69.50%, second 53.43%
with >>>
P(draw < 0.001): first 0.10%, second 0.10%
P(draw < 0.01): first 1.00%, second 1.00%
P(draw < 0.1): first 9.99%, second 10.01%
P(draw < 0.5): first 49.98%, second 50.09%
We got the same numbers by lifting makeRandom straight out of the published dist/server/index.mjs.
Impact
Every TypeScript module using ctx.random() (and so integerInRange, fill, uuid v4 and the rest) gets a biased first draw in each reducer, scheduled reducer and procedure, and a skewed second one. In our game that meant boss drops, upgrade success rolls and rare resource finds all succeeded far more often than their stated odds.
Suggested: fix the shift and add a distribution test over timestamp seeds. The current tests replace the RNG with fixed values, so they can't see this. Since the seed sequence changes, it may also be worth a release note for modules that depend on reproducing past rolls.
Workaround
We patch that one line in the installed dist/server/index.mjs before building our module until a fixed release is out.
Thanks! Found by one of our players, @Kiira1.
- Dominant language
- Rust
- Stars
- 25.3k
- Forks
- 1.1k
- Avg merge
- 3d 22h
- Merged PRs (30d)
- 61
Getting set up
- Ships a Dockerfile or Docker Compose file
- Has a pull request template
- No contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from clockworklabs/SpacetimeDB
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
clockworklabs/SpacetimeDB#6288 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
clockworklabs/SpacetimeDB#6146 ·
Maintainers usually reply within 1 day
-
C# codegen: generated `Equals(object)` on `[SpacetimeDB.Type]` structs recurses foreverPossibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
clockworklabs/SpacetimeDB#6124 ·
Maintainers usually reply within 1 day
-
CI flake: LintsOpen
Difficulty 1/5 Under an hour Newbie friendliness 88/100
clockworklabs/SpacetimeDB#5985 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
clockworklabs/SpacetimeDB#5943 · 1 comment ·
Maintainers usually reply within 1 day
All issues in clockworklabs/SpacetimeDB
Similar issues
-
documentation good first issue
Difficulty 2/5 Half a day Newbie friendliness 72/100
sujithputta02/Esapay#13 ·
-
needs triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
topgrade-rs/topgrade#2419 ·
Maintainers usually reply within 1 day
-
review-bot
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
midagedev/bloomery#318 · 1 comment ·
Maintainers usually reply within 1 day
-
area/infra good first issue type/bug
Difficulty 2/5 1-3 hours Newbie friendliness 60/100
cristim/AztecCraft#92 ·
Maintainers usually reply within 1 day
-
Standalone Unix updater reports success when curl fails (`curl | sh` masks the exit status); Windows path is unaffectedPossibly taken @akhil9tiet claimed this today. Openbug CLI windows-os
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
openai/codex#54465 · 1 comment ·
Maintainers usually reply within 1 day