Plan #
What this repo is building, in the order it gets built. Each stage has a file under TODO/ with the implementation tasks.
The project's state model is PDS-first: durable, player-owned artifacts live as records in the player's own PDS, not in a database of ours. This repo defines those records. It is on the critical path for headquarters' M3, and it is what the OAuth scope request is waiting to name.
Two namespaces #
blue.lance.* is for anything specific to this game. A camo, an after-action
report, a pilot. None of it means anything to a chess ladder.
games.permadeath.* is the identity and social layer, and nothing else. A
match, a challenge, an achievement, a rating. The rule for deciding is whether
the field would still make sense for a game that has no mechs and no hexes. If
it would not, it belongs in blue.lance.
Both domains already have DIDs. The _lexicon DNS records point at them, and
each repo publishes the schemas for its own namespace.
Three ideas that shape the schemas #
A match is a claim until somebody sanctions it. Every participant writes their own copy of the result into their own repo. A sanction is a separate record, written by a third party into that third party's repo, saying the result is one they vouch for. Zero sanctions is a valid state, not a broken one. A tournament organiser can sanction a match without our involvement, and a leaderboard decides for itself which sanctioners it honours. Participants' copies that disagree with each other are visible to anyone reading.
Nothing depends on a global order. ATProto has no reliable network-wide ordering. Timestamps are self-reported, record keys are attacker-controlled, and firehose sequence numbers are specific to one relay. Anything that needs matches in order would break as soon as a second sanctioning service existed. So the rating is computed from a set of matches rather than a sequence, using an algorithm where processing order does not change the answer.
Point at finished things with a CID; point at changing things without one. A strongRef pins a specific version of a record, which is what you want for something that is done. Records that change by design — a challenge moving from open to accepted, an achievement tier being upgraded — must be referenced by bare at-uri, or the reference silently goes stale the first time the record is written again.
Stage 1 — Namespace conventions and tooling #
No schemas are written here. Each stage writes its own when it needs it, and adds the DNS record for its prefix if that prefix is new. What has to be settled first is only the part that is expensive to be inconsistent about: how a name is chosen, how a record key is derived, when a reference carries a CID and when it does not, and the rule for which of the two namespaces something belongs to.
The reason those specific things come first is that a collection name cannot be changed once records exist in other people's repos. ATProto's answer to a breaking schema change is to mint a new name and leave the old one behind. That argues for deciding the conventions early, not for writing fifteen schemas before the first one is used. The rest of this stage is tooling: the directory layout, a linter in the commit hooks, and writing down the publication steps once stage 2 has been through them for real.
Stage 2 — Camo #
Camo is the prototype. It is one record in one repo with no second party
involved, so it exercises the whole write path without any of the hard
questions. Widening the OAuth scope past bare atproto, uploading a blob to
somebody else's PDS, writing a record there, and reading it back out are all
new, and all of them are easier to debug on a collection that nothing else
references. It is also where the schema publication steps get done for the
first time and written down.
The record is deliberately a leaf: an image and a name, referencing nothing. A player has several. What uses a camo points at it — a configuration profile naming the one a match should default to, and later a persistent unit naming the one it wears. Keeping selection out of the camo record means the same stored image can be a default today and a unit's markings later without being rewritten, and that nothing needs migrating when those things arrive. A default camo will ship in the match image for players who have not made one; today they get MegaMek's own colours.
Stage 3 — Achievements #
Achievements need a definition, an award and an acceptance, and none of those depend on a match existing. An award granted by hand — for a donation, say — touches nothing else in the system. That makes this the earliest point where the game feels like a game rather than a log, and it does not have to wait for the hardest schema in the project.
The awards arrive in three waves, all using the same records. The first wave is whatever can be issued now, without match data. The second wave points its evidence at match records once stage 4 exists. The third wave comes from an aggregating service that can see things no single match knows about, such as a player's hundredth match; those wait for stage 6. Because the evidence field is an open union from the start, none of the later waves need a schema change.
Awards are written into the issuer's repo, never the player's. A player cannot mint their own, and anybody can check a claim by reading the issuer's repo without asking us for anything. Acceptance is a separate record in the player's repo, so a player chooses what to display.
Stage 4 — The match record #
This is headquarters' M3. A finished match writes a record into each participant's repo, and a sanction into the service's repo. It is the first schema with more than one party in it, and the first that has to handle a participant who never granted us the scope to write on their behalf. The sanction exists either way, so a missing player copy degrades rather than breaks.
The record is created when the match starts, not when it ends: it is born with a live status and an address where the match can be watched, and it transitions to complete when the result is known. That way "who is playing right now" is a query over records that were always going to exist, rather than a second set of presence records that need cleaning up afterwards.
This stage needs arena to produce a better result document first. Today's result has no winner field, no timestamps, no scenario name and no starting unit count, and it is only written at all if the match reaches victory. The outcome vocabulary has to cover the matches that do not.
Stage 5 — Multiplayer #
A challenge names slots rather than an opponent, because matches are N-player and team-based. It either names invitees directly or names a condition for who may accept, such as membership of a list. Membership is resolved when somebody accepts, not when the challenge is written, so a challenge to a group still works for people who join the group later.
Acceptances are claims, like everything else here. If several people race for one open slot there is no ordering to appeal to, so the match record settles which acceptances took. A challenge can also name the sanctioners the challenger would like; that is a request, not a binding, since a sanctioner writes its own record or does not.
Most of the work in this stage is in arena rather than here. The container renders one browser path for the first human slot, and a second client joining a held slot is renamed and owns nothing.
Stage 6 — The appview #
Everything up to here reads from our own database. This stage reads from the network: an ingest service filtered to our collections, plus a backfill pass for repos that already hold records we have never seen. It is the first thing in the plan that is a new service rather than a schema.
It unlocks two things. Matches and challenges written by clients that are not ours become visible, which is the point of putting them in PDSes at all. And achievements that can only be observed across a player's whole history become issuable.
Stage 7 — Leaderboard #
The rating is published as two records: a policy that states the algorithm, its version, which sanctioners it honours and how much unsanctioned matches count for; and an epoch that pins the exact set of matches it consumed and the ratings that came out. Pinning the input set as a Merkle root over CID-sorted sanctions gives a canonical commitment that does not depend on anybody's clock.
The goal is that somebody else can fetch the pinned inputs, run the published algorithm, and get our numbers. That is achievable; bit-identical agreement with an observer who saw a different slice of the network is not, and the difference should be written down rather than implied. Two rating services that honour different sanctioners will legitimately disagree, and that is correct behaviour rather than a fault.
Stage 8 — After-action reports #
The turn reports arena writes today are the Swing client's presentation layer, HTML and all, at ten to twenty files per round. They are the wrong thing to put in a repo. The right seam is the structured report objects behind them, which nobody has opened yet.
An after-action report is one record per player per match: their force, what happened to each unit, and text they can edit. Seeded from the match data and then owned by the player, so two accounts of the same battle disagreeing about who was decisive is a feature. This stage also supplies the per-unit data that kill-count achievements need.
Stage 9 — Campaign #
Persistent pilots and forces that carry damage and experience between matches. This is the part that makes the project a successor to MekWars rather than a match launcher, and it is furthest out because it needs the most from arena: crew data that nothing extracts today, and per-unit state recorded at both ends of a match.
Scenarios can start units already damaged, so a record of end-of-match state without the corresponding start state would be misleading. That has to be solved before any of this is durable.