Skip to content

Add Shell Keep, a game skin over the session list - #171

Merged
Alexgodoroja merged 50 commits into
mainfrom
game-skin
Sep 17, 2026
Merged

Alexgodoroja merged 50 commits into
mainfrom
game-skin

Conversation

@Alexgodoroja

@Alexgodoroja Alexgodoroja commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Adds Shell Keep, a game skin over the web app. A controller at the right of
the top bar opens it; a pause screen whose last item says "Quit to boring UI"
closes it again.

The Marches are nine holdings spread over open country 128 tiles across, the
Keep in the middle and eight around it on the compass points, with a river down
the west, a lake in the north-east and woods in the gaps between. Each holding
is a rename of a part of the product -- the Forge is where features are built,
the Watch is where faults are met, the Chronicle is the audit log, the Vault
holds session passwords the service cannot open -- and that is the rule the lore
is held to, because a garrison nobody can point at a feature for drifts into
fantasy filler the first time anybody edits it.

Every team member is a hero, and every session is that member's soldier.
Somebody with ten Claude Code sessions and three OpenClaw sessions has thirteen
soldiers of two classes, all of them theirs, around their own camp. A hero
exists whether or not anything is running, because a colleague with nothing open
is still on the team; it is the soldiers around them that come and go. A
soldier's class is the harness its session runs, shown as a coloured chip above
its head beside the session name.

Above a hero: a shield with their initials, their name, a health bar, and a bar
for how much of their company is at work. That last is a read-out like
everything else -- the share of their sessions doing something rather than
sitting at a prompt -- and nothing consumes it.

Left-click the ground to send your own hero somewhere; their retinue follows.
Only your own: marching a colleague around would be the one thing in this game
that changes what somebody else sees. Click a figure to inspect it instead.

Camps are solved from the map and assigned by hash, never stored -- the same
person gets the same ground on every machine for everybody looking at the same
team, so storing it would mean two places that could disagree.

The Unmade come to the camp of whoever has a session on a fault, which is what
makes them somebody's bugs rather than weather.

Every holding has a board standing outside saying what it is for, because lore
in a menu nobody opens is a document rather than a world.

The country is 128 tiles across and ends in old-growth forest: a thicket of
oversized trees, boulders and the occasional ruin hugging the edge, with a baked
canopy carried far past the playable bounds, so that zooming out shows a place
with edges rather than a polygon in a void. Drag to move, wheel or pinch to
zoom, and a road book in the pause menu rides you to any of the nine holdings.

The three constraints it was built to

It costs the session list nothing. The whole game is one lazily imported
chunk. scripts/check-bundle.mjs fails the build if any of it reaches the entry
bundle, by looking for a marker the game writes onto the DOM. Measured against a
baseline build, the corporate bundle grows by 2.07 kB gzipped — a Phosphor
icon and a lazy route — and it has not moved since, including through the
renderer rewrite. The game chunk is 103 kB gzipped, PixiJS and all, and is
fetched only when somebody asks for it. That check has already earned its keep:
naming the game in manualChunks looked tidier and made Vite preload it for
every visitor.

It is a read-out, not a game you can play wrong. Everything that earns
experience is something a session did, counted by the service from the caller's
own sessions — sessions that ran and ended cleanly, distinct days, machines that
answered the muster, and how many of the finished ones read as mending or
making. The browser adds none of it up, because a browser that could would be a
browser that could claim anything. There is no failure state, nothing decays
while you are away, no streak to break, and marks arrive in fixed amounts rather
than rolled. The shop is cosmetic by construction and a test asserts it stays
that way.

Nothing in that counting reads a session. Sessions are end-to-end encrypted and
the service could not open one if it wanted to; these are counts of rows and the
names people gave their own sessions.

The elixir vial is real now. It shows what statistics gathering has cost,
itemised by run and by machine. It is off until it is turned on, and the notice
saying what a machine would read -- and what it never reads -- is one press from
the vial. That notice is written plainly and deliberately out of character:
somebody deciding whether to let a program read their work has stopped playing
along, and a consent notice in fantasy voice is one designed not to be
understood.

The reading happens on the operator's machine rather than on the service,
because sessions are encrypted end to end and the service holds no key. So
internal/stats reads git, the GitHub CLI and the coding agents' own history
files there, and what leaves is six numbers -- there is no field for a branch, a
commit message, a path or a diff, and a test holds that shape so adding one has
to be argued for. shell stats prints exactly what would be sent and sends
nothing, so "what does this actually read?" has an answer somebody can check
rather than a paragraph they have to believe.

It is playable by people who are not sitting where I am. Built to the
game-ui-design skill, with scripts/check-game-ui.mjs enforcing it in
npm test: a 16px floor for text, 44px for anything you can hit, motion at or
under 300ms with a reduced-motion escape, a bounded z-index scale, a visible
focus ring, and no physical button named anywhere except the one table that
knows what the player is holding. There is a safe-area inset with a calibration
target for televisions, an interface-size slider, and colourblind palettes —
though colour is never the only signal anywhere in the game.

How to try it

npm run dev
  • http://localhost:5173/qa.htmlthe whole app, signed in. This is what
    to test: the route inside the product, the controller in the top bar, and the
    way back out to the session list. Calls to the service will fail, because the
    identity is a stand-in and the server checks a real token; those error states
    are worth seeing, and the experience bar says "not counted yet" rather than
    pretending the answer was zero.
  • http://localhost:5173/game-preview.html — the game on its own, for looking
    at the artwork without the app around it.

Neither page is an input to vite build, which builds index.html and nothing
else, so both exist in development and in no deployment.

Isolation

Everything the game is lives in src/game/ and src/styles/game.css.
src/game/README.md records the seams where it touches the rest of the
application, so that adding another is a decision somebody makes rather than one
that happens. It also owns server/routes/game.ts, server/lib/game-stats.ts,
migration 014's game_profiles table, and two Store methods; nothing else on
the server reads them.

One module is shared deliberately. src/game/world/work.ts decides whether a
session reads as mending or making, and it has no imports at all so that the
field and the service can both use it — the map walks a wright to the garrison
its work belongs to and the service counts the same session towards the same
column, and two copies of those patterns would drift until they disagreed about
what a session was. It stays out of the corporate bundle, and the bundle check
still says so.

The artwork

Kenney's Medieval RTS pack, Particle Pack and Fantasy UI Borders, all CC0 and
vendored, with provenance in docs/third-party-notices.md. An earlier version of
this branch drew everything as palette-indexed text sprites, which was small and
diffable and produced structures you could not identify. Recognisable art was
worth more than a clever pipeline.

Two import scripts keep the vendoring honest rather than leaving mystery files
in the repository. scripts/import-kenney.mjs converts Kenney's XML atlas to the
JSON Pixi wants. scripts/import-fantasy-ui.mjs recolours the panel frames from
white to brass, which costs three bytes and a checksum: the pack is 1-bit
paletted, so it rewrites the one palette entry that is not transparent and
leaves every pixel where Kenney drew it.

The woods are placed rather than drawn: twelve of them, in the gaps between
holdings so a road from one to another has something to run through, with lone
trees and stones thinned out over the open ground between. Nothing may grow
inside a holding or within two tiles of a road, and a test checks it -- a tree
in the middle of a road is not a charming detail, it is the thing that sends
somebody looking for the collision bug that is not there. Anything walking
raises dust, tinted by the ground under the foot that raised it.

Rendering is PixiJS with pixi-viewport. Two details were needed to run under this
application's Content-Security-Policy, which is strict and worth keeping:
pixi.js/unsafe-eval, because the default renderer builds its shader plumbing
with new Function; and Assets.setPreferences({ preferWorkers: false }),
because the asset loader starts a blob worker that script-src 'self' forbids.
Widening the policy would have been the other fix and the wrong one.

What the tests and the checks found

The heroes shook. Three separate causes, all of which looked identical on
screen — a figure having a fit against a building. Shoving out of a wall makes it
vibrate; sliding along the wall makes it creep at a twentieth speed; heading for
the cheapest corner walks it into the wall. The fix in the end was deleting the
obstacle avoidance: the map is open country and there is nothing to route
around. sim.test.ts counts direction reversals over nine hundred ticks, which
is how it was found and is the only honest way to say it is gone.

The field filled with duplicates. Instrumenting the click handler showed
every session on the map twice: muster and garrisonSoldiers pushed blindly,
and both the roster poll and a remounted effect call them again with ids already
on the field. The roster count fell from ten to five, which is how many there
are.

The Unmade arrived on a quiet map. The garrison's own watch is drawn at a
bug-facing holding and counted as somebody fighting, so the enemy turned up
whether or not anything was broken. Only a real session summons them now.

Levels were earned by leaving the tab open. state/progress.ts opens by
promising that nothing in the game can be earned by playing it. That was not
true: the experience bar was fed the simulation's own tally, so the shop
unlocked itself overnight. This is the change the stats work above exists for.

Zooming out sent the map into a corner. A diamond grid laid out from the
origin runs half its width into negative x, and pixi-viewport describes the world
as a box from (0, 0), so there was nothing to clamp against. The projection is
shifted rather than a container moved, so that every coordinate agrees —
including the one a click is turned back into, which is the part that would have
broken silently. iso.test.ts checks the round trip.

Reduced motion stopped at the edge of the canvas. The canvas is not styled
by CSS, so somebody who had asked for less motion got a still HUD over a map of
drifting smoke and flapping birds -- the setting doing nothing in the one place
it was most needed, and drifting particles are named in the game-ui-design rules
as a motion-sickness trigger. Found while adding dust.

The scatter used eight of the twenty things it had. The hash was one
multiply and a shift, which is cheap and badly distributed: asked to pick from
four trees it chose three and never the fourth, anywhere on the map. One round
of mixing does not decorrelate the low bits, which are exactly what the pick
reads. The ground had the same weak hash choosing its per-tile tone.

The gathering could charge somebody twice. Recording a run and adding its
cost were two statements in a transaction, and the conflict clause skipped the
duplicate row while the token add ran anyway -- so an agent that reported, lost
the reply and retried doubled the bill. Caught by the store conformance suite.
It is one statement now, whose second half draws its numbers from what the first
half actually wrote.

Cache reads were ninety-nine per cent of the token count. Run against a real
machine, three days of ordinary work reported four and a half billion tokens,
which pins the vial at its maximum on the first run and never says anything
again. They are read and not counted now. The honest figure was eighty-three
million, which was also most of the way to the server's cap, so the cap went up:
one that clips an honest report is worse than none, because the number it
produces is wrong and looks reasonable. The vial is logarithmic for the same
reason -- no linear scale covers a range from a hundred thousand to a billion.

The ground would have failed silently on a good many machines. It was baked
into one cached texture, which was right at 4096x2048 and is not at 8192x4096:
134 MB of video memory, and past the maximum texture size in a lot of places.

