What this is and is not a port of
Last edited by · ·
What this is, and is not, a port of
cogame-minecraft is an in-spirit reimplementation of the MineRL
ObtainDiamond problem as its own deterministic seeded simulator, written in
Nim on the coworld-ctf (paintbot) stack. This file is the complete list of
places where it departs from Minecraft, from MineRL, or from the design note
it was built to — so nobody has to reverse-engineer the difference from the
code.
1. No Malmo, no MineRL, no MineDojo, no Minecraft, and no bit-exactness
Decided as a scoping rail before design. Minecraft is a JVM game behind Malmo's C++/Python bridge; MineRL is Python plus a JVM server; MineDojo adds a task suite and a video-pretrained reward model. Embedding any of them means a simulator that cannot compile to WebAssembly, so the static replay viewer — a non-optional platform pin — would be impossible, and a per-frame pixel policy interface is not a coworld interface.
No upstream code is vendored, no upstream number is claimed as reproduced, and no score from this coworld is comparable to a published MineRL, VPT or Voyager figure. What is reproduced is the problem: the ObtainDiamond ladder, rung for rung, in a seeded world you have to dig through.
2. Top-down 2-D cells on four discrete levels, not 3-D voxels
The idea's "per-frame keys + camera" interface is the one it calls "heavy"; it also names the realistic alternative — "LLM agents work through a skill library (Voyager-style), which is the realistic coworld interface". This game's seventeen primitives plus three macros are that skill library, made explicit and made deterministic. There is no camera to aim, no continuous look, and no pixel observation.
3. Integer value noise, not Minecraft's world generator
A hashed lattice of stride 8 with fixed-point bilinear interpolation, because a float noise field cannot be hashed identically native and in wasm. The result is recognisably the same kind of world — grass and trees on top, stone and coal below it, iron deeper, diamond and lava at the bottom — and is not the same worlds.
mix64 is splitmix64 over the mixed words, read as a pure HASH rather than a
stream, so nothing a policy does can shift a draw, reorder draws, or consume
one out from under a later tick: the world of seed s is the same world
however the cog plays it.
4. Eleven rungs, not twelve, and a strict doubling
MineRL's schedule contains two ties (stick/crafting_table at 4,
furnace/stone_pickaxe at 32) which make two materially different runs
score identically, and a league needs a total order. This game drops stick
as a scored rung (it remains a required crafted item and a real prerequisite
for all three pickaxes) and re-values the remaining eleven as a strict
doubling 1, 2, 4, …, 1024. That makes milestoneScore exactly the milestone
bitmask and makes "one rung deeper always wins" an integer identity.
5. Episode length
MineRL's ObtainDiamond allows 18 000 steps (15 minutes at 20 Hz); this game
allows 960 (deepcut: 640), because the seat is an LLM on a 720 s budget.
The world size, the ore densities and the recipe costs are all scaled to that
budget so the ladder is genuinely completable.
6. Actions are batched under a driver, not stepped one per call
Up to twenty primitives per turn under a deterministic driver, plus three
macros and an n multiplier. One LLM call per primitive would be 960 calls in
a 720 s budget — impossible — and a policy that cannot express "tunnel east"
spends every turn walking. The interrupt rule (a newly known adjacent lava
ends the turn) is what keeps batching from removing reactivity.
7. No survival layer at all
No hunger, no health bar, no mobs, no day/night, no drowning, no fall damage,
no tool durability. Minecraft has all of them and cogame-crafter already
ships a coworld about them. Here the only lethal thing is lava and the only
clock is the deadline, because the thing being measured is how deep a policy
can plan, not whether it can stay fed.
8. No BASALT, no MineDojo language tasks, no LLM judge
The idea offers a judged-task league as an alternative motive; it is ruled out of v1. Out: find-a-cave / build-a-house / pen-the-animals objectives, a fixed rubric, an LLM judge, a judge audit trail, and any per-episode score that is not a deterministic function of sim state.
9. Trees are finite; mining stone leaves walkable tunnel; a shaft is permanent
Stated because they are the ones an implementer guesses wrong.
10. maxGames = 1
The starter's multi-game episode is not used; a single run has no side to swap.
Departures from the design note itself
The design note (docs/plans/2026-08-29-minecraft-design.md) is
authoritative and was implemented as written. These are the places where the
note's own text and the shipped repo differ, and why.
A. window.CTF_WIRE survives as an alias
The note requires client/chrome_common.js byte-for-byte (sha256
7ace7287e0d19bf0fddb2362c55e4d76dfb44adcd4fbc8d1743b0557ced72f7c) AND the
window.CTF_WIRE → window.MINECRAFT_WIRE rename. That file reads
window.CTF_WIRE, so the two requirements are only jointly satisfiable
through an alias. src/minecraft/wire_constants.nim emits
window.MINECRAFT_WIRE={...};window.CTF_WIRE=window.MINECRAFT_WIRE;
and client/broadcast_core.js reads MINECRAFT_WIRE first, CTF_WIRE
second. ci.yml's ctf_/CTF_ sweep whitelists that one line by name.
B. The forbidden-vocabulary sweep is scoped to spectator-visible strings
The note asks tests/test_minecraft_endcard_labels.nim to find zero matches
for Lives, flag, heart, paint, hill, team, kill and friends
"outside comment blocks". Applied to every identifier in the page that is
unsatisfiable while ALSO inheriting the starter's chrome verbatim: teamCol,
activeTeams, teamName, .ec-tname and killMarkerTeam are
chrome_common.js's own exported names, and chrome_common.js is pinned
byte-for-byte.
The test therefore parses the page and sweeps the spectator-visible
strings — text nodes plus title, aria-label and alt — which is what
the rule is actually protecting: a spectator must never read paintbot's
vocabulary. Every re-mapped string the note enumerates is separately asserted
present exactly once. The team word is dropped from the forbidden list for
the same reason (it appears only in inherited CSS class names, never in a
string a viewer reads).
C. Lava's cave gate is 300, not the note's 120
The note's underground rule 2 is C < 120 and a per-cell draw below
lavaChance[z]. Implemented literally, that is a game with no hazard in it:
C is the interpolated cave field over 0 … 1023, so C < 120 selects only
the densest few per cent of rock, and the product with a 12-45 permille draw
measured, over 300 seeds of each variant:
| gate | z = 2 | z = 3 | seeds with any lava | |
|---|---|---|---|---|
C < 120 | standard | 0.11 cells | 0.38 cells | 97/300 (32 %) |
C < 120 | deepcut | 0.21 cells | 0.58 cells | 140/300 (47 %) |
C < 300 | standard | 1.56 cells | 4.21 cells | 295/300 (98 %) |
C < 300 | deepcut | 2.41 cells | 6.37 cells | 298/300 (99 %) |
At 120, two thirds of standard seeds contained no lava at all, and
endRule = death, deathCause = lava, the interrupt rule, place_block's
bridge, dig_down's case 3 and the death endcard were live code with no live
traffic — while the note's own prose calls lava "the only thing in this world
that can end a run" and its test 26 asks the certification seed to emit a
lava event. src/minecraft/world.nim's LavaCaveGate is therefore 300.
Everything else in rule 2 — the lavaChance table, the draw, the salt — is
the note's.
This changes the game's difficulty, so it carries a GameVersion bump
(1 → 2) and the replay fixtures were re-cut against it:
- The scripted
minernow dies in lava on about one standard seed in ten (0 of 100 before), and its swept total over the 40 + 40 seed battery falls from 37 374 to 35 528.tools/tune_baselines.nimwas re-run andtools/ci/baseline_tuning.jsonandDefaultBaselineParamsare re-pinned to the new pick (woodPlanks: 16,woodSticks: 4,stoneCobble: 16). - The sweep exposed a real defect in the baseline that no seed had reached
before:
safestStepreturnedfcNorthwhen the cog was boxed in with no traversable neighbour, which walked it into the lava it was fleeing. It now reports "no safe step" and the miner mines its way out instead. - The certification seed goes back to the note's 42 (divergence G): under
the new gate and the re-swept baseline, seed 42's scripted episode takes all
eleven rungs, reaches
z = 3in 532 ticks and emits alavaevent, so the design note's test-26 lava clause is asserted rather than documented away.
Every level is still at least 78 % diggable (unsealLava, post-pass 5), every
seed is still completable — tests/test_minecraft_world.nim now plays the
whole ladder with an omniscient reference solver on 60 seeds of both variants
(divergence O) — and the miner baseline still clears rung 9 on 79 of 100
standard seeds.
One thing the higher gate does not make common is the interrupt (tick step 8): it fires only on lava that becomes newly known while already within Chebyshev 1, and the 5 × 5 underground window usually reveals a lava cell two steps before the cog can stand next to it. Most lava deaths are a stale plan walking into a cell the cog learned about mid-turn, which is the reactivity cost the note's batching section describes.
D. Seeking re-simulates instead of restoring a keyframe
The starter needs flatty keyframes because a paintbot sim carries ~40 MB of
static map bakes. This sim is four 32 × 32 integer grids over at most 960
ticks — microseconds of work — so seekReplay re-simulates from tick 0.
That removes the whole class of bug the keyframe machinery exists to manage
(stale bakes, restored-but-restamped geometry) at no cost to the viewer.
E. global.nim is a new module, not a retarget
The starter's global.nim is 8 000 lines of pixel arena, raycast fog and
paint FX, essentially none of which survives the note's own deletion list.
The fork's src/minecraft/global.nim is written against the same
bitworld/spriteprotocol API and keeps the three named edits the note
specifies (cell-space board, block/overlay pools emitted incrementally, a
baked tile bed) in ~380 lines. The board tiles are the nano-banana renders
under data/art/, scaled once at startup with pixie, rather than recoloured
crops of arena_floor.png.
F. The end-to-end episode test drives the engine, not the sockets
tests/test_minecraft_engine.nim runs a real episode through the same procs
server.nim drives — the decision engine, the driver, sim.step, the replay
writer and runResultsJson — with the websockets left out.
tools/ci/docker_smoke.sh covers the socket path end to end, in the
production image, on every CI run.
G. The certification fixture's seed is the note's 42 (it did move, twice)
The design note pins seed: 42 for the certification fixture and asks that
the episode it produces reach at least seven rungs, descend to at least
z = 2, run at least 400 ticks and emit at least one lava event. Under the
first cut of the sim seed 42 reached six rungs and no seed emitted a lava
event at all, so the fixture was cut on seed 8 with the lava clause recorded
as unsatisfiable. Both causes are gone — divergence C made lava real and the
re-swept baseline goes deeper — and seed 42 now takes all eleven rungs,
reaches z = 3 in 532 ticks and emits a lava event, so the manifest is
back on the note's seed and every clause of test 26 is asserted.
tools/probe_seeds.nim is the committed probe (its filter is exactly the
note's list of clauses, lava included) and tests/test_minecraft_engine.nim
asserts every one of those properties against whatever seed the manifest
actually declares, so the two can never drift.
H. mix64 is masked to 30 bits, not 63
The sim compiles TWICE from one source: natively, where Nim's int is 64 bits,
and to wasm32, where it is 32. mix64 originally returned
int(x and 0x7FFF_FFFF_FFFF_FFFF), which is a perfectly good native value and
raises value out of range on the first world generation in the browser. It
did: the whole bundle loaded, every asset returned 200, and the viewer sat on
its loading curtain with initialize replay runtime: value out of range
(run 33241005565). The mask is now 0x3FFF_FFFF, which is unambiguously
positive in an int32, and mix64u carries the full 64-bit value for the
world digest, where uint64 is 64 bits on every target.
tests/test_minecraft_world.nim asserts every generated integer fits an
int32, which is the check that would have caught it natively.
I. The follow-cam converges instead of using the note's closed form
The note's test 42 asks the game block to call core.setZoom(32 / cameraCells)
with cameraCells == 15. That closed form is only correct when the board is
letterboxed on its width: setZoom is a scale on the core's own fit, and
the core fits whichever axis is tighter. Measured in the browser (the fixture
screenshot at run 33242102244) it left the board at roughly four cells across.
client/replay_broadcast.html:4653-4676 therefore reads the span back from the
transform the core reports and corrects towards it:
var cellsNow = t.visW > 0 ? t.visW / 24 : CAMERA_CELLS;
if (followArmed && Math.abs(cellsNow - CAMERA_CELLS) > 0.5) {
core.setZoom((t.zoom || 1) * (cellsNow / CAMERA_CELLS));
CAMERA_CELLS is still 15 and the target is still the note's 15-cell window;
only the way the zoom is computed differs, and the static bundle's core lives a
thread away, so its own answer is the only honest one.
tests/test_minecraft_viewer.nim pins all three lines as exact strings, so this
mechanism cannot be loosened without the test noticing.
J. Playback runs at 24 ticks/second, not the note's 10
The note specifies "one tick per three animation frames at 30 fps = 10
ticks/second", with the cog interpolated across the three frames, and speed
chips [0.5, 1, 2, 4, 8]. The shipped rate is one tick per frame at
TargetFps = 24, with chips [1, 2, 3, 4, 8, 16] (PlaybackSpeeds), and
no sub-tick interpolation.
Two reasons, both structural rather than aesthetic:
ReplayFpsisTargetFps(sim_types.nim), so a recorded time and a tick are the same clock.tickTime/tickOfTimeround-trip exactly at one tick per frame; any other cadence needs a fractional accumulator on the playback side and leaves the scrubber's tick axis and the recorded chat times disagreeing by up to half a tick.- The chips are integer step counts per frame (
replaySpeed()multipliesstepReplaycalls), so a0.5chip is not a slower step but a skipped frame, which is the same accumulator again.
The consequence the checklist cares about is the soak: a 960-tick episode
plays for 40 s and the shortest episode CI can produce is well over
viewer_smoke.mjs --soak 10, so the soak still watches a genuinely advancing
replay. ci.yml's comment on the soak step carries the 24 as well, so the two
cannot drift apart again.
K. Four cog facings from one render
tools/art/source/cog_sheet.png is one nano-banana render of four Softmax
cogs in a miner's kit (head-lamp, pickaxe). The model drew them near
front-facing rather than truly top-down, so the four split sprites read as
four poses rather than four unambiguous headings; the cog's heading is also
carried by the DOM chrome (ALPHA · y=32 · FACING EAST) and by the agent-view
inset. A re-render with a stricter camera instruction is a cheap follow-up.
L. The board is composited server-side, so broadcast_core.js gained no draw calls
The note's §Viewer says of client/broadcast_core.js: "Deleted: every
ctf-specific draw call and the raycast FPV pipeline … Added: drawBlocks,
drawShafts, drawCog, drawFog, drawAgentView, drawLadder,
drawStrata, drawInventory." None of those eight functions exists in this
repo, and the deletions did not happen either: client/broadcast_core.js
differs from the starter's by exactly one added line (:49, the
MINECRAFT_WIRE lookup).
That is deliberate, and it is the stronger reading of the checklist's "the chrome is the starter's, not a lookalike":
- The board is composited into the starter's own sprite protocol by
src/minecraft/global.nim(buildBoardPacket) and drawn by the starter's unmodified compositor. Blocks, shafts, the cog and the fog are sprites in that packet, not new draw calls in the client, so the camera, the zoom bar and the minimap keep working on the code they were written for rather than on a fork of it. - The four panels — the milestone ladder, the strata gauge, the inventory
strip and the agent-view inset — are DOM, built by the appended game block
(
client/replay_broadcast.html:4489+). Item 15's "every drawn string fits its frame" is why: a DOM panel cannot draw a caption at a negative coordinate, and this viewer has nofillText/strokeTextanywhere as a result (test 44).
The note's eight names describe the same eight readouts; only the layer they
are drawn on differs, and the layer that shipped is the one that leaves
broadcast_core.js inherited.
M. A no-show seat is declared and the run plays out; the note also says "refuses to start"
The note's named edit 2 asks the server to "log loudly and refuse to start the
game when the joined seat has no register record (the grf-football 2026-08-27
silent-default scar)", and its test 27 asks the same scenario to "produce a
finished episode inside the wall-clock budget", with fallbackTurns counted
and deadSeats set. Those two are not jointly satisfiable: an episode that
never starts never finishes and never scores.
src/minecraft/server.nim:575-595 implements the second, with the first one's
guarantee kept intact — the thing the scar is actually about is a silent
default:
ERROR: seat 0 <why> within <n> lobby ticks; the run plays the published miner baseline and the failure is declaredon stdout,declarePlayerFailure(0, …)— the closed two-key{message, failed_policy_index}payload the platform reads,sim.deadSeats[0] = true, so the seat is marked dead inresults,- and only then a
startrecord, with the seat playing the publishedminerbaseline rather than a hidden internal default.
Nothing about the outcome is silent or invented: the platform is told the seat failed, the replay says which baseline played, and the run scores. A refusal would instead produce no replay, no results document and nothing for the league to read, on the one failure mode a single-seat game is most likely to hit.
N. llmTurns and fallbackTurns are per-seat arrays, not scalars
The note's worked results document shows "llmTurns": 47, "fallbackTurns": 1
as plain integers. This game has exactly one seat, so a scalar would be
readable — but every other per-seat key in the document (scores, win,
names, aliases, policyKinds, deadSeats) is an array of length
num_agents, which is the platform's own shape, and mixing the two in one
document would make a consumer special-case two keys.
Both keys are therefore
{"type": "array", "items": {"type": "integer"}, "minItems": 1, "maxItems": 1}
in coworld_manifest_template.json's results_schema, exactly as
src/minecraft/roster.nim writes them.
tests/test_minecraft_engine.nim asserts the written key set equals the
declared key set with nothing extra and nothing missing, so the code and the
schema cannot drift apart.
O. Post-pass 2b: a tier-0 route to wood, which the note does not specify
The note's post-pass plants trees but never says the cog has to be able to
REACH one, and the note's conclusion — "every seed is completable" — is false
without it. Water is neither walkable nor mineable, and the only way to cross
it is place_block, which costs a cobblestone; getting a cobblestone needs a
wooden pickaxe, which needs planks, which need a log, which only the surface
has. A spawn ringed by water is therefore a seed on which every policy
scores zero, and the reference solver added for test 3 found them
immediately: 35 of 300 standard seeds were sealed like that.
src/minecraft/world.nim's openSurfaceRoute runs between post-pass 2 and
post-pass 3. On a seed that already has a walkable route from spawn to a tree
it does nothing. On a sealed one it takes the cheapest route to the
nearest tree — fewest blocking cells, ties by cell index, so it is
deterministic and seed-pure — and turns the water on it to sand and the
rock on it to grass. Nothing else changes: the bedrock ring, the forced
grass 3 x 3, the tree count and the ore minima are all untouched, and
tests/test_minecraft_world.nim asserts the route exists on every one of its
200 seeds by playing the ladder to the diamond on 60 of them.
That is a generator change, so it carries its own GameVersion bump (2 -> 3),
a re-run of the baseline sweep and a re-cut of both replay fixtures.