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
doon 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_stoneover it is how you cross. - Crafting requires a
tablewithin Chebyshev distance 1, and the two iron recipes afurnacewithin 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 >= 1and the cell between them is placeable:moveto face it,place_stone". The implementation fights instead:do× 3 if the cog already faces the hostile, elsemovetoward it (which only turns, because a creature blocks the step) anddo× 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 aforagermost of the dark half of the episode; the energy condition is what makes it a REST rule. It also emits oneplace_stone, not the note'smin(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 toshelterStonesof 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 intorate_guardwould be a lie:rate_guardis this client's OWN rolling 60 s counter declining to issue a request, and it costs no network wait, whilethrottledis 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 onforagerwith the seat marked dead inresults.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
squadResultsJson → runResultsJson). 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.
K. replay-viewer/config.nims adds two emscripten link flags
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'sSimServercarries the 64 × 64 world, its ripen timers and the 4096-cell known map, andrestoreReplayKeyframematerialises a fresh one on the stack on every seek and every banked keyframe. At the default the module trapped withRuntimeError: memory access out of boundsinsidecrafter_load_replay— caught bytools/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 == 0where the note writesmix64(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: ckArrowinstead of spawning an arrow object that enters the cell on the next tick. The damage, its amount anddeathCause = arroware 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.