A boolean path operation wiped out fourteen thousand fills. The far canopy
rendered as one flat colour. Every tone was being built correctly -- counting
the blobs showed all four evenly spread -- and none survived into the picture.
The cause was a darker ring drawn as a rectangle with the country's diamond
cut() out of it, in a context that already held thousands of filled subpaths.
Painting the tones in four wildly different colours for one screenshot is what
found it.

The frame-rate numbers in the earlier commits should be read with suspicion.
Chasing a border that appeared to cost thirty-eight frames a second led to two
restructurings; measuring the baseline again in the same conditions showed this
browser has no GPU, the numbers move by ten frames between identical runs, and
the real cost was about twelve per cent. The structure arrived at is still right
on its own terms, and it was arrived at by chasing noise.

An audit after the hero rewrite found heroes wearing two health bars, your
own chosen class never reaching your own hero, a dead garrisonFor describing a
model the code no longer has, and world/ importing the projection upwards out
of pixi/ while the README claimed it did not. The projection is pure
arithmetic about the shape of the map, so it now lives in world/.

check-game-ui caught its own author three times, all at fifteen point text
on the map.

Not in this PR

Postgres was not exercised locally (no Docker on this machine), but CI runs the
store conformance suite against a real database, so migrations 014 and 015 and
the SQL behind them are checked before this merges.

The gathering is verified end to end except for the last hop: shell stats was
run against a real machine and reported real numbers, the probe and the service
side are covered by tests, but no agent has posted to a live server from this
branch.

docs/shell-keep.md is the specification the implementation is held to: what
each thing is a rename of, where every number comes from, and the plan for the
rest of the live data.

1139 tests on the web side and the full Go suite, plus typecheck, lint, the
bundle guard and the game-UI rules, all pass.

Alexgodoroja added 12 commits September 15, 2026 23:09
The console is correct and it is boring. This begins a second skin over the
same product -- Shell Keep -- reached from a controller in the top bar and
left again through a pause screen that says "Quit to boring UI" in those
words, because somebody looking for the exit has stopped playing along.

Nothing of the game is in the bundle the session list downloads. It is a
dynamic import in App.tsx and a stylesheet imported only from inside it, so a
visitor who never opens it never fetches it. That property is now checked
rather than trusted: check-bundle reads the entry script and the chunks
index.html preloads beside it, and fails the build if the game's marker turns
up in any of them.

It caught its first leak immediately. Naming the game in manualChunks looks
tidier and is a trap -- Vite treats a manual chunk as part of the initial
graph, so index.html came back with a modulepreload for the game and a link
to its stylesheet, and every visitor paid for the keep on their way to a list
of sessions. Left alone, the lazy import produces a genuine async chunk.

What is here is the frame rather than the game: the options that decide
whether the thing is legible on a given screen, and the input layer under it.
Both exist this early because they are not decoration. A television crops the
edges of the picture, so there is a safe area with a calibration target and a
slider to widen it. Motion makes some people ill, so it can be stopped, and
the system setting is watched rather than read once. Colour alone tells a
colourblind player nothing, so every state carries an icon and a word too.

Menus are driven by keyboard, pad and thumb alike. They are real buttons with
real focus rather than a painted selection, because a menu whose current item
is only a CSS class cannot be read aloud or reached by Tab. The index
arithmetic that keeps a pad from getting trapped is pure and tested: wrapping
at both ends, stepping over what cannot be chosen, and never looping when
nothing can be.

No button is named anywhere but in one table. Screens ask for "confirm" and
are told what this player is holding, so a PlayStation pad is never told to
press A and a phone is never told to press anything.
Pixel art here is text. A sprite is rows of characters -- '.' for nothing, hex
digits for slots in a sixteen-colour palette -- and a 32x32 one is about a
kilobyte that gzips to a couple of hundred bytes.

PNGs were the obvious alternative and the wrong one for this repository. There
is no pipeline for binary assets, there is a build that checks what reaches the
bundle, and review here means reading a diff; a folder of images is none of
those. It would also make a skin a second copy of every picture. Written this
way a change to a watchtower is a readable diff, the whole atlas travels inside
the game's chunk, and a skin is a different sixteen-entry array -- about forty
bytes -- because sprites store palette slots rather than colours. The
colourblind palettes fall out of the same property.

Sixteen colours is the style rather than a limit worked around. Flat fills and
dithering instead of gradients are what the hardware this borrows from could
do, and an unlimited palette stops reading as 8-bit however few pixels it has.
The ramps are the product's own tokens read as materials: --terminal is the
stone, --paper the parchment, --acid the prompt glow. A watchtower is a cathode
screen on a plinth. The keep is a fortified terminal with a lit gate.

The weakness of art-as-text is that you cannot look at it, so there is now a
contact sheet: `npx tsx scripts/sprite-sheet.ts` renders every sprite, magnified
and captioned, to one standalone page. It earned itself immediately. The first
walls were three grey rectangles that read as scenery, the first keep was small
enough to read as another tower, the flagstone laid out as graph paper because
its joints lined up in both directions, and the rubble was per-pixel noise that
read as a dead television. None of that is visible in a diff and all of it was
obvious on the sheet.

The atlas test is the other half. A row one character short shifts every pixel
after it and looks, on screen, like the art was drawn badly -- nothing throws.
It caught seventeen miscounts on the first run. Long runs of identical
characters are now built with repeat() rather than typed, because a row of
twenty-six identical characters is a row nobody can proofread.

Simulation runs at a fixed thirty ticks a second, drawn at whatever rate the
display manages, so the game is not half speed on a 30Hz panel and double on a
120Hz one. A tab left in the background is the interesting case: the tests
caught the loop keeping ten minutes of unrun debt and asking for it again every
frame for ever. Time that was not simulated is now dropped. The ground and the
buildings are drawn on their own canvas and only redrawn when something is
built, which leaves the per-frame cost to the things that actually move.

The skill's rules are enforced rather than remembered. check-game-ui fails the
build on text under 16px, targets under 44px, motion over 300ms, z-index above
the named scale, an outline removed with nothing in its place, animation with
no reduced-motion escape, and any button named outside the one table that knows
what the player is holding. Where it departs from the skill's own regex it is
because the literal pattern flagged every border shorthand in the file, and a
lint nobody believes is worse than no lint.
`npm run dev` has been serving a blank page on every route. The dev server
sends the production security policy on purpose, so that a sign-in the policy
would break fails locally rather than after a deploy -- but @vitejs/plugin-react
injects its Fast Refresh preamble as an inline module script, script-src 'self'
refuses to run it, and because that preamble runs before main.tsx the refusal
takes the whole application with it. One CSP error, no application, every page.

So the dev server, and only the dev server, now also allows inline scripts.
The guarantee that mattered is kept where it matters: `preview` still sends the
strict policy, and preview is the one serving the real production build, which
has no inline script in it. A policy problem that would break a deployment
still surfaces before the deployment.

Seeing the game needed a second thing. Its route is behind RequireAuth, and a
machine with no .env.local has no provider to sign in with; weakening that
guard so the interface could be admired would trade a real protection for a
convenience. game-preview.html mounts the same component with nothing around
it instead. It is not an input to `vite build` -- that builds index.html and
nothing else -- so it exists in development and in no deployment, which the
build output confirms.

The harness paid for itself on its first load. The game's stylesheet was using
--mono from tokens.css, which travels in the entry bundle rather than in the
game's chunk; inside the app that dependency happens to hold, and outside it
the whole interface fell back to the browser's default serif. The keep now
carries its own font stack. Monospace rather than a true pixel typeface,
because a pixel face at 16px is charming and hard to read across a room, and
legibility is the rule this file is held to -- the uppercase and the letter
spacing do the period work instead.
Five changes, all of them yours, and the first two are the ones that matter.

The camera now looks down. Half the old frame was sky -- empty air above a
side-on elevation -- and a view from above has no air in it, so the horizon is
gone and the ground is a floor rather than a backdrop. The picture also fills
the window now instead of being letterboxed into a fixed frame: a wider window
sees more meadow rather than the same image stretched, which is what looking
down at a place actually means. The title is gone from over it; the game is
the picture, and a wordmark across the top of it was a browser tab's job.

The palette is warm. Sandstone, terracotta, timber and amber, lit as if late
in the afternoon. The screens set into the stone went from green to amber CRT,
which is the warmer choice and the more period-accurate one -- amber
monochrome was the other half of that history, and it lets the keep stay a
terminal without a cold cast over everything. Bugs will be cold and slightly
luminous against it, so they read as something that got in.

Then roughly twice the detail, which turned out to be three things rather than
more artwork:

Shadows. Everything standing up casts one, down and to the right, so the map
has a single light source. This is the cheapest large improvement available
here -- a building drawn straight onto grass reads as a sticker -- and it
costs one extra blit of a sprite that already exists, because a flat palette
turns any sprite into its own silhouette.

Variants. One grass tile repeated across a window is a chequerboard the eye
solves in a second. There are now four cuts of meadow, two of paving, two of
road, and a kerb along the join where two ground textures meet, which is the
seam that most makes a map look assembled out of tiles.

Props. A well, barrels, crates, a handcart, torches at the gate and the
holding's banner. None of them do anything. Leaving them out is the difference
between a diagram of a fort and a place somebody uses.

The artwork got the same treatment. Trees are half again the width of a tile
with trunks and lobed canopies, because at sixteen pixels a canopy has room
for a silhouette or for structure but not both, and the first ones read as
cabbages. The hall is three tiles square rather than two -- at two it was just
another tower -- and it now has a roof with a *direction*: a ridge across the
middle, the slope above it a step brighter than the one below, courses with
staggered joints, a chimney, a dormer with a light in it, and a south face
with lit windows and a door that faces the gate.

The atlas test and the contact sheet earned their keep again: seven short rows
in a boulder, a hall whose south face was six pixels narrow, and a keep left
floating two tiles clear of its own courtyard because props are anchored to
the bottom of their tile and a building has to be anchored to the block of
tiles it occupies.
The game is a skin over a product, so the thing worth testing is not the game
on its own: it is the route inside the app, the controller in the top bar that
opens it, and the way back out to the session list. None of that exists
without a signed-in page to hang it on, and a machine with no .env.local has no
provider to sign in with.

qa.html mounts the real application with a stand-in identity. This weakens
nothing: every guard still asks the same context the same question, and the
service still checks a real token, so anything that reaches it answers 401 and
shows its error state -- which is itself worth seeing. AuthProvider exports its
context for this and changes no behaviour; App splits the signed-in tree out so
the harness can mount it under a different provider.

Neither harness is an input to `vite build`, which builds index.html and
nothing else.

src/game/README.md now writes down the isolation boundary and, more usefully,
the five seams where the game touches the rest of the application -- so that
adding a sixth is a decision somebody makes rather than one that happens.
Iterations one to four of the review loop.

Lore first, because it names everything after it. The rule it is held to: every
piece of it renames something real and never invents on top of it. A session is
a wright because a session really is a thing your machine sent to do work. The
Prompt must not go out because a shell really does die when its process does.
Token spend is elixir because that really is what is being consumed. Lore that
describes the product is flavour; lore that describes a fiction nobody can
check is noise, and noise is what makes a small game feel complicated.

