AX note · Method

A reverse-construction generator and the gate behind 1,100 arrow-puzzle levels

Once the move rule is written down and removal can only open paths, a board built in reverse is solvable without ever being played. Per-level gates then reject dull or unfair boards, and G2 checks the difficulty curve of the whole pack.

By SugarMount Game development 11 min read Last updated

The generator built all 1,100 levels in Arrow Knots (the main game, 1 to 1000, plus an endless-mode fallback pack, 1001 to 1100), tutorials included; for the three tutorials the code fixes only the size and target windows. The pack first passed the G2 pack gate in commit e3c7781 at 18:32 on October 3, 2026, and in the current report all 1,100 levels pass the six structural gates checked per level. Two days earlier, generator v2 (6249b11) cut F0, the share of arrows you can remove the moment a board opens, from 0.50 to 0.11 in the hardest band of the generator benchmark.

Sources: the game repository's git history (ecedf0b, 6249b11, e3c7781, 9de89eb), ADR-0001, the level reports report-v2 and report-v3 (.md and .csv), the kickoff cross-check document, and session logs from October 1 and 3, 2026. Report numbers were re-read at HEAD on October 28, 2026.

Contents (8)
  1. Randomly placed boards were all stuck from 10×10 up
  2. The move rule was fixed in an ADR before any generator work
  3. Half the arrows in v1 were free from the start
  4. Defects found in 300 levels became G2 checks
  5. What G2 missed: a repeating mask and per-level failures
  6. Seven steps, from a monotonicity test to a byte comparison
  7. F0 and depth have not been checked against real play
  8. FAQ

Randomly placed boards were all stuck from 10×10 up

In Arrow Knots you tap arrows to slide them off the board. An arrow leaves along the line in front of its head (the code calls it the escape ray; below, just the ray). If another arrow sits on that ray, the tap bounces and costs a heart. The kickoff report of September 30, 2026 said boards would be generated and then checked with "an actual removal simulation" to see whether they could be cleared.

A cross-check simulation run the same day ruled that out. With arrows placed at random, 64% of 8×8 boards with 10 arrows could not be cleared, 92% with 16 arrows, and 100% from 10×10 with 25 arrows. Levels 861 to 1000 ship on 24×44 boards with a median of 73 arrows, a size a discard-and-retry loop would never reach.

The alternative is reverse construction. Start from an empty board and add arrows so that the k-th arrow placed is the k-th from last to leave. Accept an arrow only if its ray crosses no arrow already on the board, and the placement order, reversed, is a solution. A later arrow may sit on an earlier arrow's ray; that blocking is where difficulty comes from. In the same simulation, 200 of 200 boards built this way were solvable.

Comparison: random placement then verify leaves 100% of boards unsolvable from 10×10 with 25 arrows; reverse construction checks only that each new arrow's ray is empty, and all 1,100 shipped levels are solvable.
Generate-then-verify stalls as boards grow; building in reverse makes the build order the solution.

The move rule was fixed in an ADR before any generator work

Reverse construction depends on monotonicity: once an arrow can leave, it stays able to leave. The kickoff report never said how an arrow moves. A bent arrow could slide out snake-style behind its head, or the whole body could translate in one direction, and the two models block different cells. In the cross-check, 400 boards built for the head-following model were replayed under whole-body translation, and only 52 of them (13%) could be cleared.

