A 2.5D hex survival/crafting game built on real anthropological discovery sequences: true scarcity (worlds gate you), redundancy (multiple routes to every gated achievement), non-linear progression (skills unlock gates in both directions), and maximum interdependence between five skill domains.
Plain ES modules and canvas 2D. No build step, no dependencies.
node tools/serve.js # then open http://localhost:8080
node tests/all.mjs # 136 tests, ~2s
http://localhost:8080/?seed=alpha generates a named world;
?load=1 restores the saved game; ?free=1 turns on free-resource mode for
testing; /editor.html is the map editor.
index.html the game
editor.html/.css/.js the map editor
src/
main.js bootstrap, frame loop, and the wiring between world and UI
core/
camera.js pan, integer zoom, follow, and the device-pixel rule
rng.js seeded noise and hashes (carried over from the old game)
world/
hexgrid.js THE lattice — flat-top, odd-q. Everything imports this.
tileset.js terrain vocabulary and the autotile resolvers
mapformat.js one map format, read and written by game and editor
worldgen.js seeded worlds: elevation, mountains, river, woods, villages
pathfinding.js A* with the climb/drop rules, ladders and bridges
render/
renderer.js 1x buffer, one integer upscale, back-to-front draw order
columns.js stacks drawn as cliffs (see "Cliffs" below)
decor.js trees and props stood on tiles (see docs/decor.md)
structures.js placeholder art for buildings
game/
state.js inventory.js skills.js crafting.js economy.js
harvests.js context.js actions.js player.js save.js
ui/
ui.js panels/{inventory,crafting,build,skills,trade,menu}.js
data/ the economy: 93 items, 85 recipes, 5 skills x 7 levels
new_tiles/ 27 tile sheets + index.json (lattice + autotile tables)
decor/ 40-sprite prop sheet + index.json (trees, rocks, crops)
icons/ 93 item icons, 5 skill glyphs, 35 ability icons
tools/ the art pipeline, and serve.js
tests/ node test suites, no framework
docs/ research/ how the art was made, and the design research behind it
skill_map.md is the design spine — read it before changing the economy.
The lattice. src/world/hexgrid.js is the single source of truth: flat-top
hexes in odd-q offset coordinates, stepX 26, stepY 24, odd columns pushed
down 12. Game and editor both import it, so they cannot drift.
tests/hexgrid.test.mjs proves the plane tiles with zero gaps and zero overlaps.
Cliffs. A tile frame is a flat-top hexagon in rows 16-39 extruded straight
down by its 8px wall — every tile already carries its own side section. So
columns.js just stacks whole frames, each lifted by exactly that wall height:
each copy covers the face of the one below and leaves only its side showing, so
an h-high column draws one surface over h bands of wall. On stone those bands
read as bedding planes, on grass as a cut earth bank. Nothing is synthesised, and
the game and the editor draw a column with the same function.
Height lighting. Ground darkens below LIGHT_BASE and lightens above it,
5% a level. Applied per level rather than per column, which is what gives a
cliff face its gradient: the stacked bands darken as they descend, so a tall
column stands in its own shadow at the foot and its crown reads as a snowcap.
The base sits at 3, roughly the median of a generated world — centring it on
worldgen's baseHeight of 5 rendered the whole map dim, because most of a map
lies below that. The clamps at -20%/+95% are a safety net for hand-edited maps:
the full range worldgen produces, height 0 to 20, lands inside them at 85% to
185% and never reaches either end.
It is baked, not filtered. ctx.filter expresses it in one line but flushes
canvas state on every change, and the exposure changes per level — 193ms a frame
against a 16.7ms budget. litImage runs the same filter once per (sheet,
exposure) pair at first use instead, which brings it to 13.5ms.
Scale. Everything is drawn at 1x into an offscreen buffer and upscaled once
by a whole number, with smoothing off. Fractional device pixel ratios are floored
for the same reason. docs/new_tiles.md is explicit that this art crawls at
fractional zoom.
The economy. data/game_data.js is the source of truth. Recipes carry
tools (a list of slots, each one item or an any-of) and needs (world gates:
skill:x:n or built:item). A tool slot naming something with a build recipe
means "stand at one", not "carry one". checkRecipe returns its reasons rather
than a boolean — with this many gates, a list that only says no is unplayable.
Where things come from. Ten raw materials are inputs to recipes and the
output of none, so the world supplies them. src/game/harvests.js binds every
harvest to terrain and is the one place that decides what a hex is worth walking
to. Harvests spend the world: woods fell to bare floor, cliffs mine down until
they can be climbed, veins run out.
The knowledge gate. Ore exists in the map from generation but is invisible
and unusable until Mind 4 (Lore) reveals it, a few hexes at a time, and each vein
is finite. A world with no prospectable ore is a stone-age world with
metalworking fully locked. That is the scarcity anchor, not a bug — and
tests/economy.test.mjs proves the stone age stays fully playable inside it.
The river. It divides the board and is meant to. The far bank waits on a bridge (woodworking 6) or a boat (woodworking 5). The generator therefore puts the spawn and every village on the near one.
The mountains. Base terrain gets its own low ceiling (baseHeight) and the
massifs get the rest, up to 20 levels — the split the old three.js generator used,
and the reason its worlds were rolling country with real mountains in them rather
than uniform noise nobody could cross. The two barriers that fall out of that are
not treated alike. Low ground cut off by a step or two is an accident of the
noise and gets a staircase; high ground ringed by a sheer face is left standing,
because those plateaus are ladder country (woodworking 4) and carving a way up
would throw away the reason to build one. Ramping therefore stops at
rampCeiling, and tests/worldgen.test.mjs holds both halves of that: nothing
low is ever walled off, and something high always is.
A testing switch: ?free=1, or the toggle under Game → Testing. It turns
off every cost — materials, tools, skill levels, needs gates, energy, gold,
rental fees — and not one rule. Harvests still only work on the terrain they
belong to, buildings still need legal ground, cliffs still have to be climbed,
the river still has to be crossed, and felling a wood still uses it up. XP is
still awarded, so you level up as you go.
It is one flag on the state, read through isFree in src/game/state.js, so it
saves and loads with the game and there is exactly one thing to grep for. The
HUD says FREE RESOURCES while it is on, because nothing is more confusing than
wondering why a recipe you have no materials for is green.
The one cost it keeps is a full pack: free mode would otherwise drop what you just made without saying so.
The asset set covers terrain, paths, shorelines and crops. It has no characters
and no buildings, so the player and every structure are drawn procedurally in the
tile palette's idiom — dark outline, light from the top left. Swapping in real
sprites means replacing drawPawn in src/game/player.js and drawStructure in
src/render/structures.js, and nothing else.
Item icons use SWEETIE-16, which shares no colours with the tiles, so they appear only on UI panels and never on the map.
No framework — tests/_harness.mjs is thirty lines.
| suite | what it pins down |
|---|---|
hexgrid |
the lattice tiles the plane, neighbours are reciprocal at both parities, picking prefers the column in front |
autotile |
all 64 masks land on real art; path coverage matches what docs/new_tiles.md measured |
decor |
the prop index still describes the sheet, and the game only asks for sprites it has |
lighting |
the height curve is monotonic, clamped, and centred where the ground actually is |
worldgen |
determinism, the low country is all walkable, the high ground stays gated, peaks reach 20 |
pathfinding |
climb 1 / drop 2, ladders, bridges, and every returned step is legal |
economy |
the graph is consistent, and the whole discovery path is walkable from an empty pack — by planning backwards, not by crafting greedily |
world |
harvesting, felling, mining, prospecting and building change the map correctly |
save |
a save restores the world you left, not the world the seed would generate |
freemode |
the testing switch lifts every cost and not one rule |
Unchanged, and still the way to regenerate the assets:
node tools/generate_icons.js # validate + write icons/
node tools/generate_tiles.js # validate + write tiles/
node tools/newtiles_index.js --json # derive the autotile tables
node tools/newtiles_gen.js # generate matching extra terrain
node tools/newtiles_map.js out.png mixed 4
node tools/decor_index.js --json # derive the decor sprite boxes
node tools/to_json.js # rewrite data/game_data.json
See docs/icon_pipeline.md, docs/tile_pipeline.md, docs/new_tiles.md and
docs/decor.md — new_tiles.md is the reference for anything that touches how
tiles are drawn.
- Path junction art. 12 of 15 four-connection pieces and all six five-connection ones do not exist. Roads are grown as a tree to avoid them; unsupported masks fall back to the nearest supported one, preferring to drop a connection rather than invent one.
- Non-contiguous shorelines have no art either, and get the same treatment.
- No character or building sprites, as above.