Then the two things last time's screenshot showed. The courtyard was lighting
the top of every paving course with the palest colour in the palette, which
across a yard is a bright line every eight pixels and read as decking rather
than stone -- real joints are ones you have to look for. The well was a black
dot; at a tile's width there is no room for a rim, water and a frame, so it and
the stores are wider now.

Three things move: smoke from the hall's chimney, the torches at the gate, and
the banner on its pole. None of them is a mechanic. A still picture of a fort
reads as a diagram however well it is drawn, and this is also the cheapest
proof that the loop, the clear and the layering all work before anything that
matters is drawn on them. Each is a pure function of the clock, which is what
makes stillness a single early return rather than a flag every effect has to
remember.

And then the wrights. Five classes from one figure -- a crest, a palette and a
name -- because forty hand-drawn people at sixteen pixels come out as five
slightly different people with inconsistent proportions, which nobody can name
and everybody sees. It also makes a shop skin the same kind of thing as a
class, so the renderer never has to know which it is holding.

The world they walk in is a plain object advanced by pure functions, with no
canvas, sprite or clock in it, which is the only practical way to ask whether a
wright can get stuck or walk out through a wall. Both are now tests. The second
found a real one: choosing targets outside the hall is not enough, because the
walk between two points on opposite sides goes straight across it, and a wright
strolling over the roof makes the whole map read as flat. A step that ends
inside the building is now pushed out the shortest way, which on screen is
somebody walking into a wall and sliding along it.

Name plates are drawn in a pass of their own and pushed apart where they would
overlap. Five of them landing in the same place hid the only real information
on the field behind itself.

One test in this commit was written wrong and is fixed here: it compared two
snapshots with nothing in between, so it asserted that not running any code
changes nothing. It now also asserts that ticking does move them, because
"nothing moved" is equally what a world that never moves looks like.
Iterations five to seven.

The Unmade arrive when a session is working on a fault, not on a timer of
their own, so a keep whose sessions are all building things is a quiet keep --
which is the correct picture of that account. They come over a wall rather than
through the gate, because the gate is how the garrison comes and goes and
things that are not invited should not be using the door. Wrights working a
fault go to the nearest one and swing at it; numbers come off the hit.

Nothing is lost when one reaches the hall. There is deliberately no failure
state: this is a picture of work that already happened, and a keep that falls
over because somebody shut their laptop would be a punishment for nothing.
The same reasoning runs through the progression -- no decay, no streak to
break, and marks awarded in fixed amounts rather than rolled, because a
currency that arrives at random turns every level-up into a disappointment
somebody could have avoided by waiting. That is the shape of a slot machine and
this is a tool people use for work.

Feature work builds. A wright claims a plot and raises a structure through
three stages, one site per builder rather than one shared one, because two
sessions building two features are doing two different things and the field
should say so. The stages matter more than the artwork: a structure that
appeared finished in one step would be a number going up with a picture beside
it.

The HUD carries four things and nothing else -- standing, purse, elixir,
garrison -- because a heads-up display is screen space borrowed from what it is
displayed over. It is DOM rather than canvas: it does not move with the world,
it has to be readable aloud, and it has to be reachable with a pad. It reads
the world twice a second rather than every frame, since re-rendering at frame
rate to move a bar by a pixel is what makes a game feel heavy for no visible
reason.

The elixir vial is drawn as a vessel rather than a bar on purpose. Every other
gauge here is a game resource; that one is real money, and it should not look
like the experience bar beside it. Right now it says "not gathering", which is
true, and will keep saying so until somebody agrees to it.

The progression tests found a real fault: an experience total that arrived
broken would reach the bar as NaN, because floor(NaN) is NaN and max(0, NaN) is
NaN, and a bar of NaN pixels is one the canvas silently declines to draw.
Iterations eight and nine.

The wrights on the field are now the account's live sessions: their class is
the harness the session runs, and what they are doing is read from what it is
called. `kindForCommand` is reused rather than reimplemented, so the game and
the session list cannot start disagreeing about what things are -- including
seeing through the `sh -c` wrapper a browser-started session arrives in.

Reading a session's name for "fix" or "feat" is a guess, so it is a careful
one: mending is checked before making, because "fix the new importer" is a fix
and reading it as a feature for containing "new" is exactly backwards. Anything
unrecognised is neither and shows as a wright going about the yard, because
pretending to know is worse than showing that you do not.

The field is not rebuilt when the list is polled. Wrights already there keep
walking, new sessions march in through the gate, finished ones go home.
Rebuilding would teleport the whole garrison back to the gate every four
seconds, which looks like a rendering fault and is a data one.

Then the animation. Swings, hammer blows, and a bolt for the Arcanist, which
is the one class whose flavour is naming a fault from across the yard rather
than hitting it. Attack and build frames change only the six rows the working
arm lives in, so a swing reads as the same person swinging rather than as a
second character appearing. The action is held for a few ticks rather than
being derived from position, because an action that is true only on the tick
the damage lands is one frame in eighteen -- a swing nobody ever sees. Bursts
where blows land, cold for the Unmade and warm stone chips for a hammer. And
the garrison rests a quarter as long between errands, because a yard of
statues is not what a place with work going on in it looks like.

The tests earned their keep three times over on one problem. A wright at the
gate with a fault directly opposite, hall in between, breaks every local
steering rule in turn: shoving out of the wall makes it vibrate in place;
sliding along the wall makes it creep at a twentieth speed while jittering,
because the building is axis-aligned and the travel is not; heading for the
cheapest corner walks it into the wall, because the cheapest corner is the one
diagonally across the building and the path to it goes through. It now routes
via corners it can actually reach in a straight line, checked with a slab
test. All three failures would have looked identical on screen: a hero having
a fit against a building.
Iterations ten to twelve.

Choosing a character is the single thing the player actually decides -- every
other fact about the keep is derived from work that already happened -- so it
gets a screen rather than a dropdown. The opening is three lines, because a
wall of fiction in front of a game somebody opened out of curiosity is a wall
they close.

The pedlar sells cloth and dye, and says so in as many words. Nothing in the
shop makes a wright hit harder, a wall hold longer, or experience arrive
faster. That is a line rather than an oversight: the moment a purchase changes
a number, the game stops being a read-out of work and becomes something you
could play wrong, and there is no way to play this wrong. Fortification is
pointedly not for sale -- the base growing is the reward for the work, and
selling it would let somebody buy the thing the work was supposed to buy them.
Every refusal names its reason, because a button that does nothing is the
worst possible answer to a press.

The Chronicle is where the lore pays for itself. Each entry says the fictional
name and then says plainly what it corresponds to: a wright is one session,
the Prompt is the shell process, elixir is tokens and real money. Lore that
only describes a fiction is a thing to skip; lore that quietly teaches
somebody what they are looking at is doing a job.

The save is deliberately small. Everything derivable is derived -- the level
from the experience, the fortification from the level, the purse from the
level less what was spent -- so what is stored is only what cannot be worked
out again. A save holding both the experience and the level has two facts that
can disagree, and the day they do, somebody has to decide which is true. It is
written on every change rather than on a timer or on the way out, because a
browser tab is closed rather than exited and a save waiting for a clean
shutdown is a save that is sometimes lost.

Two things the screenshots caught: a sentence that began with a lower-case
"the Unmade", and a panel that scrolled its own footer out of view, which on
the Chronicle and the shop put the way out below the fold. A dialog whose
close is off screen is a dialog somebody is stuck in.
Iteration thirteen.

One table, one row per account, and deliberately short: everything the game
can work out again is worked out again -- the level from the experience, the
fortification from the level, the purse from the level less what was spent --
so what is stored is only what cannot be derived. A row holding both the
experience and the level holds two facts that can disagree, and the day they
disagree somebody has to decide which is true. There is no such day here.

Two endpoints, scoped to the caller's own account rather than their team,
because a keep is one person's progress. Nothing in the profile is worth
defending from its owner, so the route defends against nonsense rather than
against cheating: every field is narrowed on the way in, exactly as the client
narrows what it reads out of storage. The one exception is the token count,
which the browser may not set -- it is the figure that stands for real money,
and it is written from what the agent reports.

The interesting part is arrival. The service's copy is merged with the
browser's rather than replacing it, and merged by what cannot go backwards: a
class once chosen, skins once bought, marks once spent. Taking whichever was
written most recently would mean a tab somebody opened on a borrowed laptop,
played for nothing and abandoned could overwrite months of progress. That is
the test worth reading in remote.test.ts.

Writes are sent and not waited for. A purchase should land the instant it is
made; whether the service also heard about it is not something to make
somebody watch a spinner for. Everything here fails soft -- a keep that will
not open because the service is unreachable is worse than a keep that opens
with yesterday's hats.

The store conformance suite covers the new methods, so the in-memory store and
Postgres have to agree about them, including that a deleted account takes its
keep with it and that the in-memory one does not hand out the array it is
holding. Postgres is not exercised locally -- there is no Docker on this
machine -- but CI runs that suite against a real database, so the SQL and the
migration are checked before anything merges.

This adds a sixth seam between the game and the application: `request` is now
exported from lib/api.ts. The alternative was a second copy of the token
handling, the outage-page parsing and the error sentences inside the game,
which is a worse trade. README.md records it with the others.
Iteration fourteen: a pass through the real application rather than the
preview, which is where these turned up.

The heads-up display was four panels wide. On a 390-pixel screen they stacked
into a column that covered most of the field, and squeezing them onto one row
clipped every one instead. The floor this file holds itself to is a floor --
text does not shrink to fit -- so what gives is the content: on a narrow
screen the purse, the vial and the garrison count leave the HUD and the level
and the pause control stay. All three are repeated in the pause menu, which is
one tap away and is the right home for a number you look up rather than glance
at, so a phone loses nothing.

The pause panel pinned its header and footer by being a three-row grid, which
broke the moment it gained a fourth child: the row meant to scroll became
whichever one happened to be second. It now says which element scrolls instead
of relying on where it sits, so the next line added cannot quietly push the
way out below the fold.

Also the opening panel, which had the same clipped footer for the same reason,
and the class name in the pause header, which ran off the edge of a phone
while being already visible behind it.

Verified end to end in the app at /qa.html: session list, controller in the top
bar, the game, choosing a character, the field running, pause, and Quit to
boring UI landing back on the session list with the console intact. No console
errors on a clean load, at desktop and at phone width.
Alexgodoroja added 17 commits September 16, 2026 02:51
…signposts

The single yard was the whole game, it was smaller than the window it sat in,
and everything happened in one place. This replaces it with the Marches: six
garrisons spread over a map far larger than one screen, joined by roads, drawn
in isometric, and movable and zoomable with the mouse.

Each garrison is a rename of a part of the product, which is the rule the lore
has always been held to and is now what makes a place worth walking to. The
Forge is where sessions building a feature work. Watchmen's Rise is where
faults are fought. The Chronicle is the audit log, the Muster Yard is your
linked machines, Pedlar's Gate is the shop, and Prompt Keep is the hall the
whole map is arranged around.

