Plan #
What lance.blue is building, and roughly in what order. One file per epic. milestones.md is the other view of the same work: releases worth naming, in prose, without reference to any of this.
An epic is a line of work that takes many pull requests and has an exit criterion somebody could check. It is not a task list — the tasks live inside it, and inside the TODOs of whichever repo does the work. Everything here spans the whole project, not just this repo: headquarters is the main repo, so an epic that is mostly arena's or infra's work still has its file here.
Two decisions shape all of it #
State is PDS-first. Durable, player-owned artifacts — match results, challenges, camo references — live as records in the player's own PDS, not in a database of ours. headquarters-api keeps only OAuth sessions and an ephemeral live-match registry. This is why so many epics carry a schema, and why managed-store is about server state rather than about everything.
Compose-first. headquarters-api, web and arena all run locally under Docker Compose before anything runs on AWS.
The id is the commit scope #
Each epic's filename is its id, and that id never changes. plan/complete/camo.md
is camo, so its commits read feat(camo): … and fix(camo): …. That is the
whole reason the ids have no number prefix, and the reason there is no order
field either: a filename or a field that has to be rewritten when something
else moves is not an identifier. Archiving moves the file
between directories and never renames it, for the same reason.
plan is a valid scope too, for changes to this register.
Order is advisory, and unnumbered #
The Open table below reads roughly top to bottom, and that is the whole of the
ordering. There is no order field and no numbered column, because a total
order over twenty epics is a lie that costs something to maintain: inserting
one at the front used to renumber every file beneath it and every row here, so
two branches each adding an epic conflicted by construction, over a number
neither of them cared about.
What actually constrains the work is dependsOn, which says what genuinely
cannot start first, and status, which says what is blocked. Those are per
epic, they are true independently of what anybody else is doing, and moving an
epic is moving one line.
The sequence the tables read in comes from order.txt, which is a list of ids and nothing else. It is advisory and it is optional: an epic it does not name still appears, alphabetically, at the end of whichever table it belongs to. So a new epic needs no line in it — add one only when where the epic sits is worth saying to somebody reading down the list.
Status #
shipped — the exit criterion is met. The file stays, because how something
was built and what it cost to learn is worth more after it works than before.
A shipped epic with nothing open left moves to complete/; one
with loose ends stays here until they close.
open — being worked on or ready to be.
blocked — cannot start until something in dependsOn lands.
continuous — no exit criterion and no completion date. Worked whenever
adjacent code is open. Accessibility and copy are like this: they are never
done, and pretending otherwise just produces an epic that is permanently 80%
complete.
declined — decided against. Not blocked, which is waiting: the answer is no
rather than not yet. The file stays, because a decision that is not written
down gets made again.
The tables are generated #
The five tables below are output, not source. scripts/gen-plan-readme.py
reads every epic's frontmatter and rewrites what sits between the
<!-- generated: … --> fences. Everything around them is hand-written and is
passed through untouched, so prose can go anywhere except inside a fence. The
plan-register prek hook runs the script with --check, which means a row
that disagrees with the file it points at fails the commit.
Adding an epic is therefore one file and one command: write plan/<id>.md
with the frontmatter keys every other epic carries, run
scripts/gen-plan-readme.py, and stage both. Nothing here is typed by hand —
that is what stops two branches each adding an epic from conflicting over rows
neither of them cared about.
Which table an epic lands in follows from the file as well. complete/ is Complete, and outside it the status picks between the other four. Shipping an epic, or archiving it, is an edit to the epic and nothing else.
Complete #
Nothing open, nothing left to decide. These live in complete/ so the list above stays the list of things somebody might work on. They are not deleted — how something was built, and what it cost to learn, is worth more after it works than before — and their ids stay valid commit scopes, because a follow-up fix to a finished epic is still that epic's commit.
| id | title |
|---|---|
| sign-in | A player signs in with their own ATProto account |
| camo | Player-owned camo, drawn here, stored in their PDS, painted in the match |
| match-startup | A slow match start can be attributed instead of guessed at |
| match-launch | A match runs on Fargate from a manifest we authored |
Shipped, with loose ends #
The exit criterion is met and something is still open in the file.
| id | title |
|---|---|
| lobby | Several humans get into one container |
| flare | In-match feedback reaches a public board |
Open #
| id | title | status |
|---|---|---|
| match-records | A finished match is player-owned, checkable data | blocked |
| match-lifecycle | A match starts, survives, and ends properly | open |
| match-control | In-match commands run through us, not MegaMek's chat box | open |
| session-security | A session is only as alive as the grant behind it | open |
| managed-store | Losing the box does not lose login grants | open |
| proxy-split | Deploying the API does not drop every live match | open |
| security-review | The boundaries have been looked at deliberately, not incidentally | open |
| scenarios | The scenario catalog is generated and validated, not curated | open |
| user-safety | A player has somewhere to go when another player is the problem | open |
| invitations | Nobody is put in a match without being asked | open |
| player-profile | A player has a page that is about them | open |
| onboarding | Somebody who has never played MegaMek can finish a match | open |
| daily-challenge | One fight a day, the same for everyone, scored | open |
| cost-model | What a match costs is measured, and what a busy day costs is known | open |
| unit-library | Every unit MegaMek knows is queryable, from any MegaMek | open |
| unit-search | Filtering units means the same thing everywhere it happens | open |
| unit-rules | A force's battle value is ours to compute, and the same on both sides | blocked |
| forces | A player brings their own list | open |
| post-to-bluesky | A player can post a match to Bluesky, and is never surprised by one | blocked |
| social-presence | Somebody who finds one post can find the project | open |
| achievements | A player is awarded something and chooses to show it | open |
| appview | lance.blue shows a match it never hosted | open |
| leaderboard | A rating anybody can recompute | open |
| seasons | A run of days is a season with a theme and an end | blocked |
| after-action | A finished match is worth reading, not just counting | open |
| campaign | Pilots and forces persist between matches | open |
| unlocks | A campaign is walked by unlocking the library one design at a time | blocked |
| tournaments | A tournament says what may enter, and checks it | blocked |
| house-rules | One named set of rules every lance.blue match is played under | open |
| setup-tree | Match setup is one tree, rendered as a lobby and as a scenario file | open |
The middle of that list — security-review through invitations — is where the project stops being played only by people who know each other. It sits ahead of the things that bring strangers in, onboarding and the daily challenge, rather than behind them, because a consent gap or a missing block is much cheaper to fix before it has been exercised than after.
Continuous #
| id | title |
|---|---|
| site-copy | Every user-facing string is written by a human |
| site-a11y | The site works at 200% text on a phone |
| web-testing | The app has a test runner, not regexes over source |
| publishing | A post reaches the network in one deploy |
| render-performance | Input-to-pixels stays under the tail |
| asset-caching | Static assets are reused across matches |
| arena-image | The image is small, current and reproducible |
Not pursued #
Answered no. A file here is a list of answers rather than a line of work.
| id | title |
|---|---|
| declined | What lance.blue is not building |
Where the schemas are #
There is no lexicon epic. A blue.lance.* or games.permadeath.* schema is a
deliverable of whichever epic needs the record, listed in that epic's file, and
authored in the lexicons repo. Doing it the other way round — a schema track
running ahead of the features — is how you publish a collection name nothing
ends up using, and a collection name cannot be taken back once records exist in
other people's repositories.
lexicons/PLAN.md was that other way round, and its nine stages are where most
of this register came from. Treat it as a source, not as an authority.
Every epic ends in Done #
Open work above, finished work as - [x] items under a trailing ## Done.
An epic with nothing under Done says so.
That is what makes "is this finished" answerable by looking rather than by
reading: an epic with no - [ ] left is a candidate for complete/.
Where TODO.md went #
This directory replaced it. TODO.md had become a flat list of everything anybody had noticed, with the milestone chain mixed into one-line bugs; every entry in it now lives in the epic that covers it, verbatim where the wording was already right.
There is deliberately no general TODO file any more. Something worth writing down belongs in an epic — and if it belongs to no epic, that is the useful signal, not an excuse to start another list.
Rendering this later #
The frontmatter is shaped for an Astro content collection — the same glob
loader web/src/content.config.ts already uses for the blog, with base
pointed at this directory and this README excluded from the pattern. Nothing
renders it today, and turning it into a public "future development" page is a
decision about what the project promises, not a build step.