Wiki · what-this-is-and-is-not-a-port-of

What this is and is not a port of

Last edited by · ·

What this is, and is not, a port of

cogame-crafter implements the problem posed by Crafter (Hafner 2021) and Craftax (Matthews et al. 2024). It does not port either. No upstream code is vendored, no upstream number is claimed as reproduced, and no score from this coworld is comparable to a published Crafter or Craftax figure.

What is reproduced is the shape of the problem: a 64 × 64 procedurally generated world seen nine cells at a time, day and night, four vitals, the same seventeen actions by name, and the same twenty-two achievements by name and in the same order.

Every divergence is enumerated here.


1. No crafter and no craftax dependency, and no bit-exactness with either

Decided as a scoping rail before design. Crafter is Python (numpy, OpenSimplex, an imageio renderer); Craftax is JAX. Embedding either means a simulator that cannot compile to WebAssembly, and the static wasm replay viewer is a non-optional platform pin — every hosted replay is served from /v2/coworlds/replays/static/<cow_id>/<sha>/index.html, which loads the sim itself in the browser. So the sim is Nim, it compiles twice (natively into /bin/crafter and to wasm through replay-viewer/config.nims), and it is this repo's own rules.

2. Integer value noise, not OpenSimplex

The generator is a hashed lattice of stride 8 whose corner values are mix64(seed, fieldSalt, gx, gy) mod 1024, bilinearly interpolated in 16-bit fixed point. A float noise field cannot be hashed identically native and in wasm, and the per-tick gameHash chain the viewer re-checks is what makes a replay verifiable at all.

The resulting worlds are recognisably the same kind of world — grass plains, water, sand shores, forests, a mountain massif with cave floor, lava and ores at depth — and they are not the same worlds.

3. Episode length

Crafter allows 10 000 steps. This game allows 1344 (56 turns × 24 ticks), because the seat is an LLM on a 720 s budget. The vitals drain rates (40 / 30 / 50), the day length (192) and the ore depths are all scaled to that budget so the tech tree is genuinely completable.

4. Actions are batched under a driver, not stepped one per call

The "per-tick discrete" interface is preserved exactly as the seventeen primitives; what changed is who calls it. Up to twenty-four primitives per LLM turn under a deterministic driver, plus two macros (goto, move) and an n multiplier on do / sleep. One LLM call per primitive would be 1344 calls inside a 720 s budget — impossible — and a policy that cannot express "walk over there" spends every turn walking.

The flinch rule is what keeps batching from removing reactivity: any damage from a creature, an arrow or lava ends the turn on the spot and throws away the rest of the plan.

5. The observation is symbolic, and it carries memory the agent has not got

Crafter's observation is a 64 × 64 × 3 image. This game's is symbolic (the idea names Craftax-Symbolic as what makes LLM play feasible), and it adds two things Crafter does not have: a 16 × 16 downsampled region map of what the cog has explored, and a nearest dictionary of the closest known cell of each notable kind. An LLM re-prompted fresh each turn has no hidden state, so the game keeps the map for it.

Partial observability is untouched: an unexplored cell stays ? until the cog walks somewhere it can see it from, and creatures are never remembered — they appear only in view and in threats, both of which are current.

6. Reward shape

Crafter's per-step reward is +1 per new achievement with ±0.1 for health changes. The league needs one rankable integer, so the score is

scores[0] = 10000 * achievementsUnlocked + survivalTicks

— achievements dominate (1344 < 10 000), survival is purely the tie-break, and the health shaping is dropped entirely: it exists to shape RL gradients, not to rank policies.

7. Semantics stated because they are the ones an implementer guesses wrong

  • move_<dir> sets the facing and steps, and a blocked move still turns.
  • Trees are infinite: a do on a tree yields wood and leaves the tree.
  • Mining stone leaves walkable path — that is how a tunnel is dug.
  • Lava is walkable. Stepping in is instant death, not something the physics prevents; place_stone over it is how you cross.
  • Crafting requires a table within Chebyshev distance 1, and the two iron recipes a furnace within 1 as well.