The lore is on the ground rather than in a document. Every holding carries a
board with its name and a line saying what it is for; the name is legible at
any zoom and the sentence appears when you are close enough for a sentence to
be worth reading. Reading it means walking the map, which is the point.

PixiJS rather than the hand-rolled canvas, for three reasons that all turned
out to matter: it batches thousands of sprites into a few draw calls, which is
what makes a map this size possible; it has depth sorting, which an isometric
view needs and which I was doing by hand and badly; and pixi-viewport gives
drag, wheel zoom and pinch, which is most of what "make it interactive" means.
It lands only in the lazily-loaded chunk -- the entry bundle is unchanged at
357.76 kB gzipped, and the game chunk grew to 100 kB, which nobody downloads
until they ask for the game.

The artwork is Kenney's Medieval RTS pack, CC0 and usable commercially,
vendored as one 160 kB spritesheet with an atlas generated by
scripts/import-kenney.mjs so it can be regenerated against a newer release.
Provenance and licence are in docs/third-party-notices.md. The buildings are
now castles, churches, windmills and market stalls that you can tell apart at a
glance, which the hand-drawn ones were not.

The ground is drawn rather than textured, because Kenney's terrain tiles are
square and this map is diamonds. Flat diamonds with one lit edge read as a
tilted floor at any zoom, and the whole four thousand of them are baked into a
single cached texture rather than four thousand sprites with four thousand
transforms to update.

Two Content-Security-Policy problems surfaced here, both of which would have
reached production. Pixi builds its shader plumbing with `new Function`, which
`script-src 'self'` rightly forbids, so the renderer refused to start and drew
a black rectangle; it now imports Pixi's eval-free build. Then its asset loader
decodes images in a Web Worker made from a blob URL, which the same directive
blocks by fallback, so no texture ever arrived; it now decodes on the main
thread. Widening the policy would have fixed both and been the wrong trade for
one 160 kB atlas.
The map had buildings and no one in it. Now the garrisons are manned, the
Unmade come over the north-east country to meet the Watch, and both sides
fight: wrights that stand for real sessions, and the garrison's own soldiers,
who are scenery and say so when clicked rather than pretending to stand for
something. A fault met by one wright did not look like a battle.

Clicking a wright opens a panel saying which session it is, on what machine,
running what, for how long, and where it has been posted. That is the answer to
"the game is non-interactive": the figures on the map are the account, and now
you can ask them. The panel is at the side rather than over the middle, because
a modal in the middle covers the thing that was just clicked.

The shaking is gone, and mostly by deletion. The old simulation spent most of
its code steering around the one building in the middle of the one yard, and
every version of that steering oscillated -- the wright re-decided which way to
go thirty times a second and spent its time turning round. Three separate
causes were found and two were fixed before it became clear the whole approach
was wrong. The Marches are open country: a wright walks to the garrison its
work belongs to and mills about on the apron, there is nothing that has to be
gone around, and the thing that shook is not there any more. What remains is
committed: an enemy once chosen is kept until it dies or gets a long way off,
because picking the nearest every tick is the same disease in a different
place.

Ambience, because a map where the only moving thing is the thing you are
watching reads as a diagram of a place. Birds drift across on the wind, drawn
as two strokes that flap rather than as a sprite, since a five-pixel bird is a
smudge. Smoke rises off every holding. Blows throw a flash, and the numbers
that come off them are pooled rather than created per hit, which is the fastest
way to make a Pixi scene stutter.

The simulation and the scene stay apart: world/sim.ts has no Pixi in it and can
be run a thousand ticks deep in a test. That split is what made the shaking
findable in the first place -- it was a question about numbers, not pixels.
Clicking a figure opened nothing. Three faults, found in order:

A Pixi container only hit-tests its children unless it is given a hit area of
its own, so a tap on open grass -- which is most of the map -- reached nothing
and the handler on the viewport never ran. The listener now sits on the stage
with a hit area the size of the screen, so every click inside the canvas
arrives and where it landed becomes a question about coordinates rather than
about the display list.

Picking then finds the nearest wright to the click rather than asking anybody
to hit a twenty-pixel figure exactly, which at the far end of the zoom range is
asking them to miss.

Instrumenting that turned up the second fault: every session was on the map
twice. `muster` and `garrisonSoldiers` pushed blindly, and both the roster poll
and a remounted effect call them again with ids already on the field. Both are
idempotent now, and the roster count fell from ten to five, which is how many
there are.

The third was in the premise. The Unmade arrived on a map where nothing was
broken, because the garrison's own watch is drawn at a bug-facing holding and
counted as somebody fighting. Only a real session summons them now. A quiet map
is the correct picture of a quiet day, and it is the reason a loud one means
anything.

`sim.test.ts` covers all of it, including a count of direction reversals over
nine hundred ticks -- the shaking was found by counting rather than by looking,
and this is what keeps it gone.
The interface was four flat black rectangles over a warm map, and the lore was
outlined text laid straight onto grass, roofs and road. Both were unreadable
for the same reason: there is no single text colour that survives a map, and
the outline thick enough to try turned every letter to mud.

So everything that carries words now carries its own background.

The panels take a 9-slice frame from Kenney's Fantasy UI Borders, recoloured to
brass on the way in. Two frames in the whole interface and each one means
something -- this is over the map, this has stopped the game -- because an
interface where nothing is framed the same way twice reads as a sampler rather
than a place. The recolouring is three bytes and a checksum: the pack is
paletted, so scripts/import-fantasy-ui.mjs rewrites the single palette entry
that is not transparent and leaves every pixel where Kenney drew it.

The HUD is one strip divided by brass rules instead of four panels. Four panels
near each other read as four things that happen to be near each other, and at
the sizes their contents wanted they collided.

The garrison signs are boards standing above their holdings, so a sentence can
no longer run down across the buildings it describes, and the board resizes
between the name alone and the name with its purpose rather than being redrawn
on every frame of a drag. Wrights' names are boards too, in a layer above the
world -- parented to the figure they sorted at the figure's depth, so anybody
walking in front covered the name -- and staggered by a hash of the id so two
wrights standing together do not stack their names into one unreadable line.

Figures are drawn a quarter larger than the pack intends. Kenney's units are
scaled to stand beside Kenney's buildings, which is correct and useless here:
the wrights are what the game is about and the buildings are where they stand.

check-game-ui caught both new pieces of map text sitting at fifteen point,
which is the rule doing its job.
Zooming out sent the whole map sliding into a corner of an empty screen, and
the reason was a coordinate system, not a camera. A diamond grid laid out from
the origin runs half its width into negative x, because tile (0, n) is as far
left as tile (n, 0) is right. pixi-viewport describes the world as a box from
(0, 0) to (worldWidth, worldHeight), so there was nothing to clamp against.

The projection is shifted instead of a container being moved, so that every
coordinate in the game agrees -- including the one a click is turned back into,
which is the part that would have broken silently. `iso.test.ts` checks the
round trip and checks that the four corners of the map land exactly on the
edges of the box the stage declares, not merely inside it.

The floor of the zoom range goes up to match: below about half the buildings
are specks and the names have already gone, so there was nothing down there
worth being able to reach.
state/progress.ts opens by promising that nothing in the game can be earned by
playing it, because there is nothing in the game to play at. That was not true.
The experience bar was fed the simulation's own tally of faults put down, so a
tab left open overnight levelled you up, and the shop unlocked itself while
nobody was looking.

What counts now is counted by the service, from the caller's own sessions:
sessions that ran and ended cleanly, distinct days anything was started,
machines that answered the muster, and how many of the finished ones read as
mending or making. `server/lib/game-stats.ts` does the counting and the browser
does none of it, because a browser that added this up could tell you it had
earned whatever it liked.

Nothing in that reply came from inside a session. Sessions are end-to-end
encrypted and the service could not read one if it wanted to; these are counts
of rows and the names people gave their own sessions. Anything richer -- pull
requests, diffs, tokens spent -- has to be gathered where the plaintext already
is, which is the second tier and is still ahead.

The classifier moved to `src/game/world/work.ts`, which has no imports at all so
that both sides can share it. The field walks a wright to the garrison its work
belongs to and the service counts the same session towards the same column; two
copies of those patterns would have drifted until the map and the ladder
disagreed about what a session was. It stays out of the corporate bundle, and
the bundle check still says so.

`routes/game.ts` had no tests. It does now, mostly about the two things a
browser may not set: the token count, which stands for real money and is the
agent's to report, and the moment the profile was created.

Days are worth more than sessions, because turning up on another day is the one
thing on that list nobody can farm by starting five sessions in a minute.
The game opened centred on the Keep at a comfortable zoom, which put Watchmen's
Rise -- the holding that faces the Unmade, and so the only place anything is
actually fighting -- about twenty pixels off the right edge. The first thing it
showed was therefore a peaceful village, and the battle the whole thing is
about was happening where nobody could see it.

The opening view is now fitted to the holdings, with room reserved for the
boards that stand above them and for the HUD lying over the top. The margins
are asymmetric because what is in the way is not the same on all four sides,
and the camera moves the opposite way to the picture -- to push the country
down the screen, away from the HUD, the camera has to look higher up the
country. That sign was wrong first time and cost a round trip.

Two things had to give. Names on wrights were hidden below two-thirds zoom,
which was a guess, and turned out to be exactly the zoom at which all six
holdings fit -- so the opening view was a map with nobody named on it. They are
shown across the whole range now. And the zoom floor is no longer a matter of
taste: below about half, the world is shorter than the window, and
pixi-viewport's clamp centres an underflowing world, which quietly discards the
offset and puts the northernmost holding straight back behind the HUD.
The map was 64 tiles across with six holdings clustered in the middle of it,
which is a board you look at rather than a country you are standing in. It is
128 across now, the Keep in the centre and eight holdings roughly on the
compass points, with a river down the west and a lake in the north-east so the
edges are features rather than merely where the tiles stop.

Three new holdings, each a rename of a part of the product, because that is the
rule the lore is held to: the Relay carries every keystroke and can read none
of them, the Vault holds session passwords sealed once per member, and Ravens'
Roost is the inbox. A garrison nobody can point at a feature for would drift
into fantasy filler the first time anybody edited it.

Two things the size broke.

The ground was baked into one cached texture, which was right at 4096x2048 and
is not at 8192x4096 -- 134 MB of video memory, and past the maximum texture
size on a good many machines, where it would have failed silently. It is static
geometry now, uploaded once and redrawn for almost nothing. The tiles are also
grouped by colour before anything is drawn: a fill per tile was sixteen
thousand instructions, and quantising the variation into a few steps brings
that to two dozen for the same picture.

And a map this size cannot be crossed on foot. `lookAt` had been sitting on the
scene handle unused since the rewrite; the pause menu now has a road book that
calls it -- every holding, the sentence from its own board, and the line the
board does not carry, which is what it is a rename of. Riding is animated
rather than cut, because a cut across a map this size leaves you somewhere that
looks like where you were with no idea which way you came from.

