atkuzu #
A game built on the AT Protocol, inspired by AT Mot.
atkuzu = AT Protocol + Takuzu (aka Binairo). Players solve a daily puzzle; results are written to their own PDS as atproto records.
This is a SvelteKit app with browser OAuth already wired up: users log in with their own AT Protocol handle (Bluesky or any other PDS), and actions are taken on their behalf against their own repo. There is no game logic here yet, this is the OAuth/session/DB scaffolding the game will be built on.
Forked from the atproto-sveltekit-template, see NOTICE.md for attribution.
Dev Setup #
Should work with any package manager if you prefer to use another. But these are directions for pnpm
- Copy .env.example to .env, .env.example is the default dev settings
pnpm install- May need to run
pnpm approve-buildsfor the build scripts for sqlite pnpm run devorpnpm run dev:loggingwith pino-pretty for pretty logging
If you are running locally on a different port than
5173or something else odd can setOAUTH_DOMAINin.envto the domain and port. Just make sure to use either127.0.0.1or[::1](ipv6) for oauth to work for local development.
Implementation details #
How OAuth is configured #
OAuth is configured entirely from environment variables, no code changes needed.
All atproto actions taken for the user happen server side, so we can take advantage of the confidential client and longer session lifetimes.
Types of OAuth clients #
- Development local: Set
DEV=truein.envto use a local development client. This is a special client just for development, it does not require a public url. As outlined here. This is the default from copying .env.example - Production Public: Remove
DEV=true, setOAUTH_DOMAINto your publicly accessible domain in.envto use a production public client. These have a lower atproto oauth session lifetime, limited to 2 weeks. - Production Confidential: Follow
Production Publicand setOAUTH_JWKto the value fromnode ./bin/gen-jwk.js. These are cryptographic signing keys and should be kept secret and private. These have the longest session lifetime: 180 days for refresh tokens, or indefinitely if refreshed, pending revocation or jwk rotation.
Production most likely wants the confidential client for the longest lifetime. The cookie session lifetime is shorter and could expire before the atproto session lifetime; that's configured in ./src/lib/server/session.ts, default 30 days, reset on every logged-in web action. More on client types and session lifetime.
OAuth scopes #
OAuth scopes are permissions to the user's repo you are requesting. Read more at atproto.com/specs/permission.
Set requested scopes via the OAUTH_SCOPES environment variable, tailored to whatever lexicons the game ends up writing
to. The default in client.ts requests write access to atkuzu's own collections
(see Lexicons below). For development or full access to a user's repo, atproto transition:generic.
You may find that the OAuth consent screen doesn't immediately reflect a scope change. The PDS can cache those. Docs say this can be anywhere from 15-30mins
OAuth branding #
A couple of other odds and ends you can set for OAuth to customize the branding. This mostly shows up on
yourpds.com/account for now, but it is a standard and more may adopt
it. Docs:
OAUTH_CLIENT_NAME(string, optional): human-readable name of the clientOAUTH_LOGO_URI(string, optional): URL to client logo. Only https: URIs are allowed.OAUTH_TOS_URI(string, optional): URL to human-readable terms of service (ToS) for the client. Only https: URIs are allowed.OAUTH_POLICY_URI(string, optional): URL to human-readable privacy policy for the client. Only https: URIs are allowed.
Database #
This project uses drizzle ORM with the sqlite adapter to make it easy to run locally and get started. It's used for the server-side and atproto session stores. This will work in production as well, but if you want to change out the database layer it shouldn't be too bad since there aren't many db queries right now.
- ./src/lib/server/db/index.ts sets up the DB
- ./src/lib/server/db/schema.ts is the database schema
- ./src/lib/server/cache.ts is an abstracted key/value cache that @atproto/oauth-client-node uses to store state and sessions
- ./src/lib/server/session.ts is the server-side session store tied to the cookie session
- A tiny job runs at ./src/hooks.server.ts that clears the atproto state store and runs migrations on startup. If you change from a node server adapter you may have to change this too.
- Will most likely need to change out some settings in drizzle.config.ts
- Delete the contents in drizzle and run
pnpm run db:generateto generate new migration files for a new adapter.
Handle input #
HandleInput.svelte is a typeahead component for AT Protocol handles, used on
the login screen. It queries the public Bluesky appview (public.api.bsky.app) and remembers recently picked handles in
localStorage.
Lexicons #
atkuzu's record types are defined as lexicons under the com.tomplanche.atkuzu.*
NSID authority (domain authority atkuzu.tomplanche.com, reversed):
- lexicons/com/tomplanche/atkuzu/result.json:
com.tomplanche.atkuzu.result, one immutable write-once record per completed daily puzzle (puzzle number, whether it was solved, time taken, and optionally how many cells were toggled) - lexicons/com/tomplanche/atkuzu/stats.json:
com.tomplanche.atkuzu.stats, a single mutable per-account record (currentStreak,maxStreak,gamesPlayed,gamesWon,lastPuzzleNumber), also doubling as the "this account plays atkuzu" declaration record - lexicons/com/tomplanche/atkuzu/preferences.json:
com.tomplanche.atkuzu.preferences, a single mutable per-account record of board display preferences (theme:digits,sunmoon, orcolors), read back on load and synced across devices
Validate the schemas against the Lexicon Style Guide:
pnpm run lexicons:validate
Publish them as com.atproto.lexicon.schema records (run by the account that controls the namespace authority) to make
them network-resolvable:
ATKUZU_PUBLISH_IDENTIFIER=you.example ATKUZU_PUBLISH_PASSWORD=<app-password> pnpm run lexicons:publish
Publishing also requires a
_lexiconDNS TXT record on_lexicon.atkuzu.tomplanche.comwith valuedid=<publishing did>. Lexicon resolution is not hierarchical, each distinct authority needs its own exact TXT record, resolvers never walk up the DNS tree. See Lexicon Publication and Resolution.
Writes still pass validate: false (see records.ts): not every PDS resolves
third-party lexicons over the network for validation yet, confirmed failing on the account's own PDS with
Unknown lexicon type even after publishing and DNS were both in place.
Other production considerations #
There isn't a great way to run a "sidecar process" with SvelteKit: a Jetstream listener that runs alongside the SvelteKit application. If the game ends up needing to listen to the firehose or jetstream for real-time record creation, swap the database layer for something not embedded, then run a separate container (or process) with a script in a loop to consume that data. @atcute/jetstream is a good way to do this in TypeScript. For an overview of that pattern, see the Statusphere quick start guide.
Production #
Local server like a VPS #
- Install docker
- Copy .env.example to .env and fill in the variables. Make sure to remove
DEV=true docker-compose up
The docker compose comes with Caddy, if you have another reverse proxy you can remove it from the docker compose and just reverse proxy to port 3000. You may also have to adjust the Caddyfile depending on your setup.
Daily puzzle scheduling #
Generating and publishing each day's puzzle isn't handled by the SvelteKit process itself: it's a separate cron job on the VPS (Europe/Paris midnight, DST included), not something kept alive alongside the site. See src/routes/daily/README.md for what that job actually runs.