8. maxGames = 1

The starter's multi-game episode is not used: a survival run has no side to swap.


Divergences from this repo's own design note

The design note is committed verbatim at docs/plans/2026-08-28-crafter-design.md. Every place the implementation departs from it is a lettered section below, in the order they were found — the note's own rule is that a divergence which is not written down is a defect, so this list is appended to rather than summarised, and it carries no count to fall out of date.

A. The playability post-pass has a sixth step: connectivity

The note's five steps guarantee a tree, water and stone exist within reach of spawn. They do not guarantee the cog can walk to one, and 15 of 60 standard seeds spawned the cog on a three-by-three grass island in a lake with all the wood on the far shore — unwinnable, which is exactly what the post-pass exists to prevent ("every seed is completable").

Steps 2-5 are therefore run together, to a fixed point (at most three sweeps; the second is a no-op on any seed the first settled). Steps 2-4 place a tree, water and stone within reach if the generator left none, and step 5 is a deterministic connectivity carve: for each of tree, water and stone in that order, if no cell of that kind touches the land region reachable from spawn, an L-shaped corridor of sand is carved to the nearest one — horizontal first, then vertical, never through the bedrock ring and never over the forced 3 × 3 grass at spawn. The two halves are interdependent in both directions: a corridor sands over whatever is in the way, which can take the only tree step 2 forced, and a replacement tree can in turn sit off the reachable land. One ordered pass leaves both holes open.

The corridor does sand over coal, iron and diamond when they are in the way. A corridor with one unsanded cell in it is not a corridor: on seed 105 a single coal cell severed the only route to the only reachable tree, and no cog can mine coal before it has the wood for a pickaxe. The ore minima are step 6, after this, so any count a corridor spends is restored.

oreHost gained a fallback for the same reason: a seed whose only stone is the single cell step 4 forced would otherwise spend it on coal and end with no iron and no diamond. When there is no stone left the ore goes to the highest-mountain walkable cell at least ten cells from spawn.

tests/test_crafter_world.nim items 2 and 3 are what hold this: over 200 seeds of both variants the invariants hold, a reachable tree, water and stone still exist after the whole post-pass, and over 60 seeds of each a full-knowledge reference solver (test-only, never shipped in the image) reaches collect_diamond.

B. The glyph vocabulary is twenty-one, not twenty

Fifteen terrains + four creatures + @ + ? = 21. The note's prose says "twenty"; its own legend block lists twenty-one, and twenty-one is what the sim emits and what tests/test_crafter_world.nim pins.

C. The terrain digest folds by XOR, not by a sequential mix

The note writes the incremental digest as terrainHash = mixHash(terrainHash, x, y, oldKind, newKind) and then asks (§Tests item 33) for the incremental value to equal a fresh fold over all 4096 cells. A sequential mix cannot satisfy that: the running value depends on the order the mutations happened in, the fold does not.

So the digest is an XOR of per-cell hashes. XOR is its own inverse, so a mutation is digest xor cellDigest(old) xor cellDigest(new) and the two are genuinely equal — which is what makes the optimisation provable rather than merely plausible.

D. The baseline tunables are the sweep's pick, and the sweep moved three of them

The note's prose names "the thirst/hunger thresholds 3, the shelter stone budget 4, the sleep length 12, the explore step count 3, and whether the frontier score breaks ties by distance or by (y, x)" as the tunables. They are a parameter object chosen by a sweep, not guessed (the note's own rule), and the sweep in tools/tune_baselines.nim — 1296 cells, both shipped variants, seeds 1..40, every cell played — picks exploreSteps = 2, sleepTicks = 16 and shelterStones = 2, leaving the two thresholds and the tie-break where the note puts them. tools/ci/baseline_tuning.json is that harness's own output, cell for cell (--json prints the file), and tests/test_crafter_driver.nim asserts the shipped defaults still equal its pick and that every tunable in the pick was actually swept.