The opening view is the Keep rather than the whole country, for the same
reason: fitting nine holdings on screen means a zoom at which a wright is three
pixels tall. The far end of the zoom range is worked out from the map instead
of chosen, so making the Marches bigger again cannot leave a corner of them
unreachable.
A country four times the size was, at first, four times as much empty grass.
Distance only reads as distance if there is something between here and there to
pass: a wright walking across an unbroken field looks like a sprite sliding
over a texture, and the same walk past a wood, a boulder field and a fallen log
looks like a journey.

Twelve woods and three scree fields, placed in the gaps between holdings so
that a road from one to another has something to run through, with lone trees
and stones thinned out over the open ground between them. Named clumps rather
than noise over the whole map, because a forest is a place you can be inside or
outside of and evenly-spread trees are a texture.

Nothing may grow inside a holding or within two tiles of a road. A tree in the
middle of a road is not a charming detail; it is the thing that makes somebody
go looking for the collision bug that is not there. `scatter.test.ts` checks
that, and that nothing is standing in the river.

Two faults found writing it.

The hash was one multiply and a shift, which was cheap and badly distributed:
asked to pick from four trees it chose three of them and never the fourth,
anywhere on the map, and the scatter used eight of the twenty things it had.
One round of mixing does not decorrelate the low bits, which are exactly what
the pick reads. It is the murmur3 finaliser now, here and in the ground, where
the same weak hash was choosing the per-tile tone.

And one size range for everything suited the boulders and made a full-grown
pine half the height of a cottage. A wood of those reads as a herb garden. Size
is by what the thing is: a tree stands over a roof, a shrub comes up to a knee.

The props are sprites and nothing else, and their shadows go into the ground
rather than travelling with them -- a shadow lying on flat earth cannot move
and never needs sorting, so there is no reason to pay for it twice a frame.
The cheapest thing in the game that most changes how it feels. A figure
crossing open ground with nothing coming off it is a sprite being moved; the
same figure trailing a little dust is somebody walking.

Each puff is tinted by the ground under the foot that raised it, and lighter
than that ground rather than the same colour -- dust matching the road exactly
is dust you cannot see, and what makes it read is being the ground caught in
the light. Crossing from a road onto grass changes the colour of what comes up.
Almost nobody will notice that, and the reason to do it anyway is that the
details nobody notices are what the noticeable ones are made of.

The pool is fixed and allocated once. A fight with thirty walkers can ask for a
puff several times a second, and a particle system that allocates is one that
stutters; when the pool is empty the request is dropped, which nobody can see
and which cannot cost anything.

Writing it turned up something worse than a missing effect. The reduced-motion
setting reached the interface and stopped at the edge of the canvas, because
the canvas is not styled by CSS -- so somebody who had asked for less motion
got a still HUD over a map of drifting smoke and flapping birds, which is the
setting doing nothing in the one place it was most needed. Drifting particles
are named in the game-ui-design rules as a motion-sickness trigger.

Birds go entirely, because a frozen bird is stranger than no bird. Smoke is
held rather than hidden: smoke standing still over a chimney still says the
holding is lived in, which is its whole job, and it is the drift that is the
problem. Dust stops, since dust is drift and nothing else.

It is applied when the scene finishes building as well as when the setting
changes, because the scene is built asynchronously -- arriving with reduced
motion already on would otherwise open a map full of movement and never be
corrected.
The elixir vial said "not gathering" and there was nothing behind it. This is
the half of that which lives on the service: consent, a way to ask, and an
itemised account of what was spent.

The notice is written plainly and deliberately out of character. Everywhere
else the game calls tokens essence and machines outposts, because that is the
skin and the skin is the point; here it does not, because somebody deciding
whether to let a program read their work has stopped playing along, and a
consent notice in fantasy voice is one designed not to be understood. It says
what would be read, what never is, and where it goes, in that order -- the
reassurance second, because you cannot be reassured about something you have
not been told yet.

It is off until it is switched on, and one press turns it off again. The vial
on the field is the way in: a figure standing for money somebody's machine has
spent should be one press from the account of what spent it.

`routes/gathering.ts` holds the shape a machine may report, and it holds
numbers only. There is no field for a branch name, a commit message, a path, a
diff or a line of output, which is deliberate rather than incidental -- a shape
that cannot carry those cannot leak them because somebody later found it
convenient. A test asserts that shape, so changing it has to be argued for.

Everything is narrowed on the way in. An agent is a program on somebody's
laptop, and these figures are summed and never recomputed: a negative count
would run a total backwards, and one absurd report would make the vial
meaningless for good.

The conformance suite caught the worst bug in this. Recording a run and adding
its cost were two statements in a transaction, and the conflict clause skipped
a duplicate row while the token add ran anyway -- so an agent that reported,
lost the reply and retried doubled somebody's bill. It is one statement now,
whose second half draws its numbers from what the first half actually wrote.
The memory store had the same hole in a plainer form.

Consent is checked on the service rather than trusted from the caller. It is
the whole basis on which any of this runs, and a button is not where that
belongs.
The half of the elixir that runs on somebody's laptop. `internal/stats` reads
git, the GitHub CLI and the coding agents' own history files, and reports six
numbers; the agent runs it when the browser asks; `shell stats` prints exactly
what would be sent, and sends nothing.

It exists because of the encryption rather than in spite of it. Sessions are
sealed end to end, the service derives no key and holds no password, and so
anything richer than counting rows exists only where the plaintext already is.
That is not a limitation worked around here -- it is the reason the shape is
what it is.

Three rules, in order. Nothing read ever leaves as text: not a branch, a commit
message, a path, a diff or a line of output. The struct has nowhere to put any
of it, which is the point, and a test holds the field count so that adding a
seventh has to be argued for. Every source is optional and failing is normal: a
machine with no git is an ordinary machine, and a run that found two of its
three sources is worth reporting. And it runs only when asked -- nothing here
polls, watches or schedules.

The probe command carries no arguments at all. One that could name a directory
or a repository would be a way to ask somebody's machine to go and look
somewhere on a browser's behalf, and a test asserts that arguments sent anyway
are ignored.

Running it against a real machine found two things worth having found.

Cache reads were ninety-nine per cent of the token count -- three days of
ordinary work reported four and a half billion -- which pins the vial at its
maximum on the first run and never says anything again. They are read and not
counted now; what is counted is what was written. The honest figure for those
three days was eighty-three million, which was also most of the way to the
server's cap, so the cap went up: one that clips an honest report is worse than
none, because the number it produces is wrong and looks reasonable.

The vial is logarithmic for the same reason. A light week is a few hundred
thousand tokens and a heavy one is hundreds of millions, and no linear scale
covers that. The exact figure is printed beside it, which is where precision
belongs.
The README still described palette-indexed text sprites, a directory layout of
folders that no longer exist, and a build script that was deleted two rewrites
ago. It also claimed nothing outside `src/game` imports from it, which stopped
being true when the service started counting sessions and needed the same
classifier the map uses -- so that is now written down as the one seam pointing
the other way, with the reason it is allowed to exist.

The changelog entry was written when there were six holdings on a map you could
see all of at once and the elixir showed nothing.
Zoomed out, the country was a diamond of grass in a flat void, with the corners
of the view showing whatever colour the renderer happens to clear to. That
reads as an unfinished map: the player is not looking at the end of the world,
they are looking at the end of the *tiles*, and those are different things.

The country now ends in forest. A canopy is laid under everything and carried
well past the playable bounds -- past them deliberately, because at the widest
zoom the country is shorter than the window and the viewport centres it, so
stopping at the world bounds would put the void back one step further out. A
thicket of real trees hugs the edge, its treeline wandering in and out by a
couple of tiles: a wood whose inner edge is a perfect straight line does not
hide a straight line, it draws a second one beside it.

Everything past the thicket is drawn rather than built, and baked into one
low-resolution texture at start-up. At the only zoom where any of it is visible
a tree is a few pixels of dark green, which is what it draws.

A note on how that was arrived at, because the honest version is less tidy than
the result. The first attempt scattered sprites over the whole canopy -- four
and a half thousand of them -- and the frame rate appeared to fall from
fifty-six to eighteen. Cutting back to a thicket, then baking the rest,
appeared to recover some of it and then not. Measuring the baseline again in
the same conditions showed why: this browser has no GPU, the numbers move by
ten frames between identical runs, and the real cost of the border is about
twelve per cent -- on a view that was already at twenty-two frames without it,
because the map itself is the expensive thing.

The structure is still right on its own terms: a thousand sprites rather than
four and a half, and one texture for a shape that never changes. But it was
arrived at by chasing noise, and the numbers in the earlier commits of this
branch should be read with the same suspicion.
…n it

A hedge is not a barrier. The border trees were drawn at the same size as the
ones inside the country, and at the zoom where the edge of the map matters that
reads as scrub you could walk through rather than as the end of the world.

They are roughly twice the size now, the band is half again as deep, and the
spacing is tighter. Boulders stand through it -- the big ones; the small stones
belong in the country where a figure can walk round them -- and there are ruins,
rare enough to be worth noticing.

Kenney's pack has no broken wall, so the ruins are its stone buildings drawn
dark and half-swallowed by the trees. A whole building at the edge of the world
would read as a place you can go; one glimpsed between trunks, in shadow, reads
as something older than the map.
…t green

Three faults in the border, two reported and one found while looking at them.

The thicket was drawn under the ground. It deliberately straddles the edge of
the country so the straight line the projection makes disappears under
branches, and a straddling tree drawn beneath the ground is a tree sliced off
along that exact line -- which draws the eye to it rather than hiding it. The
far wood still goes under the map; the thicket now goes over it.

The canopy did not reach the corners on every window. The overhang was worked
out from the arithmetic for one window size, and at the widest zoom the
viewport centres a world smaller than its view, so the bands it has to fill
depend on the window's shape. It is nearly twice the country's width now, which
costs nothing: the whole thing is baked into one texture whose size is fixed by
the bake scale rather than by the area it covers.

And the far wood rendered as one flat colour. The tones were being built
correctly -- counting the blobs showed all four evenly spread -- and none of
them survived into the picture. The cause was a darker ring drawn around the
country as a rectangle with the country's diamond `cut()` out of it: a boolean
path operation in a context that already held fourteen thousand filled
subpaths, which turned out not to be a local edit. Everything above it came out
as the last colour filled.

The ring is gone. What it was drawing, the wood receding from the clearing, is
done by the tone gradient instead, which costs nothing and cannot reach
backwards. Painting the tones in four wildly different colours for one
screenshot is what found it; they had been four nearly identical greens, which
is also why the flatness looked like a contrast problem rather than a bug.

The spec sheet in docs/shell-keep.md is new, and records the model the next
commits implement: heroes are team members, soldiers are their sessions, and a
soldier's class is the harness it runs.
The model the rest of this was missing. A hero is a person, a soldier is a
session, and a soldier belongs to the hero who owns it: somebody with ten
Claude Code sessions and three OpenClaw sessions has thirteen soldiers of two
classes, all of them theirs, all of them around their camp. Everything else
here follows from that sentence.

