Add Shell Keep, a game skin over the session list - #171
Merged
Merged
Conversation
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mjsfails the build if any of it reaches the entrybundle, 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
manualChunkslooked tidier and made Vite preload it forevery 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/statsreads git, the GitHub CLI and the coding agents' own historyfiles 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 statsprints exactly what would be sent and sendsnothing, 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-designskill, withscripts/check-game-ui.mjsenforcing it innpm test: a 16px floor for text, 44px for anything you can hit, motion at orunder 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
http://localhost:5173/qa.html— the whole app, signed in. This is whatto 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 lookingat the artwork without the app around it.
Neither page is an input to
vite build, which buildsindex.htmland nothingelse, so both exist in development and in no deployment.
Isolation
Everything the game is lives in
src/game/andsrc/styles/game.css.src/game/README.mdrecords the seams where it touches the rest of theapplication, 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_profilestable, and twoStoremethods; nothing else onthe server reads them.
One module is shared deliberately.
src/game/world/work.tsdecides whether asession 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 ofthis 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.mjsconverts Kenney's XML atlas to theJSON Pixi wants.
scripts/import-fantasy-ui.mjsrecolours the panel frames fromwhite 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 plumbingwith
new Function; andAssets.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.tscounts direction reversals over nine hundred ticks, whichis 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:
musterandgarrisonSoldierspushed 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.tsopens bypromising 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.tschecks 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
garrisonFordescribing amodel the code no longer has, and
world/importing the projection upwards outof
pixi/while the README claimed it did not. The projection is purearithmetic about the shape of the map, so it now lives in
world/.check-game-uicaught its own author three times, all at fifteen point texton 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 statswasrun 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.mdis the specification the implementation is held to: whateach 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.