The pick is constrained, and the constraint is a shipping requirement rather than a thumb on the scale: the certification fixture is a forager episode on seed 42 of standard, and that same episode is the replay docker_smoke.sh produces and the wasm-viewer job plays in a browser under viewer_smoke.mjs --soak 10. A cell that scores two achievements better by dying at tick 400 leaves the certification replay barely outlasting the soak (the ecos 2026-08-23 scar), so a cell is eligible only if its cert-seed episode survives ≥ 900 ticks and unlocks ≥ 6 — the floor tests/test_crafter_engine.nim asserts directly.

The sweep also carries a tunable the note does not name, restThreshold, and forager's rules 1 and 4 differ in shape from the note's ladder:

  • Rule 1 (under attack) — the note's middle branch is "if stone >= 1 and the cell between them is placeable: move to face it, place_stone". The implementation fights instead: do × 3 if the cog already faces the hostile, else move toward it (which only turns, because a creature blocks the step) and do × 3, else back away by BFS to the reachable known cell farthest from it. Two of the twenty-two achievements (defeat_zombie, defeat_skeleton) are only reachable by fighting, and a baseline that walls itself in at Chebyshev 2 never unlocks either.
  • Rule 4 (night shelter) — fires on (not daylight and energy <= restThreshold) or energy <= 2, where the note's rule 4 has no energy condition at all. Sleeping at every nightfall regardless of energy costs a forager most of the dark half of the episode; the energy condition is what makes it a REST rule. It also emits one place_stone, not the note's min(4, stone): move_<dir> steps into a walkable cell and every open side is walkable, so "turn to face an open side" is not expressible in the action set — the only side a cog can wall off without walking out of its own hole is the one it is already facing. It seals that one, and up to shelterStones of them across consecutive turns.

E. wanderer rotates past lava rather than blindly

The note describes wanderer as rotating the facing clockwise whenever the cell ahead is not traversable. Taken literally that walks the control policy into lava on the first rotation — move steps into any walkable cell and lava is walkable. It rotates past known lava instead, which is the smallest change that keeps it a four-line reactive control and not a suicide.

F. The directive record has a size cap the note does not name

MaxDirectiveRunes (src/crafter/sim_types.nim) caps the whole serialised directive record. The note caps say (160 runes) and notes (400) but puts no bound on the record, and an unbounded record is an unbounded replay.

The cap is 6000 runes, sized so that a whole observation (≈3800 runes: the 9 × 9 window, the 16 × 16 region, the legend, 22 achievement names, up to 24 landmarks and the executed queue) plus a full-cap say fits with room to spare, because the note's reason for mirroring the observation into the record is that "the replay explains every decision". say shrinks first and the view is dropped only if a record cannot fit with no say at all — which no observed episode reaches.

G. The derived event enum has a twenty-second kind: budget

The note's §Record and event vocabulary B is "a closed enum of twenty kinds, plus end", and its §Tests item 48 asks for exactly twenty-one. This repo emits twenty-two: the twenty-one plus budget {turn, remaining_s}, derived from the budget_guard chat record the note's §Record vocabulary A already lists.

The record exists in the note; without a derived event for it the feed has no way to say why every remaining turn is suddenly scripted, and phase 60 reads the same fact out of the replay either way. budget is not a beat — it does not reach the scrubber — and tests/test_crafter_events.nim pins the emitted set at exactly these twenty-two.

H. fallback.cause carries throttled, and never carries disconnected

The note's §Degrade, never hang closes the cause enum at timeout | parse_error | transport_error | no_credentials | rate_guard | budget_guard | disconnected. This repo emits one cause outside it and never emits one that is in it:

  • throttled — the provider answered 429 and there is no other model to rotate to (src/crafter/llm.nim, src/crafter/decide.nim). Folding it into rate_guard would be a lie: rate_guard is this client's OWN rolling 60 s counter declining to issue a request, and it costs no network wait, while throttled is the provider refusing one that was issued. Phase 60 needs to tell "we throttled ourselves" from "they throttled us".
  • disconnected — never emitted. There is exactly ONE seat; a seat whose socket drops does not leave a round barrier waiting, because there is no barrier. The run plays out on forager with the seat marked dead in results.deadSeats, which is the same fact recorded in the right place.