Heroes exist whether or not anything is running, because a colleague with
nothing open is still on the team. It is the soldiers around them that come and
go. A soldier's class is the harness its session runs, which was already true
and is now visible as a company of mixed units rather than as figures spread
over the map by what they were doing.

The Unmade are per hero now. They come to the camp of the person whose session
is fixing something, which is what makes them somebody's bugs rather than
weather.

Camps are solved for rather than stored. A camp is a fact about a `uid` -- the
same member gets the same ground on every machine for everybody looking at the
same team -- so storing it would mean two places that could disagree and a
migration the first time the map changed shape. They are assigned for the whole
roster at once, because "no two members share ground" is not a property a hash
can promise one member at a time.

Point and click moves your own hero, and only your own: marching a colleague
around would be a toy, and the only thing in this game that changes what
somebody else sees. Clicking a figure inspects it, clicking the ground orders,
which is the arrangement nobody has to be taught.

Heroes are drawn at twice a soldier, which sounds like a lot and is barely
enough -- at the same size they were indistinguishable from their own retinue,
which is the one thing on this map that has to be legible at a glance.

Three things the tests caught.

A hero walked to where they were sent, arrived, noticed they were a long way
from their camp, and walked straight back -- which makes the one thing the
player can do pointless. Where they are sent is now where they hold.

The camp solver's clearances left room for seven sites on a map that wants
fourteen, so a team of eight had two people sharing ground. The test asked for
eight distinct camps and got seven.

And the roster poll no longer diffs. `setRoster` is idempotent -- it musters
what is new, dismisses what has left, and leaves everybody standing where they
were -- so the hook that used to work out the difference is gone. Two places
deciding what had changed is two places that can disagree, and the way that
shows is everybody teleporting home every four seconds.

The stand-in roster is three people with their own retinues rather than five
loose sessions, because the thing worth showing when there is no service is the
shape of the model, and one hero would demonstrate half of it.
Alexgodoroja added 21 commits September 16, 2026 12:49
What stands above somebody's head is now different for each kind of thing,
because they are different kinds of thing.

A hero carries a shield with their initials on it, their name in brass, a
health bar and a bar for how much of their company is at work. The shield is a
pointed pentagon rather than a rectangle because a rectangle with letters in it
is a label, and the job of this is to be a device somebody picks out at a
glance from across a large map.

The second bar is a read-out like everything else: the share of that person's
sessions doing something rather than sitting at a prompt. It is not a resource,
nothing consumes it, and it cannot be spent. Calling it mana is the skin's word
for "how much is in flight".

A soldier carries its class in a coloured chip and the session it is. The chip
is separate from the name rather than run together with it, because a company
of mixed classes is the thing this map is about and telling them apart has to
survive being read at a glance.

Three things were wrong with where they sat. They were below the figure rather
than above it. They were positioned by their top edge, so the taller a board
got the further down it pushed -- which made the hero, whose board is the
tallest, the one whose name covered them; they hang by their bottom edge now.
And the clearance was a fixed number, so a hero at twice the size wore their
own name; it is measured from the sprite.

Names are shown at every zoom again, heroes and soldiers alike. They were
hidden below a threshold on the theory that a dozen boards over one camp
interleave into an unreadable stack -- which they do, and the answer is a wider
stagger and scaling the boards up as the map shrinks, not taking the names away
at exactly the distance you need them.

The shop sells two things now, on two shelves: a hero skin for your own figure
and a retinue livery for your soldiers. Run together in one list they read as
ten interchangeable hats, and buying one and watching the wrong figure change
colour is a small betrayal. Migration 016 adds the second column, because
packing both choices into one field is storing two facts in one place and
parsing them apart for ever after.
A pass over the game code after the hero rewrite. What it turned up:

**Heroes wore two health bars.** The small one drawn over the sprite and the one
on their plate, at different widths, in slightly different places, showing the
same number. The sprite bar is now only for figures whose plate does not carry
one.

**Your own chosen class never reached your own hero.** Everybody, including you,
was drawn as the harness they run most -- which is right for a colleague, whose
own choice this account cannot read, and wrong for you, whose choice is sitting
in your own saved game. It wins for your hero and nobody else's.

**`garrisonFor` was dead.** It sent a wright to the holding matching what its
session was doing, which is the model this no longer has: soldiers gather at
their own hero's camp. Removed rather than kept, because a function that
describes a model the code does not have is worse than no function.

**A comment claimed only sessions carry names**, which stopped being true when
heroes got them.

And one thing that was not a bug yet. `src/game/README.md` says `world/` knows
nothing about Pixi, and it was quietly false: the projection lived in `pixi/`,
and once the border and the camps needed it, `world/` was importing upwards out
of the renderer. The projection is pure arithmetic about the shape of the map
with no Pixi in it, so it belongs in `world/`, and that is where it is now. The
rule is worth keeping because it is what makes the simulation testable.

The spec sheet in docs/shell-keep.md is brought up to date with everything since
it was written: point-and-click movement, hero size, the shield and the two
bars, soldier class chips, plates at every zoom, the forest border, and the
shop's two shelves.
"I cannot control my hero" turned out not to be a control bug. Point and click
worked; what did not work was seeing that it had. One figure among several dozen
looks much like another, nothing said which was yours, and the name boards over
a camp covered the figures under them. The controls were fine and the map was
unreadable, which from the other side of the screen is the same complaint.

So every hero now commands ground washed in their own colour: an ellipse on the
ground plane, following them, so a company reads as an area rather than a crowd.
Yours is washed stronger and rimmed brighter -- and never colour alone, since
yours is also the one the view opens on, the one the HUD names and the only one
that answers a click.

The colours are derived from the account id and assigned across the whole roster
at once, like the camps, so the same person is the same colour on every machine
and no two in a team collide. The greens are left out of the wheel because the
map is grass.

The map also has more happening on it. The Unmade arrive three times as often
and stand in greater numbers, because at the old rate one foe wandered in every
three seconds and was put down before the next arrived -- so a besieged camp
looked much like a quiet one, which is the opposite of the point. Each camp
takes a share rather than one drawing the whole wave.

And a soldier building something now shows that it is. It raises no walls and
unlocks nothing; it is there because a camp where half the company was on
features had nothing visible happening in it, so feature work read as idling. A
map where only broken things move would quietly teach everybody that only broken
things count.

Camps are built for every site and shown only where somebody holds. Fourteen
sets of barracks on a map with three people on it reads as a country full of
abandoned camps, which is a different and wrong story.
…e class out

The class was written above every soldier -- ARTIFICER, BEASTMASTER -- which is
a lot of letters to say the same five things over and over, and at a camp with a
dozen soldiers in it the words were most of what was on screen. A mark says it
in one glance and a quarter of the width.

They are the real logos, from `public/icons/`, which is where the session list
already gets them. That matters more than drawing something prettier: a soldier
*is* a session, and the mark over its head should be the mark beside that
session in the console. Two sets of icons for one thing would drift the first
time either was updated, and the game would start disagreeing with the product
about what a tool looks like.

What makes them belong to this game is the mounting rather than the artwork.
Each is set on a brass-rimmed disc in its class's colour -- the same treatment
the panels get -- so a row of them reads as heraldry rather than as a toolbar
that wandered onto a map. They are fitted by their longest side, because these
are five companies' logos and not a matched set, and anything else lets one of
them dominate.

Each icon is loaded on its own and a failure is swallowed: five files in three
formats, and one being missing should cost that one sigil rather than every
sigil. A mark that will not load falls back to a plain device.
The strip was one panel wide enough to reach edge to edge and tall enough for
three rows, and what it mostly did was cover the map. Everything on it is
glanced at rather than read, and things that are glanced at belong at the edges
of the eye.

So: who you are, top left, because it is the only thing here you might read a
whole sentence of. What you have, bottom left, where money lives in every game
anybody has played. Who is out, bottom right. The pause button keeps the top
right, and the key prompt sits centred at the foot.

The crest is the medieval part: a shield pointed at the foot rather than a
square, because it is the one piece of this interface that says whose interface
it is, and that is the shape a device takes. Four straight lines and a point, so
a clip path rather than an image.

One fault worth writing down, because it is a rule about CSS rather than about
this game. The corners were pinned with `top: 0` inside a container that carried
the safe-area inset as padding, on the assumption that padding would hold them
off the edge. It does not: an absolutely positioned child is laid out against
its ancestor's padding *box*, so every panel sat flush against the glass, where
a television would have cropped it away. The inset belongs on the things being
inset.
It was pinned to the right edge, which meant reading about somebody while
looking at a box a screen's width away from them. It now stands next to whoever
was clicked and follows them as they walk.

Its position is written straight onto the node by the scene rather than kept in
React state. It moves sixty times a second, and re-rendering the route at frame
rate to move one box is paying a component tree for arithmetic. It is clamped to
the window, because a card about somebody standing at the edge of the view is
otherwise a card half off the screen.

Every fact carries a mark as well as a word. The mark is the faster read and the
word is the unambiguous one; a card skimmed wants the first and a card read
wants the second. Never the mark alone -- a glyph nobody has been taught is
decoration.

The class is the harness's own logo in a brass disc, which is the mark the
session list uses and the mark over that figure's head on the map. A letter in a
box would have been a third way of saying the same thing.

And the facts now depend on what was clicked. A hero shows the company they
command and what it is doing; a soldier shows whose company it is in. Both used
to show "Posted to", which for a hero read "the field" -- a sentence that tells
nobody anything.
**The Barrow.** A tenth holding, gravestones and a chapel, north of the Keep.
When a session closes its soldier does not blink out: it turns for the Barrow
and walks there, and is taken off the field when it arrives. A session ending is
where every point of experience in this game comes from, and a figure that
vanishes is the one way of showing that which says nothing at all. Heroes do not
march there -- a member leaving a team is an administrative fact rather than an
event on the map, and a graveyard would be saying something quite different and
untrue.

Its screen is the ledger behind the experience bar, written out: every count,
what each one is worth, and the sum. That is the honest answer to "where did
that number come from", and the reason the bar can be trusted at all.

**The signposts are lettered in blackletter.** Pirata One, SIL Open Font
License, vendored beside its licence and declared inside the game's own
stylesheet so it travels in the lazy chunk -- eight kilobytes to somebody who
opened the game and nothing to anybody who did not. Only the names of places
use it. A whole interface in blackletter is one nobody can read in a hurry, and
you stop walking to read a signpost, which is exactly the difference.

It has to be waited for before the scene is built. Pixi rasterises a Text when
the object is made, not when it is shown, so a webfont arriving a moment later
arrives too late and the signs stay in the fallback serif; `font-display: swap`
fixes that for the DOM and does nothing for a canvas. It fails soft -- a sign in
Georgia is still a sign.

