Play
  1. Tyamka
  2. Devlog
  3. Fifteen levels from a seed, without hand design

Fifteen levels from a seed, without hand design

· 7 min read · Tyamka team

How every Tyamka game builds its levels from one 32-bit seed, why each game has a written spec with test vectors from a second implementation, and how Pipes makes sure a board has exactly one answer.

In this article
  1. A spec before the code
  2. One generator, small enough to retype
  3. Vectors from a second implementation
  4. Pipes, a board with one answer
  5. What it buys on the server
  6. What is still designed by hand

Nobody at Tyamka draws a level. Each of the 15 games builds its levels from one 32-bit number, the seed, with the mulberry32 generator. The same level and seed give the same board on a phone, in a browser and on our server. What we write by hand is a table of 15 rows per game and a spec with test vectors that any implementation has to reproduce bit for bit. In Pipes a solver also makes sure every board has exactly one answer.

A spec before the code

Every game has a folder in games/ with the logic, a spec.md, a vectors.json and a test. The spec opens with a version, a date and the sentence that sets the bar. "Any port (backend replay, another client) must reproduce every vector bit for bit."

Most specs share the same sections: rules, scoring, how a level is generated, the level table and what the vectors contain. The rules describe what each input does to the state, down to a tap on a number already found or a move after the level ended. Both are ignored, and the spec says so, because a second implementation will meet them too.

The level table is the part a person designs. For the Schulte table, level 1 is a 3×3 grid counted up and level 15 is 7×7, with counting down and reshuffles on some levels in between. The spec gives the reason for the ceiling too. 7×7 is the largest grid that keeps a 44 pt cell on a 375 pt wide phone. When a rule changes, so does the version, as it did for Opposites on 26 September.

One generator, small enough to retype

All fifteen games draw from one function in games/shared/random.ts.

export function nextUint32(state: number): [value: number, state: number] {
  const s = (state + 0x6d2b79f5) | 0;
  let t = Math.imul(s ^ (s >>> 15), 1 | s);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return [(t ^ (t >>> 14)) >>> 0, s >>> 0];
}

This is mulberry32. Its whole state is one unsigned 32-bit integer, so the seed is the starting state and there is nothing else to save. Math.imul and >>> 0 keep every step in 32-bit integers, so the result never depends on floating point. The spec writes the same steps as plain arithmetic modulo 2³², so a port never has to read the TypeScript.

The shuffle on top is Fisher–Yates from the last index down, with next() mod (i + 1) picking the swap. The Schulte spec puts the bias of that modulo below 2⁻²⁶ and makes it part of the spec, since a port that removed it would build different boards.

Game modules never call Math.random and never read the clock. The app picks a seed for each attempt, passes it in and saves it with the result. The daily challenge takes its seed from the date, nextUint32(dayNumber), so every player gets the same game, level and seed that day.

Vectors from a second implementation

vectors.json is the contract written as data. The shared vectors pin the generator itself. For seed 0 the first three outputs must be 1144304738, 1416247 and 958946056.

test.each(vectors.prng)('prng seed $seed', ({ seed, first }) => {
  let state = seed;
  const out: number[] = [];
  for (let i = 0; i < first.length; i++) {
    const [value, next] = nextUint32(state);
    out.push(value);
    state = next;
  }
  expect(out).toEqual(first);
});

A Schulte case lists the level, the seed, every tap with its time, the grid before and after, the outcome of each tap and the final score. What matters is where those numbers come from. Cases named generated: were computed by an independent Python port written from the spec, not from the TypeScript. Cases named hand: are edge cases worked out by hand. When the TypeScript passes a vector the Python produced, two implementations have read the spec the same way. That says more than a test written next to the code it checks.

The shared shuffle vector also turns 1 to 9 with seed 42 into 7 8 2 5 4 6 9 3 1. Schulte level 1 starts by shuffling exactly those nine numbers, so the 3×3 table for seed 42 is that list read row by row.

Pipes, a board with one answer

In Pipes you rotate tiles until every opening meets another and all tiles form one network. The game checks that rule, so it would accept a second solution. But a board with two answers cannot be solved by reasoning alone. And after a timeout the game shows the stored solution and marks every tile that differs from it, which is only fair if no other solution exists.

So the generator grows a random spanning tree from the center tile, with no four-way tiles, and asks a solver in how many ways its tiles can be turned into a solved board. Anything other than one, and the tree is dropped and the next one grows from the same random stream. Then every tile is turned at random to make the starting board, and turned again if that board comes out already solved. How to solve a pipe puzzle shows such a board from the player's side.

The solver, countSolutions in games/pipe-connect/pipe-connect.ts, keeps for each cell a 16-bit set of the masks its tile can still take. Propagation drops a mask with an opening toward a neighbor that cannot open back, and a mask without that opening when the neighbor has to open toward it. It repeats until nothing changes, and the result is the same in any sweep order. If cells still have a choice, the search branches on the cell with the fewest options (the lowest index on a tie) and tries its masks in ascending order. It stops at the second solution or after 20,000 nodes. A full assignment counts only if every tile links to the source, which rules out loops cut off from the rest.

The fixed order makes the node count deterministic, so the vectors store it, and the Python port matched it exactly. On the first board of level 1 with seed 42 the search takes one node, so propagation alone solves it. In the vectors each tile is a 4-bit mask (up 1, right 2, down 4, left 8). The 14 at the top is open to the right, down and left, and the 13 in the center is the source, open up, down and left.

solution     start
 2 14 12      4  7  9
 6 13  1      6 14  4
 1  3  8      4  6  1

The cap is there so a phone can never hang on a bad board, and it is never reached. The hardest boards to make unique are the wrapping ones, where a pipe that leaves one edge comes back on the opposite edge. On 9 November we reran level 15, the largest of them, for seeds 0 to 1,999. The longest search, rejected trees included, took 927 nodes, the same figure the spec recorded in September. On level 11, a 4×4 wrapping board, the generator throws away more than two trees for every puzzle it keeps. Target Sums uses the same generate-and-check loop with an exhaustive solution counter.

What it buys on the server

Since a level is just the game, the level number and the seed, the server can rebuild it. The score check runs the same game modules under Deno and replays the player's moves, as the entry on replaying scores on the server explains. For the daily challenge it also works out the day's game, level and seed and rejects a run that does not match.

There is a limit. For an ordinary level the seed comes from the app, so a script could try seeds until it finds an easy board. A seed issued by the server is the planned fix.

What is still designed by hand

The level tables, from grid sizes and time limits to the level where Pipes starts to wrap. Most specs call these numbers hypotheses until playtests. They are the one place where a person decides how hard a level is. The other logic games are built the same way, each from its own spec.