I. The cog and the creatures are committed image-model renders, not a rig_art.nim composite

The note's §Art has the cog as data/soldier_red.png "composited by rig_art.nim into 4 facings × 2 sizes", with cows, zombies and skeletons composited from the same rig. There is no rig_art.nim in this repo. The terrain bed is exactly as the note describes it — one pixie bake at install over data/arena_floor.png and client/art/walls/wall_{h,v}.jpg, with the achievement icons cut from the baked tiles — but the cog and the three creatures are committed PNG sprites (data/art/*.png), split out of two gemini-2.5-flash-image renders under scripts/art/source/ by scripts/art/split_cog_sheet.py.

The renders are anchored on the shipped data/soldier_red_front.png master, so the cast is one style and one lineage; the art is committed, so nothing is downloaded at build or at runtime and the bundle is reproducible. What the divergence buys is a heading that is readable at a 24 px tile without a label — a re-tinted rig at that size reads as four identical smudges, and this board has showPlayerLabels: false and no text on the board layer at all, so the sprite is the only thing that can carry the facing.

J. There is no roster.nim; its three named edits live in sim_state.nim

The note's Kept table maps src/ctf/roster.nim to src/crafter/roster.nim with three named edits (the alias, the achievement ids, and squadResultsJsonrunResultsJson). All three behaviours are present and all three are asserted, but the module split is not: IdentityNames, seatAlias, addPlayer, seatName and runResultsJson are in src/crafter/sim_state.nim, and recordAchievement with the achievementTick[22] array is in src/crafter/achievements.nim.

The starter's roster.nim is a multi-squad roster: teams, squads, per-team score arrays, join/auth across four seats. With num_agents fixed at 1 there is one seat and no squad, and what survived the retarget was a dozen procs over the sim's own state — a module boundary between them and the sim would have been a header for a struct with one member.

The note's Kept table forks replay-viewer/config.nims for "identifiers and the output name only". Two link flags are added on top of that (a diff against the starter shows exactly these, plus the renames):

  • -s STACK_SIZE=8388608 — emsdk's default is 64 KB since 3.1.27. This game's SimServer carries the 64 × 64 world, its ripen timers and the 4096-cell known map, and restoreReplayKeyframe materialises a fresh one on the stack on every seek and every banked keyframe. At the default the module trapped with RuntimeError: memory access out of bounds inside crafter_load_replay — caught by tools/wasm_replay_smoke.cjs, invisible to every native shard, because natively the stack is megabytes. 8 MB is emscripten's own pre-3.1.27 default.
  • -s INITIAL_MEMORY=33554432 — a 1344-tick replay of a 4096-cell board is the biggest thing the module has to hold, and growing linear memory from 16 MB during the load-time pre-scan is a stall on the frame it rides on.

The starter's page is 169 cells and its replays are a fraction of the size, so neither flag is a correction of the starter — they are this board's dimensions. CI proves both: the wasm-viewer job steps the emitted module over the committed fixtures and reports heap 32 MB.

L. #viewpanel is kept, unmodified — and moved in the markup

The note's §Viewer keeps #viewpanel, its children, its CSS and the page's core.attachMinimap($('minimap-canvas')) call "all kept, unmodified". They are. What moved is the panel's position in the document: it is now a sibling of #status, after #scrub, instead of sitting above #mmwarn.