**The shop was unreachable.** Not broken: unreachable. No service means no
finished sessions, no finished sessions means no experience, no experience means
level one, and level one means no marks and a pedlar who will not open. Anybody
without a working service and a week of sessions behind them could see the map
and none of what the map is for.

So the example team now has an example history, labelled as an example in the
HUD and in the Barrow -- a number that looks real and is not is worse than no
number. With it the shop opens, marks can be spent, and a skin can be seen
landing on a figure.
**Six legs.** Kenney's pack has no insects, so the Unmade were dark-tinted
soldiers -- which read as "the enemy team" rather than as vermin, and the whole
conceit is that these are faults crawling out of the ground. Nothing with six
legs is a person, so that settles it in a way no amount of tinting could.

They are drawn rather than sprited: a body of one to three segments, a head with
eyes, feelers on the big ones, and six legs that walk in alternating tripods --
front and back on one side with the middle of the other, which is how a real
insect walks. The legs are children that rotate, so a walking creature costs six
transforms rather than a re-tessellation, which at forty of them matters.

**They can also take a hit now.** A camp has a hero hitting twice as hard as
anybody else, several soldiers and the holding's own watch, so at the old
figures a foe arrived and was gone inside a second: the fighting was a flicker
of damage numbers rather than something you could watch. Each carries a small
red ring, which says "this bites, and only this close" -- deliberately a
fraction of the weight of a hero's, or the map would be a field of overlapping
circles and neither would mean anything.

**Heroes mend, faster than they can be worn down.** A hero driven back to their
camp every few minutes reads as somebody losing, and nothing on this map is a
contest: the Unmade are a picture of faults being worked on, not an opponent.
They still take damage, still flinch and still show it. They just do not lose.

**A click leaves a mark.** An order was invisible for the second it takes a hero
to turn round -- you press, nothing appears to happen, and you press again. A
ring that opens and fades answers the press immediately and says *where* rather
than merely *yes*.

**The pointer is an arrowhead**, vendored as a file rather than a data URI
because a cursor is fetched under `img-src`, and a policy widened for a pointer
is a policy widened for a pointer. Its hotspot is the tip: anywhere else and it
clicks somewhere other than where it is pointing, which on a map where clicking
moves your hero is maddening in a way nobody can name.

**The vial is green glass.** It was a black slot that filled with green, which
at zero tokens -- where every account starts -- was a black slot, and read as
something somebody had forgotten to style.

**Headings are lettered like the signposts.** The interface was monospace
throughout, in capitals, widely letterspaced, which is a terminal's voice, and
this is supposed to be a keep. Only the titles change: a heading is read once
and a number at a glance, and the two want different faces.
**Three fixes first.** The enemy rings were nearly transparent, on the theory
that a crowd of them would be noise; what that actually did was make the one
thing on the map that can hurt you the hardest thing on it to see. Every panel
was a dark rectangle floating inside a brass rectangle with the map showing
through the gap, because the background was clipped to the padding box and the
frame is a sixteen-pixel border -- `border-box` puts the stone behind the frame,
which is what a framed panel looks like. And everybody on the map is half again
larger: Kenney's figures are scaled to stand beside Kenney's buildings, which is
correct and useless on a country a hundred and twenty-eight tiles across.

**The pack.** Fifteen files of about two hundred, because the rest is art
nobody loads and a chunk somebody downloads.

Banners now fly at every camp. A camp was a barracks, a tent and a gate, which
from above is three roofs and indistinguishable from any other cluster of
buildings; flags are what say somebody holds this ground. A different set per
site, chosen by position rather than at random, so two camps side by side are
not the same picture twice and the same camp always flies the same colours.

Conifers from the pack are mixed into the border wood, one tree in three. A wood
of a single silhouette repeated a thousand times reads as wallpaper however well
it is drawn, and these are flat dark shapes -- which is what a tree looks like at
the only distance any of this is seen from.

And the interface's marks are drawn icons instead of typographic glyphs. ⚒ and
⧗ and ❯ are whatever the reader's font happens to have for them, they sit on the
baseline at sizes nobody chose, and some render as boxes. They still never
appear without the word beside them: a picture nobody has been taught is
decoration.

The banners are downscaled from three thousand pixels to three hundred and sixty
with `sips`, which is macOS-only -- so the results are committed rather than
generated during the build, because a build that works on one operating system
is worse than a script that only re-runs on one.

**The pack arrived with no licence file.** The notices entry records where it
came from rather than what it is licensed under, and says so. Somebody preparing
this for distribution needs to confirm those terms.
Four things, all of them about being able to see what you are looking at.

Picking measured tile distance from a figure's feet, so only the lower body
answered a click. A figure rises a hundred-odd pixels out of the tile it
stands on, and in tile space its own head is several tiles away from it --
so clicking a hero's chest asked about whatever field is behind them.
`ActorLayer.hit` tests the drawn body instead, in world space, and the
topmost match wins where two figures overlap. Probing straight up from a
hit now answers for 128px of body and stops where the drawing does.

Your own hero's board is rimmed, shielded and lettered in turquoise, and
nothing else on the map is. The company ring already says which ground is
yours, but a ring is on the floor and the board is where the eye goes.

The imported art is three times the size it was. The banners were about as
tall as a soldier, which wasted them: the job of a camp banner is to be
seen from across a camp. The conifers were drawn to Kenney's height, which
wasted them worse -- they are the tall dark shapes that give the border
wood its depth, and at everyone else's height they were just more trees.

And three more pieces from the pack. A castle on Prompt Keep, because the
Keep is the account itself and the middle of the map; two siege engines at
Watchmen's Rise, where faults are met. The third is a banner per camp in
its holder's own colour, and it is the grey render rather than the red
flags beside it for one reason: grey takes a tint and red goes muddy. The
flags say "a camp". This one says whose.
…r evening

The lanes were ruled lines. Nine of them out of one Keep made a wheel with
spokes -- a diagram of how the holdings connect rather than a country
somebody walks through. Each road now takes a bow whose size and direction
come out of its own two endpoints, with a wander over the top and a width
that swells and narrows, stamped as a disc per step so the edge is ragged
at the scale the ground is actually drawn at.

The line is worked out once and read by both the ground and the scatter.
They used to walk a straight line each, which agreed only because two
identical expressions cannot disagree. A test caught a second version of
the same fault while it was still theoretical: the bow is measured
perpendicular to the run and the width is a function of distance along it,
so asking for the same road from the other end gave a different lane. It is
canonical now. The scatter's own test had a third copy of that straight
line in it; it reads the worn earth instead, which is both independent of
the road code and the thing anybody would actually see.

Beside the lanes: fences in runs along one side, bales in twos and threes
off the verge, and lanterns. None of the three exists in either art pack,
so all three are drawn -- a fence is posts and rails following the diamond
grid, a bale is a cylinder on its side, a lantern is a post with a light.

And it is evening. Three tints by distance rather than one dark sheet over
the top: a single wash dims what you are looking at by exactly as much as
what you are not, which is the opposite of what dusk does. The lamps are
what lift it back, and that is what makes them worth having.

Two things about the light were arrived at the hard way. It is a pool on
the ground, under everything standing on it, because that is where lamplight
goes -- over the top it washed out the very figures it was lighting. And it
blends normally: this renderer takes the WebGPU path, where Pixi maps `add`
to a blend whose alpha function is [ONE, ONE], so every lamp also added to
the canvas's own alpha channel and the whole map came back milky. `screen`
is better and still hazes. A warm translucent pool over dark ground reads as
light perfectly well and renders the same on every backend.

Under reduced motion the lamps hold at full brightness rather than going
out. Everything else that moves here is ornament and is hidden outright; a
lamp is what makes the ground under it legible.
Four things on this map overlapped, and they were four different faults.

The buildings floated because `standing` put the contact shadow at the tile
centre while dropping the sprite a fifth of a tile below it -- so every
structure on the map cast its shadow up-screen of its own feet. The roadside
props next door already had this right; two files doing the same job disagreed,
and the one with more on it was wrong.

The castle had a hall underneath it. Structure_02 at 1.6 sits at 64,60 and the
castle stands at 64,61, so the middle of the map was two buildings claiming one
tile. The castle is the Keep's hall now, so the other one goes.

The fences stacked because a road step is half a tile -- `steps` is `span * 2`
-- and a section is drawn 2.1 tiles long. Placing one every other step put each
section a single tile from the last and ran it more than halfway through its
neighbour. Every fourth step is 2.0 tiles, which is a rail meeting a rail. Runs
are counted in steps, so they are lengthened to match or a run became one
section and a gap.

The name plates were added in roster order and never given a depth, so a figure
at the back could paint its name over a figure in front. They now sort on the
same depth as the figures under them.
The map was lit for night rather than for evening, and the lamps that are
supposed to lift it back were not bright enough to lift anything: a 120px pool
at four percent a step left the ground under a lamp the same colour as the
ground twenty tiles away, which makes every lantern on the map an ornament. The
pool is wider and its centre is hotter, so the verge a lamp stands on is the
part of the road you can actually see.

The three dusk tints come up with it. Cold is what reads as evening; this dark
was just dark.

The border wood was three flat near-black silhouettes repeated, which at dusk
turned the whole edge of the map into one black band -- a wall rather than a
wood. Two more conifers from the same nature pack, drawn in sage rather than
outline, go into the mix, and the darkening with distance eases from 0.55 to
0.34 so the far trees stay trees.

Note for anyone re-running the import: palmtreeart-05 and -08 are already here
as pine-tall and pine-broad, so the two added are -06 and -07. Taking the other
two would have committed the same art twice under new names.

Also: there are ten holdings. GARRISONS has ten entries, the road book draws ten
"Ride there" buttons and the specification's own heading says ten. Two places in
the interface said nine.
…d bridge the river

The camera looks at the bottom corner of the diamond, so what is further down
the screen is nearer and has to be drawn later. That is one rule, and most of
what looked wrong here was places it was not being applied.

Depth can only decide anything between siblings, and the border wood was a
container of its own added before everything else -- so a tree at the near
corner of the map, the nearest thing there is, was painted behind a building at
the far one. The edge trees now join the same sorted layer as the buildings,
the props and the people, with a depth taken from where they landed.

The circle shadows are gone from under every standing thing. A flat disc under
a sprite in this projection does not read as contact; it reads as a thing
hovering over a disc, which is the float it was added to prevent. What puts a
building on the ground here is standing on its own tile and sorting correctly
against its neighbours.

**Buildings are solid.** `sim.ts` says there is no obstacle avoidance and never
will be, and it is right about the thing it is refusing: the version it
describes steered *before* moving, so an actor re-decided its route thirty
times a second out of a slightly different position each time and spent its
life turning round. Deleting that was correct.

