- TypeScript 87%
- CSS 6%
- Python 5%
- HTML 2%
2048 #
2048, but the tiles are ATProto people's avatars instead of numbers. Tiles are ordered by follower count, so you merge your way up from the smallest account in the game to the biggest.
- Site: 2048.social
- Account: @2048.social —
did:plc:ur5yncwiefmjrpahiax5fnek - Spec:
Singi Labs/plans/2026-09-03-avatar-2048.mdin the Obsidian workspace
Personal project. Not a Singi Labs product.
Status #
Playable and deployed. Mobile-first, WCAG 2.2 AA as a build target rather than an audit afterwards.
Signed out, the board is dealt from people who agreed to be in it. Signed in, it is built from your own follows and never leaves your browser.
Layout #
src/engine/ The game. Pure, immutable, seeded, no DOM.
src/ui/ Preact components and hooks.
src/lib/ Cast selection, avatars, sharing, OAuth, the pool.
src/lib/blue2048/ A port of the blue.2048 recording format (see below).
scripts/og.py Regenerates the Open Graph image from the consent list.
The blue.2048 port #
2048.blue is another ATProto 2048, by
@pds.dad. This game writes its stats using
his blue.2048.player.stats lexicon rather than inventing a parallel one, so
either app can read totals the other wrote.
src/lib/blue2048/ also holds a full port of his game format: the LCG, the
base 64→6 move notation, the board rules, and XXH3-64. It is verified against
the engine crate's own committed games and against all 96 real games in his
repository. Nothing in the game uses it yet.
Adopting blue.2048.game for scores would mean adopting that engine wholesale,
because a seededRecording stores no spawn positions and is only honest if the
replay lands on the same board. It spawns a 4 a quarter of the time where this
engine spawns one a tenth, so it is a gameplay decision rather than a storage
one.
Why not an off-the-shelf engine #
The published 2048 packages (2048-engine, 2048-ts-engine,
@devshareacademy/js-2048) are all abandoned — last touched 2022–2023, under
250 downloads a month, still on 0.x — and none of them carries tile identity.
They hand back a grid of numbers, which is enough to know the board and useless
for animating it: you can't tell which tile moved where, or which two merged.
This engine returns tiles with stable ids plus previous and mergedFrom, so
the renderer can animate purely in CSS with no animation library. It also needs
seeded RNG, which none of them offer, so a board can be reproduced from its
seed. That is ~250 lines of rules we want to own and test rather than 250 lines
we inherit unmaintained.
Permissions #
The game asks ATProto developers to put their face in it, so it holds itself to the narrowest access that makes it work.
Requested scope:
atproto
repo:blue.2048.player.stats?action=create
repo:blue.2048.player.stats?action=update
atproto is the mandatory baseline and grants nothing beyond identity. The two
granular scopes cover the game's only writes: a personal best and a set of
running totals, both in the player's own repository. Both lexicons are Bailey
Townsend's, adopted with his permission instead of duplicated, and each is
written at one fixed key, so the repository holds a current state rather than a
record per game. That is not the same as hiding when somebody plays: the stats
record carries updatedAt, and every write is a public firehose event
regardless. There is no transition: scope, no blob, no rpc, no account or
identity permission, and no access to any other collection.
One authenticated request, and only at game over. Signing in establishes who the player is, so the board can be built from their follows and their own tile is genuinely theirs. Everything the game reads comes from public, unauthenticated endpoints, including the player's own stats:
| What | Where | Authenticated |
|---|---|---|
| Profiles, follows, follower counts | public.api.bsky.app |
no |
| Avatars | cdn.bsky.app, via /api/avatar for canvas access |
no |
| PDS location | plc.directory |
no |
| Sifa job titles | the person's own PDS, com.atproto.repo.listRecords |
no |
| The player's own stats | their PDS, com.atproto.repo.getRecord |
no |
That claim is checkable rather than promised. fetchHandler is the only way to
make an authenticated call with an OAuthSession, and
src/lib/oauth.permissions.test.ts fails the build if it appears outside
src/lib/oauth.ts, if the scope widens, if a blob upload appears, or if
createRecord is used anywhere — appending a record per game would publish how
often somebody plays.
The app briefly asked for transition:generic so it could post the share card
itself. That was withdrawn: there is no "post one image" scope, so it amounted
to app-password-level access to somebody's whole repository in order to attach
a picture to one post. The Web Share API puts the image on the post without
asking for anything.
Design notes #
Pure and immutable. move() returns a new state and never touches its
input, so undo is "keep the previous state and put it back" — the engine owns no
undo stack.
Deterministic. A game is fully determined by its seed plus its moves. Same seed, same moves, same board — which is what makes stored scores replayable and "same board" challenges possible later.
No-op moves are free. A swipe into a wall returns moved: false, spawns
nothing, and does not advance the move counter.
Commands #
npm test # run once
npm run test:watch
npm run test:coverage
npm run typecheck
Licence #
MIT