Johan Sebastian Luna
  1. Blog
  • Work
  • Blog
  • Games
  • About
  • Contact
Johan Sebastian Luna
  1. Blog
  2. /Algorithms
  • Work
  • Blog
  • Games
  • About
  • Contact

Algorithms11 Nov 202613 min read

I built a Minecraft seed cracker.

How a few structure coordinates become a Minecraft world seed: the java.util.Random maths, the tricks that skip most of 2^48 seeds, and why the mods said no.

Post Facts

Published
11 Nov 2026
Topic
Algorithms
Reading time
13 min
Stack
TypeScript, Next.js, WebAssembly, WebGPU, C
A biome map of a Minecraft world with villages, temples, shipwrecks, ruined portals and buried treasure marked across it

Contents

  1. 01And then I asked the mods
  2. 02Where structures come from
  3. 03Half the seed dont matter
  4. 04Magic trick one: lifting the low bits
  5. 05Magic trick two: stop guessing, start solving
  6. 06Magic trick three: JavaScript can't count
  7. 07Magic trick four: get off the CPU
  8. 08People are bad at measuring
  9. 09From structure seed to world seed
  10. 10Was it worth it

If you've been following along, you know my time on this server has mostly been spent losing hearts. Skeletons, baby zombies, creepers, and maces I can't land a hit with. So I had an idea: what if I just knew where everything was?

Every Minecraft world comes from one number, the seed. Know the seed and you know where every village, temple, ancient city and buried treasure is, before you've walked a single block. Servers hide it for exactly that reason. But structures don't spawn randomly. They spawn from the seed, which means every structure you find leaks a little bit of it. Find enough of them and you can work backwards.

So...

I built Seed Cracker. You type in where you saw a few villages, temples and shipwrecks, press a button, and it gives you the seed. Most lists crack in under a second, entirely in your browser, on your graphics card if it supports WebGPU. The map up top is what a cracked seed buys you: every structure in the world, pinned before you've left spawn.

01 /

And then I asked the mods

The answer was a very firm no.

In my opinion it should have been a yes because a seed is publically available and if someone decides to spend their entire morning from 2AM to 8AM on just researching the math behind the Minecraft: Java Editions world generation just to get a competative advantage. Then I think that they should be allowed to use it.

But allas can't let my work go to waste so use it how you please. Oh and please stay for how it works.

02 /

Where structures come from

Most structures in Java Edition are placed on a grid. The world is cut into square regions of spacing × spacing chunks, and each region gets at most one attempt at that structure. Where in the region it goes is picked by a java.util.Random seeded from the region's coordinates:

placement.tsTypeScript
const seed = regionX * 341873128712n + regionZ * 132897987541n + worldSeed + salt;
const r = new JavaRandom(seed);
const offsetX = r.nextInt(spacing - separation);
const offsetZ = r.nextInt(spacing - separation);

Each structure type has its own spacing, separation and salt. Desert temples use a 32-chunk grid with offsets drawn from nextInt(24), villages a 34-chunk grid with nextInt(26), and so on. Monuments, mansions and end cities use a triangular spread instead, averaging two draws per axis so they cluster towards the middle of their region.

Here's the full set for 1.18 and later. Grid is the region size in chunks (the spacing), and n is spacing − separation, the bound passed to nextInt:

Structure (1.18+) Grid n Spread Salt
Village 34 26 linear 10387312
Desert pyramid, igloo, jungle temple, swamp hut 32 24 linear 14357617 to 14357620
Pillager outpost 32 24 linear 165745296
Shipwreck 24 20 linear 165745295
Ocean ruin 20 12 linear 14357621
Ruined portal 40 25 linear 34222645
Ancient city 24 16 linear 20083232
Trail ruins 34 26 linear 83469867
Trial chambers 34 22 linear 94251327
Ocean monument 32 27 triangular 10387313
Woodland mansion 80 60 triangular 10387319
Nether fortress / bastion 27 23 linear 30084232
End city 20 9 triangular 10387313

Biomes can stop a structure from generating, but they never move it. A structure you found is always at its grid position, and a missing one tells you nothing, so only enter structures you actually saw.

That's the whole leak. If I tell you a desert temple is in chunk (-37, 112), you know which region it's in, and you know what nextInt(24) returned twice for that region's seed.

03 /

Half the seed dont matter

java.util.Random is a 48-bit linear congruential generator (LCG). Every step is:

text
state = (state * 0x5DEECE66D + 11) mod 2^48

When you seed it, it throws away the top 16 bits of whatever you give it. A world seed is 64 bits, but structure placement only ever sees the low 48. That 48-bit number is the structure seed, and it's what the cracker actually solves for.

2^48 is 281,474,976,710,656. Testing every one of them, even at a billion a second, is about three days. That's the number to beat.

How many structures do you need to pin it down? Each one tells you two offsets. A desert temple's offsets are each one of 24 values, so one temple is worth about log₂(24²) ≈ 9.2 bits. Five or six structures gets you past 48 bits, and after that there's usually exactly one structure seed left that fits them all.