This is the other half of the problem and it is not the same half. Nothing in
`world/solids.ts` plans, looks ahead, or changes where anybody decided to go. A
step is taken exactly as it always was, and only then is the result checked: if
it landed inside a wall it is pushed back out to the nearest point outside.
That is a function of position alone, so two ticks have nothing to disagree
about and there is nothing to oscillate. Walking into a wall at an angle slides
along it, because the correction is perpendicular and the rest of the step
survives. `solids.test.ts` holds it to exactly that: that the fix is
idempotent, that no approach angle leaves anybody inside the stone, and that a
wall can still be walked along. Only the fixed holdings are solid -- a camp is
where a hero's own retinue stands, and solid tents would pen them in.

The castle is pixel art now, and the number that matters in it is not the
height but the block. Kenney's sprites are drawn a texture pixel to a world
pixel; at 160 across and a scale of 2 a castle pixel is two world pixels, near
enough the map's own grain that the two read as one picture. Scaled up until it
filled the screen it read as a castle pasted in from another game. It is made
by `scripts/pixelate-castle.py`, which resizes *and* quantises -- downscaling
alone keeps the render's gradients and turns them into big soft blocks. The
full-size render is no longer committed: nothing loaded it, and 233KB nobody
fetches is 233KB in the deploy.

There is planting round its foot. A building that size on bare flagstones has
nothing to say where the ground begins, and the eye reads the gap as air.

The fences were a handful of sticks: two hairline strokes and three two-pixel
posts. A rail is a board with a lit top edge and a dark underside, a post is
squared timber with a cap, and there are five of them to a section.

And the west road, out of Ravens' Roost, over the river and into the wood. It
is the only lane that does not join two holdings and the only one that crosses
water, and both are the point: the Roost is the inbox, so what lands there came
from somewhere that is not on this map. `crossings()` finds where a road meets
water by reading the road and the ground rather than a written-down position,
so a river that moves takes its bridge with it.
A hero had sixty points in them, which is less than a single heisenbug carries,
so a camp with a fault on it put its own person down in a few seconds while
their retinue stood around them. There is no death here and nothing to lose:
the bar is not a stake, it is a read-out of how hard a camp is being hit, and it
has to survive a wave to say anything. Four hundred and twenty does.

The name boards were staggered over five bands twenty-two pixels apart, and a
board is about thirty tall -- so the stagger was moving the collision around
rather than preventing it, and a hero was pinned to band zero, which is also the
lowest band a soldier can take. Six bands at thirty-six clear a board outright,
and a hero now rides above every band their own retinue can reach.

The castle is laid over onto the map's angle: y' = y + k(x - mid), which leaves
every vertical vertical -- the towers stay upright -- and slopes the horizontals
down to the right. Not the tile's own 0.5, because the render has its own
perspective in it already and stacking the two makes the castle read as sliding
down a hill; 0.22 was picked by looking at it beside the Kenney structures.

There is a band of grass round its foot now, six tufts from the nature set,
trimmed and downscaled by `scripts/import-grass.py`. Laid as an ellipse rather
than a circle, because the ground plane is a 2:1 diamond and a ring of equal
radius in tile space is not a ring on the floor. Thinner across the front so the
gate is not behind a hedge.

The bridge was crooked because it was built along the road's own tangent, and
the road bows -- so where it met the water it was heading a few degrees off west
and the deck matched neither the river nor the grid under it. A crossing now
takes the nearer tile axis and the deck runs straight along it, with three tiles
of bank at each end so it lands on the road rather than at the waterline.

Half the fences, and it is the number of *runs* that halves rather than the
sections in each. Thinning every run would have taken the wrong half: a run with
gaps in it is not a field boundary, it is litter. Fewer veins, each still joined
end to end.

And `pushOut` is bucketed by cell. Walking all fifty solids for every actor on
every tick was enough to take the simulation from comfortably real-time to
timing out the thousand-tick test in sim.test.ts. Nothing here moves, so the
buckets are built once; open country, which is nearly all of the map, now costs
one map lookup.
The pedlar showed a square of the skin's colour beside each name, which is an
honest description of the mechanism and a useless description of the goods. A
skin *is* one number multiplied over a sprite -- but what somebody is buying is
how their own wright reads standing on the map, and nobody can see that in a
rectangle of #c96a92.

So each shelf now draws the figure the skin dresses, from the same atlas the
renderer draws from, tinted the way the renderer tints it: multiply, then the
sprite again as a mask to give the transparency back. That is what Pixi's
`tint` does, and doing it the same way is the reason the two agree -- computed
differently, the shop would be lying about the goods. A hero skin is shown on
your own class's unit and a retinue livery on a soldier, because those are the
figures each will actually wash over.

The colour survives as a strip under the portrait. It is still the one part of
this that is legible running an eye down a list.

`UNIT_FOR` moves to `pixi/units.ts`, since the map and the shop both need it
now and two copies would drift -- and that drift would be a shop showing one
soldier and delivering another.

The sheet is fetched once for the whole panel rather than once per item, and a
failed fetch costs the pictures and not the ability to spend: the name, the line
of lore and the price are all beside it.
The imported render could not be made to stand on this grid, and the reason is
worth writing down because it was two rounds of trying.

A castle on a diamond grid has to have *both* of its ground axes on the grid's
axes. A y-shear lays the horizontals onto one of them, which is what the last
commit did, and it fixes exactly half: the base runs parallel to the ground and
the depth still runs straight up the screen, square to a map where nothing else
is. The other half needs an x-shear, and an x-shear leans every vertical -- at
0.18, 0.25 and 0.34 the castle reads as falling over, not as turned. A shear
cannot rotate a three-dimensional render. Only a camera can, and there was one
picture of it.

Kenney's own set has the pieces: Structure_02 is a gatehouse with a tower at
each end of it, Structure_06 a keep, Structure_01 a tower. They were drawn
isometric, so both axes are right by construction; they are lit from the same
corner as everything else; and they are the same artwork as the hall and the
chapel standing round them. The Keep is four of them now, and smaller than the
render was -- it dominates the holding without swallowing it.

They are laid out in *screen* pixels, not tiles. A castle is a thing whose parts
sit beside each other in the picture -- a gate in the middle, a tower at either
end -- and a tile offset walks diagonally, so placing them by tile put the left
tower nearer the camera than the right one and the whole thing came out as a
staircase of roofs.

The grass is pixel art now too, and is grown along the foot of the walls rather
than on a circle drawn round the middle of the tile. Each piece is anchored
bottom-centre, so the line it meets the ground along is its own width laid
across the screen at its own feet; the tufts walk that line in screen pixels and
convert back to tiles. They sit nine pixels down-screen of it, because on the
line exactly a tuft sorts behind the wall and the wall covers it.

`import-grass.py` flattens them the same way the castle was flattened: down to
forty pixels with a box filter, ten colours, and a hard alpha edge. Resizing
alone keeps the painted gradients and turns them into big soft blocks, which is
a photograph dropped onto a cartoon. The scale in the scene is against the new
forty rather than the old two hundred and twenty.

`castle-8bit.png`, `pixelate-castle.py` and `castle-base.ts` are gone. Nothing
loads them, and the repo's own rule is that art nobody loads does not belong in
a chunk somebody downloads.
…d gone stale

**The CI break.** `store-conformance.test.ts` truncates a list of tables between
cases and the game's two were never added to it. `recordCollectionRun` makes a
`game_profiles` row when there is not one, so a run recorded in one test left a
row behind and the next test's `putGameProfile` upserted onto it -- and that
upsert deliberately keeps the original `created_at`, which is correct and meant
the leaked row's timestamp came back instead of the one just written. Only
against Postgres, because MemoryStore is built fresh per test, so it passed for
anybody without a database and failed in CI.

**The same omission, mirrored.** `verify-schema.ts` arrived on main while this
branch was out, and its `REQUIRED_TABLES` does not know the Worker now serves
/api/game, /api/game/runs and the gathering. A deploy missing the game
migrations would have verified clean and then failed on the first request to the
keep. Both tables are in the list now.

**A ceiling on what the field draws.** Every figure costs a container, a sprite
and a name board, and a name board is a Pixi `Text` -- which rasterises a texture
of its own. Fine for a team of six; not fine without a ceiling, because an
organisation of sixty people running twenty sessions apiece is twelve hundred
figures and twelve hundred textures, which is how a tab runs out of GPU memory.
Sixty heroes, two hundred and forty soldiers, fourteen per hero -- the per-hero
cap because one person with a hundred sessions open should not fill the field
while everybody else's camp shows nothing.

Yours is sorted first, so a cap can never take your own company off the map: the
view opens on your hero, the HUD names them, and theirs is the only one that
answers a click.

What the HUD *reports* is not capped. The field is a budget; the read-out is the
whole point of the game, and making a large team's own statistics wrong to
protect its GPU would be protecting the wrong thing.

**Comments that had stopped being true.** `actors.ts` carried a paragraph saying
heroes and soldiers are named at different zooms, immediately contradicted by
the paragraph under it and by the code; `platesShown` was a field that was only
ever assigned `true`. `scene.ts` documented a `shadow()` helper deleted two
commits ago, and described the camps sixty lines above where the camps are
built. `GameRoute.tsx` explained the same ref twice. Removed, not reworded: a
comment that disagrees with the code is worse than no comment, because it is
believed.
The game is a large amount of code that no paying use of this product depends
on, and while it ships inside `shell-online-app` it shares that Worker's deploy,
its rollback and its blast radius: a bad game build is a bad console build.

So it gets its own. `app/game.html` and `src/game/main.tsx` boot the game and
nothing else; `vite.game.config.ts` builds it into `dist-game/`, inheriting the
whole of `vite.config.ts` so the sign-in checks and the version stamp cannot
drift; `wrangler.game.jsonc` deploys it as `shell-online-game`, a Worker with no
`main` at all -- static files, no database binding, no mail, no cron, no
secrets. It needs none of them, because the game calls /api/* on the same origin
and every one of those lives in the console's Worker.

`deploy-game.yml` runs only when the game's own files change, refuses anything
but main, and typechecks, tests and checks the readability rules before it
builds. A game that breaks its own rules is not deployed however green the
console is.

**It has no route, deliberately.** The game has to be on the same origin as the
console: sign-in is a Firebase ID token, Firebase persists per origin, and a
second hostname would be a second sign-in and an API on the far side of CORS.
The isolation wanted here is of deployments, not of identity. Same origin means
path routing, and `shell-online-app` claims app.shell.online as a *custom
domain*, which takes the whole hostname -- so pointing a path of it at a second
Worker means moving the console to a zone route first. That is a change to
production routing for the thing people actually pay for, and it is a decision
to take deliberately rather than a side effect of merging the game.

Until it is taken this deploys and is reachable by nothing, which is the safe
half: the pipeline can be proven before any traffic is pointed at it. The
console keeps serving the game at /game exactly as it does today, so nothing
about this commit changes what anybody sees.

docs/deploy-game.md has the switch-on order, the rollback, and what the
console's deploy still owns -- which is the game's two tables, because
migrations belong with the Worker that answers the requests.
@Alexgodoroja
Alexgodoroja merged commit abb9ace into main Sep 17, 2026
15 checks passed
@Alexgodoroja
Alexgodoroja deleted the game-skin branch September 17, 2026 02:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant