Built on atjam
Who uses the at.atjam.* lexicons — and one end-to-end worked example of mapping a judged, votable game jam onto them.
atjam is a generic coordination layer: it knows about jams, rounds, signups, invitations, and submissions, and nothing about your domain. This page has two halves — the projects actually reading and writing at.atjam.* today, and a worked example that carries one adopter's jam all the way through the four extension points.
In the wild
| Project | What it is | How it uses atjam |
|---|---|---|
| atjam.at (this repo) | The reference web app. | Reads and writes all five records; the canonical consumer of the @atjam/lexicons read layer and validator. |
| EPTSS | “Everyone Plays the Same Song” — a monthly cover-song challenge. | The motivating adopter. Rounds carry a site.eptss.song subject and accept fm.plyr.track submissions. Walked through end to end in Extending atjam. |
EPTSS's own implementation lives in a separate repository and is not part of this repo. Its pattern is described from atjam's published facts; treat the EPTSS-side code as conceptual. Only the at.atjam.* contract is verified here.Worked example: a judged game jam (the rpg.actor shape)
This is an illustration, not an adopter. rpg.actor is a real AT Protocol project — a cross-game character registry where your character travels between games (its published lexicons includeactor.rpg.statsandactor.rpg.sprite). It runs its own jam tooling and does not use atjam. We borrow the shape of its Builder Jam here only because a judged, votable game jam happens to exercise every atjam extension point at once. Theactor.rpg.game/actor.rpg.voteschemas below are illustrative — what an adopter would publish under their own namespace — not rpg.actor's real records.
The jam: over a fixed window, builders make a game that reads or writes a shared character registry; players then vote; winners get prizes. The whole thing lands on atjam with no change to the contract — the adopter publishes two small lexicons under their own domain, and everything else rides the spine.
The split: spine vs. domain
| Layer | Owned by | Records |
|---|---|---|
| Coordination spine | atjam (at.atjam.*) | jam, round, signup, submission (invitation if gated) |
| Deliverable, theme, votes, results | the adopter (actor.rpg.*) | game, vote (and optionally jamTheme, result) |
Design principle #4 (one namespace per owner) is the whole trick: at.atjam.* never learns what a “game” or a “vote” is. The adopter defines those under actor.rpg.* and embeds them by reference.
1. The adopter's lexicons (actor.rpg.*)
Published under their own domain, exactly as atjam publishes at.atjam.* (see PUBLISHING.md). The deliverable is a record describing the entry; the audio, build, or web app lives wherever it already lives.
// actor.rpg.game — the deliverable an at.atjam.submission points at
{
"lexicon": 1,
"id": "actor.rpg.game",
"defs": {
"main": {
"type": "record",
"description": "A game/tool entered into a Builder Jam.",
"key": "tid",
"record": {
"type": "object",
"required": ["title", "url", "createdAt"],
"properties": {
"title": { "type": "string", "maxLength": 300 },
"url": { "type": "string", "format": "uri" },
"engine": { "type": "string" },
"integratesLexicons": {
"type": "array",
"items": { "type": "string", "format": "nsid" }
},
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}// actor.rpg.vote — a player's vote, strong-reffing a submission
{
"lexicon": 1,
"id": "actor.rpg.vote",
"defs": {
"main": {
"type": "record",
"description": "A player's vote for a jam submission.",
"key": "tid",
"record": {
"type": "object",
"required": ["submission", "createdAt"],
"properties": {
"submission": {
"type": "ref",
"ref": "com.atproto.repo.strongRef"
},
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}Two optional extras, also under actor.rpg.*: a jamTheme for the round's subject and a result for the winner announcement. Neither is required to run the jam.
2. The round — atjam's spine on the extension points
The round is a plain at.atjam.round; the rpg.actor specifics sit on subject, acceptedSubmissionTypes, and closingEvent. (createRecord here is the app's own thin wrapper over com.atproto.repo.createRecord — see Getting started; @atjam/lexicons ships no write helper.)
import { NSIDS } from "@atjam/lexicons";
const round = await createRecord({
agent, // the organizer
collection: NSIDS.round, // "at.atjam.round"
record: {
$type: NSIDS.round,
jam: jamRef,
assignment: "Build a game/tool that reads or writes the rpg.actor registry.",
subject: { // extension point (unknown)
$type: "actor.rpg.jamTheme",
constraint: "Use a player's character class",
},
acceptedSubmissionTypes: ["actor.rpg.game"], // the adopter's deliverable NSID
milestones: [
{ label: "submission-deadline", date: deadlineIso },
{ label: "voting-opens", date: votingIso }, // custom phase
{ label: "results", date: resultsIso },
],
closingEvent: { // extension point (unknown)
$type: "actor.rpg.awards",
url: "https://rpg.actor/stream",
},
createdAt: new Date().toISOString(),
},
});3. A builder enters
The builder publishes their entry as an actor.rpg.game record on their own PDS, then signs up and submits — the submission's payload strong-refs the game record.
// 1. Publish the entry (on the builder's PDS). Returns { uri, cid }.
const gameRef = await createRecord({
agent, // the builder
collection: "actor.rpg.game",
record: {
$type: "actor.rpg.game",
title: "Tavern of Forking Paths",
url: "https://my-itch.io/tavern",
engine: "Godot",
integratesLexicons: ["actor.rpg.sprite"],
createdAt: new Date().toISOString(),
},
});
// 2. Sign up for the round.
await createRecord({
agent,
collection: NSIDS.signup, // "at.atjam.signup"
record: { $type: NSIDS.signup, round: roundRef, createdAt: new Date().toISOString() },
});
// 3. Submit — payload strong-refs the game record from step 1.
await createRecord({
agent,
collection: NSIDS.submission, // "at.atjam.submission"
record: {
$type: NSIDS.submission,
round: roundRef,
payload: gameRef, // { uri, cid }
createdAt: new Date().toISOString(),
},
});4. Players vote — and tallying is free
A vote is an actor.rpg.vote that strong-refs a submission. Counting votes is the same Constellation backlinks query that already counts signups — just pointed at the submission's AT URI instead of the round's.
import { Queries } from "@atjam/lexicons";
// A player votes (on their own PDS).
await createRecord({
agent, // the player
collection: "actor.rpg.vote",
record: {
$type: "actor.rpg.vote",
submission: { uri: submissionUri, cid: submissionCid },
createdAt: new Date().toISOString(),
},
});
// Tally: identical to fetchSignupsForRound, retargeted to the submission.
const votes = await Queries.getBacklinks({
target: submissionUri,
collection: "actor.rpg.vote",
path: ".submission.uri",
limit: 500,
});
// Enforce one-vote-per-player at READ time (writes can't be constrained):
const voters = new Set(votes.links.map((l) => Queries.parseAtUri(l.uri).did));
const tally = voters.size;That read-time posture is the same one atjam's own signup validator takes: anyone can write any record to their PDS, so eligibility and de-duplication are decided when you read, not enforced when others write.
5. Results
The organizer writes an actor.rpg.result naming the winners and surfaces it at the results milestone; the awards stream itself rides the round's closingEvent. Both are domain records — atjam just provides the timeline slots.
What this shows
- Zero changes to
at.atjam.*. The spine carried a judged competition it has never heard of. - Two small lexicons under the adopter's own namespace (
game,vote) did all the domain work. - Voting and results composed for free on the existing strong-ref + backlinks substrate — no new index, no new primitive.
That's the headline extensibility claim made concrete. To build your own: Extending atjam for the extension points, Getting started for the first records, and the API reference for exact signatures (including Queries.getBacklinks).