04 /

Magic trick one: lifting the low bits

Here's the bit of maths that does most of the work.

In an LCG modulo a power of two, the low bits of the state never depend on the high bits. Multiplication and addition only carry upwards. So the lowest b bits of the next state only depend on the lowest b bits of the current one.

Now look at what nextInt(n) does. It takes the top 31 bits of the state (bits 17 to 47) and returns that number mod n. Write n = 2^k · m with m odd. Then the result mod 2^k is just the bottom k bits of that 31-bit number, which are state bits 17 to 17 + k - 1. Those only depend on the lowest 17 + k bits of the seed.

For a desert temple, n = 24 = 2³ · 3, so k = 3. The bottom 3 bits of each temple offset depend only on the lowest 20 bits of the seed.

So the cracker brute-forces those 20 bits on their own. That's only 1,048,576 patterns, and for each one it checks whether the low bits of every observed offset come out right:

lift.tsTypeScript
outer: for (let L = 0; L < mod; L++) {
  for (const u of users) {
    let x = ((L + u.c) & mask) ^ xorLow;
    x = (x * mLow + 11) % mod;
    if (((x >>> 17) & u.km) !== u.ox) continue outer;
    x = (x * mLow + 11) % mod;
    if (((x >>> 17) & u.km) !== u.oz) continue outer;
  }
  out.push(L);
}

Every temple you add checks 3 bits on X and 3 on Z, so it throws away all but 1 in 64 patterns. With three temples, about four of the million wrong patterns survive, plus the right one. The other 48 − 20 = 28 bits still have to be searched, but now it's 2^28 candidates for each of five patterns instead of 2^48. That's around 2^30 instead of 2^48, a quarter of a million times less work, and we haven't touched the high bits yet.

This trick only works when n is even but not a power of two. Ruined portals use nextInt(25), and an odd bound gives you nothing to lift. Ancient cities use nextInt(16), and for powers of two Java takes the top bits of the state instead of a remainder, so those can't lift either. Which is why the README says temples, huts, shipwrecks and villages make it much faster.

05 /

Magic trick two: stop guessing, start solving

Lifting leaves blocks of 2^24 candidates: the seed's low 24 bits fixed, its high 24 bits (hHi) free. The obvious move is to loop over all 16 million hHi and test each one. The clever move is to not loop at all.

With the low 24 bits fixed, follow the first step of the LCG through:

  1. The seed is scrambled by XOR with the multiplier. For the high half that's hiS = (hHi + base) ^ MH, a bijection on 24-bit numbers.
  2. One LCG step gives a new high half newHi = hiS · ML + kk (mod 2^24). ML is odd, so this is an invertible affine map.
  3. The 31-bit output is u = newHi · 128 + lo7, where lo7 is fixed because the low half is fixed.

We want u mod n = r, the offset we saw. That's 128 · newHi + lo7 ≡ r (mod n), which is linear in newHi. Let g = gcd(n, 128):

  • If g doesn't divide r − lo7, there are no solutions at all, and the whole block of 16 million is skipped.
  • Otherwise newHi ≡ t (mod n / g) for one t you can compute directly with a modular inverse.

So you only visit every (n / g)th value of newHi and map each one back to exactly one hHi through the inverses of steps 2 and 1. Across all blocks that averages out to visiting 1 in n candidates, every one of which is already guaranteed to get the first offset right:

strategy.tsTypeScript
const toHHi = (newHi: number) => {
  const hiS = Math.imul((newHi - f.kk) & MASK24, ML_INV) & MASK24;
  return ((hiS ^ MH) - f.base) & MASK24;
};
for (let newHi = t; newHi < firstReroll; newHi += m) visit(toHHi(newHi));

ML_INV is the inverse of the multiplier mod 2^24, worked out once with Newton's iteration (x = x · (2 − ML · x), which doubles the number of correct bits each round).

There's one ugly edge. nextInt(n) rerolls when u lands in the last partial chunk of 2^31 that doesn't divide evenly by n. A rerolled candidate's first offset comes from the second draw, so the solver can't rule it out. Those few newHi values are listed separately and always checked. Getting that boundary off by one (a floor that should have been a ceil) meant one value per block was never visited at all.

The second-best observation then acts as a quick filter before anything reaches the full check, and the full check orders the remaining observations by how likely they are to reject a wrong seed per nextInt spent. Most wrong candidates are dead after one draw.

06 /

Magic trick three: JavaScript can't count

JavaScript and by extension TypeScript is bad at math. The search loop runs billions of LCG steps, and in JavaScript that's a problem. A 48-bit state times a 35-bit multiplier is way past the 53 bits a double can hold exactly. BigInt is correct but painfully slow in a hot loop.

So the state lives in two 24-bit halves. A 24 × 24-bit product is 48 bits, which a double holds exactly, so the low half's product and its carry are exact. The high half only needs its own low 24 bits, and Math.imul gives you those for free:

lcg.tsTypeScript
function step31(): number {
  const lo = sLo * ML + 11;
  const carry = (lo / TWO24) | 0;
  const newLo = lo - carry * TWO24;
  sHi = (Math.imul(sHi, ML) + Math.imul(sLo, MH) + carry) & MASK24;
  sLo = newLo;
  return (sHi << 7) | (newLo >>> 17);
}

My favourite bug in the whole project was in this file. At one point the search was about 10× slower than it had any right to be. It turned out the bundler compiles imported constants into getter calls, so every ML and MASK24 in the hot loop was a function call. The fix is one line at the top of the file:

search.tsTypeScript
// Bundlers can compile imports to getter calls, which made this search about 10x slower.
const { fullCheck, TWO24 } = lcg;

07 /

Magic trick four: get off the CPU

The JS version is the reference. The real work happens in two other engines that walk the exact same candidates in the exact same order.

WebAssembly. The search is also written in C and compiled to wasm32 with clang, no libc. C has native 64-bit integers, so the two-halves dance disappears and it runs 2 to 6 times faster than the JS. One worker per CPU core, each taking a slice of the candidate range.

WebGPU. WGSL has no 64-bit integers and no 64-bit products, so the GPU version goes one level further: the low half's 24-bit product is split into 12-bit pieces to get its carry out of 32-bit math. Each workgroup takes one 2^24 block. Batches are pipelined three deep so the GPU never sits waiting for a readback, and each batch is sized on the fly to take about 60 ms. Long enough to keep the card busy, short enough that the browser never decides the tab has hung and kills the device.

Hits go into a fixed buffer of 65,536 slots through an atomic counter. If a batch ever overflows it (you gave it something wildly underdetermined), that batch is just redone on the CPU.

Every candidate from every engine is re-checked against a plain, slow, obviously-correct BigInt implementation before it's shown to you.

Fast code is allowed to be clever. It's not allowed to be the last word.

08 /

People are bad at measuring

The math assumes you know the exact chunk a structure started in. You don't. You know where you were standing when you pressed F3.

So each structure type has a rule for turning your coordinates into a start chunk. Temples, huts and igloos are built inside their chunk, so you stand on the north-west corner. Villages rotate their pieces around the start chunk's corner, and monuments are centred on one, so those round to the nearest chunk border. Ruined portals can stick out a few blocks west or north, so a point in the east or south half of a chunk might belong to the next one over.

That last case becomes a span: the offset is allowed to be one of two neighbouring values. Two consecutive values always differ in their lowest bit, so a span costs exactly one bit of lifting and nothing else breaks.

And when someone just types a wrong number? A second pass looks for close matches: seeds that fit everything except one to three entries. To stop that from finding junk, the entries that still have to match must carry at least 50 bits between them, so a chance match is about a 1 in 4 shot per way of picking the misses rather than a near certainty. The filter uses a pigeonhole trick here: if at most k entries can miss, check k + 1 of them, and any real match has to pass at least one. When it finds a close match, it tells you which entry is wrong and where that structure would really be.

09 /

From structure seed to world seed

Remember those 16 bits that got thrown away? They mean 65,536 different world seeds share the exact same structures. Two tricks get that down to one.

Blank seed boxes. If nobody typed a seed, the game made one with new Random().nextLong(), which is two next(32) calls glued together. Given the structure seed, you know the second call's 32 bits and 16 bits of the first. That leaves 16 unknown bits of internal state. Try all 65,536, step each one backwards through the LCG with the inverse multiplier, and see whether it lines up. Almost always exactly one does. Typed text seeds are spotted too, because String.hashCode only ever produces a 32-bit number.

Biomes. Unlike structures, biomes depend on all 64 bits. A structure only actually generates if the biome under it allows it, so cubiomes, compiled to WebAssembly, checks every one of the 65,536 world seeds and throws out the ones where your desert temple would be sitting in a jungle. If that still leaves a few, tell it the biome at any spot you know, and it narrows to one.

10 /

Was it worth it

Hell no!

Was it fun?? Yeah and I would do it again.

Three days of brute force became under a second, in a browser tab, with nothing uploaded anywhere.

And I can't use it.

Seed Cracker is free software under the GPL v3. Use it on your own worlds, on servers that allow it, or just to see it work: Try example loads a random world for you. And if you play on a server, ask the mods first. I did.

Oh and before you go go checkout the site if you wanna try it without installing the repo to your computer.

Filed under Algorithms

Next post
Mace's are the worst thing to ever exist I cannot land a hit with them

Get in touch

Got something worth building?

me@archery-luna.com
Back to top

Index

  • Home
  • Blog
  • Games
  • Style guide

Elsewhere

  • GitHub
  • LinkedIn
  • CV

Based in

London, UK

Open to interesting projects

Archery Luna.

© 2026 Johan Sebastian Luna

Designed and built by me