ADR-0001 (an architecture decision record), accepted with the first commit (ecedf0b) on September 30, closed that gap. The ADR has the generator, the removal check, the hint and the animation share that one ray computation (the check and animation call Board.escapeRay; the generator calls the same computation inside it, size.ray). A blocked tap costs a heart and leaves the board unchanged. Together these make removal monotone, so a board is solvable exactly when its blocking graph (arrow i waits on j when j sits on i's ray) has no cycle.

Solvability then needs no search, only a peel: remove every arrow that can leave right now, repeat, and count the rounds, which gives the depth (layers). The release gate's solvability check asks for both a successful peel and a separate cycle search (DFS). Property tests, run before every commit, guard monotonicity with 300 generated boards and 1,000 runs that clear boards in random order.

Pin the move rule down in an ADR before you write a puzzle generator. Only 52 of those 400 boards were solvable under both models.

Half the arrows in v1 were free from the start

The v1 generator in ecedf0b, written with Claude, produced 300 solvable levels that were shallow. Even the hardest band in the v1 report, expert, had F0 at 0.494 and a depth of 5.6. Half the arrows could be removed from the start, and about five peels cleared the board.

At 22:32 on October 1, the main agent handed generator research to a sub-agent with a list of things to try: score candidates by how many existing rays they block, and add a final pass that turns free arrows into blocked ones. A ray-cover score was already in v1. The sub-agent worked out the direction: the last arrow placed leaves first, so new bodies should land on the rays of arrows that are still free. On 60 boards from the hard band (11×13), that score alone (freeWeight) gave F0 0.51, no better than v1 as it was (0.48). Late in a build there are almost no candidates in those spots, and the very last arrow is free no matter what. The final pass (blockerPlacements, which adds an arrow only if it blocks two or more free arrows) changed nothing.

What held F0 at 0.5 was empty rays. An arrow whose head sits on the rim pointing outward has a ray of zero cells, and nothing can ever block it. Rejecting arrows whose ray crosses no usable cell (minRay=1) brought F0 to 0.38 on its own. Adding tail growth took F0 to 0.30. If heads stay put and only tails extend into neighbouring empty cells, no ray changes and blocking edges are only added, so F0 can only fall and depth can only rise. Each extension runs a DFS to check that no cycle forms. Tails first grow only into the rays of free arrows, then into any empty cell to raise density (fill growth). Leftover holes get new arrows only when the blocking graph stays acyclic.

Bar chart: F0 over 60 hard-band boards is 0.48 for v1, 0.38 with minRay, 0.51 with the freeWeight score alone, 0.30 with tail growth, 0.27 with fill growth and 0.16 with hole fill.
The freeWeight score alone did not lower F0 (0.48 vs 0.51); rejecting empty rays, tail growth and hole fill brought it down to 0.16.

The research report came back 17 minutes later, and this combination is v2, committed as 6249b11 at 22:53. The commit message compares the expert band of the benchmark: F0 0.50 to 0.11, depth 5.8 to 14.6, density 0.74 to 0.97. Drawing long random paths up front, without growth, gave density 0.65 and F0 0.44, so it was dropped.

Defects found in 300 levels became G2 checks

At 15:48 on October 3 the operator asked whether the app simply ended after level 300, and seven minutes later asked for 1,000 levels. The agent re-analysed all 300 v2 levels. Boards were squeezed onto one screen as they grew, so the average cell size in levels 276 to 300 was 13.6 dp, and the smallest boards were down to 11.7 dp; after level 150 depth stayed between 16 and 21 while only the arrow count climbed. Counting again from the report CSV: level 293 had 94 arrows after 34 on level 292 (2.8×), and level 243 had 76 after 30. Even level 300, a regular level, had 109 arrows after 77.

The agent laid out two options, a board-size cap or large boards with zoom and scroll as competing games use, and recommended the cap. The operator chose large boards and set the ranges: one size cap for levels 1 to 200, then boards growing through 201 to 500, 501 to 700 and 701 to 1000. The agent turned that into keeping cell size fixed and letting the board outgrow the screen, and added the number: cells of at least 27 dp in Part 1.

The spec then turned each defect into a gate. Levels have roles: a ramp is a regular level on the difficulty curve, a spike is the one hard level in each cycle, and a breather is an easier level. T is an estimated solve time computed from arrow count and F0. The table shows the G2 results in the current report-v3.md.

Defect in the 300 levels, or new structureG2 checkResult (1,100 levels)
Cells averaged 13.6 dp, min 11.7 dpPart 1 cells ≥ 27 dp at 360 dp widthmin 27.3 dp
One hard level at 2.8× the level beforeSpike T over the preceding ramps' median: median 1.10 to 1.25, p90 ≤ 1.30101 spikes, median 1.12, p90 1.18
Arrow count flat for 50 levels (201 to 250), then up 40% in the 251sNon-spike, non-silhouette levels ≤ 1.15× the arrows of the previous regular level; breather T 0.60 to 0.90× the preceding ramps' median0 neighbour violations; 112 breathers, 0.61 to 0.88
Harder only by getting biggerMedian depth of 941 to 1000 ≥ 1.6× that of 101 to 20029 vs 14, 2.07×
Four parts with growing boards (ranges set by the operator)First 10 levels of a new part: arrows ≤ 1.10×, depth ≤ 0.85×All four transitions pass (201: 1.05×, 0.75×)

All 1,100 levels pass the six structural gates checked per level: L/A (depth per arrow, at most 0.60 on ramps), single-wave runs (no more than four rounds in a row that free only one arrow), F0, random-tap win rate, size caps and solvability. The random-tap win rate comes from 100 runs per level of tapping any arrow with three hearts. Counting in report-v3.csv, 1,071 of the 1,095 levels from level 6 on score 0, and every level from Part 2 on scores 0.

Random numbers come from the project's own SplitMix64 instead of Kotlin's Random(seed), which the cross-check flagged as unstable across versions, and e3c7781 records that two builds produced byte-identical packs.

What G2 missed: a repeating mask and per-level failures

Thirty minutes after the pack passed G2, at 19:02, commit 9de89eb fixed G2 itself. Endless-mode silhouettes reused the same mask a few levels apart on every 140-level cycle (castle at 1000 and 1005, fish at 985 and 1015, trophy at 995 and 1025). G2 grouped masks by the pair (name, mirrored), so a mirrored copy counted as a different picture.

128 levels missed their target window and shipped the closest candidate, and fallback level 1060 shipped while failing the per-level sawtooth check, because G2's sawtooth row looks only at the median and p90 of the 101 spikes. The random-tap cap of 0.5 is loose as well: level 7 sits at exactly 0.50.

G2 reads pack files on the JVM, so it could not see a packaging problem (aapt stripping .gz, which crashed the app on launch); that story is in "Use hundreds of agents" in practice. The pipeline also keeps going when G2 fails: it writes every pack, then prints "G2 FAILS" to standard error. What catches a G2 failure is CurveGateTest, which recomputes every level's stats from the committed pack, runs G2, and fails unless the recomputed CSV matches the committed report-v3.csv; the repository rule is that it passes before every commit.

ADR-0001 says that rotating arrows, arrows that stop on collision, keys and locks, or moving tiles break monotonicity and require a new ADR and a search-based verifier (BFS or SAT) first. T and random-tap win rate are model values; real play time was tuned separately in the par-time simulation that cut targets by 30%.

Seven steps, from a monotonicity test to a byte comparison

Any puzzle where removing a piece can only open paths can follow the same order.

  1. Write the move and removal rule as an ADR, and have the check, the hint and the animation share one ray computation.
  2. Test "removable stays removable" as a property: clear boards in random order, around 1,000 runs.
  3. If the rule is monotone, build in reverse. Accept the k-th piece only if its ray misses every piece already placed, and reject pieces with an empty ray.
  4. Raise difficulty by growing tails with heads fixed, checking for a cycle with a DFS on every step.
  5. Write F0, depth, L/A, single-wave runs and random-tap win rate for every level to a CSV.
  6. Enforce the pack gate as a test that recomputes the metrics from the boards and fails if they differ from the committed report.
  7. Derive seeds from the level number and a generator version, and bump the version when output changes. Build twice and compare bytes, then open the packaged app on a device.

Steps 3 and 4 and the depth count, as pseudocode:

place(a):                       # reverse construction
  if usable(ray(a)) == 0: reject  # an empty ray can never be blocked
  if ray(a) hits occupied: reject   # ray must miss every placed arrow
  accept(a)

growTail(a, cell):              # head fixed, ray unchanged
  waiting = arrows whose ray crosses cell
  if cell not empty or not next to a's tail end: skip
  if a at max length or cell on ray(a): skip   # never block its own ray
  if reaches(a, waiting): skip    # DFS: growing would close a cycle
  extend a's tail into cell

depth(board):                   # peel
  layers = 0
  while board not empty:
    if nothing removable: return unsolvable
    remove every removable arrow; layers += 1
  return layers

In this repository ./gradlew :arrow-engine:test :level-pipeline:test runs the property tests of step 2, the G2 test of step 6 and the determinism tests of step 7.

F0 and depth have not been checked against real play

The F0 and depth numbers here come from the generator and its reports; they have not been compared with where real players get stuck. With simulated players, the check that spikes clear at least 5 points less often than ramps in the same band failed in retune commit ab3f78c. At 22:28 the same day, spec commit 91d3c8c replaced it with a solve-time ratio of 1.10 to 1.25 and a rule that spikes clear no more than 3 points above ramps. Simulated clear rates are model values too, so how they match real play is still unknown.

FAQ

Can generated puzzle levels be guaranteed solvable?

They can if the rules are monotone. In Arrow Knots the reversed build order is a solution, and the release gate re-checks all 1,100 levels with a peel and a DFS cycle search.

What is F0 and why lower it?

F0 is the share of arrows that can be removed the moment a board opens. In v1's expert band it was 0.494: half the arrows could be removed from the start. The shipped pack sets a target window per level role (ramp, spike, breather); in report-v3.csv the median for Part 4 (levels 701 to 1000) is 0.11.

This note is written from values SugarMount measured itself; an AI coding agent was used to draft it.

All notes on Game development · All AX notes