tools/ci/viewer_smoke.mjs finds the scrubber with the selector list #scrub, #seek, input[type="range"] and takes .first(), which CSS resolves in DOCUMENT ORDER. With #zoom-slider ahead of #scrub the harness's scrub readouts drove the ZOOM BAR: at the 100 % click that is setZoom(maxZoom), and a software canvas asked to blit a 1536 × 1536 surface into a ~9600 × 9600 destination is where CI runs 33225446565 and 33226980062 both died. The panel is position: absolute, so its markup position has no effect on screen, and the move is made by tools/build_broadcast_page.py as an enumerated edit — ci.yml re-derives the page against the pinned starter on every push.

M. Playback runs at one tick per animation frame, and the speed chips are integers

The note's §Transport rules asks for "one tick per three animation frames at 30 fps = 10 ticks/second (speed chips [0.5, 1, 2, 4, 8], default 1)", so a 1344-tick episode plays for 134 s.

This viewer plays one tick per presentation frame at TargetFps = 24, with PlaybackSpeeds = [1, 2, 4, 8, 16, 32] and default 1 — the starter's own transport, whose speed multiplier is the integer tick budget per frame (replayStepBudget) and cannot express a half. A 1344-tick episode plays for 56 s, and the CI smoke's 949-tick replay for ~40 s, measured in the wasm-viewer job: soak: 10s of playback kept advancing ("2 / 950" -> "194 / 950" -> "242 / 950").

The arithmetic the note's cadence exists to protect is the one that matters — a replay must comfortably outlast viewer_smoke.mjs --soak 10 rather than finish inside it (the ecos 2026-08-23 scar) — and 40 s of playback clears a 10 s soak four times over. Halving the tick rate to reach exactly 10 ticks/s would mean a fractional speed chip in a transport that is otherwise the starter's verbatim.

N. The provider ENVELOPE is read up to 16384 bytes; the reply TEXT is capped at 4096 runes

The note's §Reply schema caps the "whole reply" at "≤ 4096 bytes read from the provider before parsing". Taken as the envelope that cap is unusable: what comes back from the provider is a JSON document wrapping the model's text in id / model / usage / content fields, and cutting it at 4096 bytes makes parseJson raise on every non-trivial reply — a parse_error fallback on every turn, which is the opposite of what the cap is for.

So src/crafter/llm.nim reads up to 4 × MaxReplyBytes = 16384 bytes of the envelope before parsing, and the model's own text — the only part that reaches the replay — is capped at MaxReplyBytes runes afterwards, on a rune boundary. Nothing byte-truncated ever reaches a record: a cut envelope raises inside parseJson, the caller turns that into a parse_error fallback, and the rune cap is what the truncation tests pin.

O. Within one tick, creatures enter the array by KIND, not by (spawnY, spawnX)

The note's §Day, night and the creatures has the three kinds in "a single stable array ordered by (spawnTick, spawnY, spawnX)". The array is single and stable, and identical on record and on playback — which is what the hash chain needs — but the tiebreak WITHIN a tick is the spawn pass's order, cow then zombie then skeleton, and new arrows are appended after the whole pass.

There is at most one spawn attempt per kind per tick, so the difference is only ever between two or three creatures born on the same tick, and nothing reads the array in a way that makes their relative order observable: stepCreatures walks it by kind pass regardless, creatureAt is a position lookup and creatures never share a cell, and the baselines pick by distance. Sorting the at-most-three by (y, x) would change every recorded gameHash — the hash mixes the array in order — for no behavioural difference at all.

P. Two small formula differences, stated because they are visible in the source

  • The sapling draw is mix64(seed, 600, idx(x, y), tick) mod 10 == 0 where the note writes mix64(seed, 600, x, y, tick) mod 10. Same 1-in-10 rate, same determinism, one fewer mixed word: idx(x, y) is the cell's slot index, so the draw is still a pure function of (seed, cell, tick).
  • When the cell a skeleton would put its arrow into IS the cog's cell, the 2 damage is applied on the spot with by: ckArrow instead of spawning an arrow object that enters the cell on the next tick. The damage, its amount and deathCause = arrow are what the note specifies; the intermediate object is elided because an arrow that spawns inside the cog would have to resolve its own entry rule against the cell it was born in.