Simple atproto game, 2048 tiles with the avatars of your friends

README.md

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.md in 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