Skip to content

Developing Chataltul

Chataltul is small on purpose: a few plain JavaScript files for the browser, one Node server, and no dependencies or build step anywhere. This page is how the code is laid out, how to run it while you work on it, how my own copy is deployed, and the tests. Your own server covers running it for real, with the settings, HTTPS, the state folder and the CLI.

How the code is laid out

  • web/engine.js is the whole app logic, as plain functions over one document: blocks, check-ins, nudges, pause, Later, breaks, history, the kitten each block invites (b.cat, and whether it came home or wandered off, E.kitten, E.away), treats and the shop (E.treats, E.buy, the prices in E.SHOP, and the limited things in E.EVENTS), missions (E.missions, worked out from the blocks like the treats, so nothing extra syncs), napping together (b.nap, E.napWith, E.napGone), the stopwatch (b.mode), cancelling in the first 10 seconds (E.cancel, which leaves a tombstone so other devices hear about it), pauses of 5 minutes at most (E.PAUSE_MAX, ended by E.tick), extra time (b.overtime, b.over), notes and edits (E.edit), achievements, the monthly and the starter challenge (E.achievements, E.monthly, E.starter, collected into doc.claimed with E.claim), the rankings round (E.round, E.ranked), tags, the past (doc.past, where the oldest blocks go once there are more than 2000, and E.pastDays), the pings to send, what friends may see, and the sync merge. The browser runs it, and the server loads the same file for accounts (E.pings, E.daily, E.presence, E.merge), so both always agree.
  • web/data.js handles storage, sync, the device, the inbox of answers tapped on notifications (the service worker leaves them in IndexedDB, and the page drains them when it opens), and the usage counts.
  • web/cats.js has the cats: one SVG kit with fifteen breeds and four poses (peek, sit, sleep, cheer), who you meet and how (CATS[].rule, worked out from your history and doc.tally) or adopt (adopt, and limited for an event's cat), and the café's things (ITEMS, each with where it goes in the room and a spot a cat can sit on). The animations (breathing, blinking, a swishing tail) are CSS in style.css. The paintings are in web/cats/ (each cat grown up, as a kitten and napping: <id>-1.webp, <id>-kitten-1.webp, <id>-nap-1.webp) and web/cafe/ (<thing>-1.webp), 512px with transparency. A new painting gets a new number, because they're cached for a day.
  • web/cafe3d.js is the 3D café, on the Café tab and behind the homecoming: one 2D canvas with an orthographic camera looking down 60°, which spins in quarter turns when you let go. KavanaCafe3D.create(box) puts a canvas in a box, and show(data) gives it the room (app.js's cafeData(): its size from the period and how many cats, the theme, the light from the clock, the things you own, the cats that came home, the shelter cats, and paw prints for the ones that wandered off). The same data twice keeps the cats where they are. Where each thing stands is PLACE (a share of the room, so it works for every room size, and the things to sit on get placed first), the cats with poses in cats.js walk side-on and loaf, wash and look out of the window, things with views in cats.js turn with the room, and arrive() drops a cat in. It isn't warp.js: 60 cats bent live would be too much, so the café's cats only breathe.
  • web/warp.js makes the paintings move: it bends each picture on a mesh, in one WebGL context for the whole page, so the cats breathe, blink, flick an ear, swish a tail, look at your finger and hop when you tap them. Where each painting's eyes, head, ears and tail are is in web/rig.json (in the picture's 512px), one entry per file in web/cats/. Both come from the animation work for the Android app, which uses the same numbers, so I drop in new copies of them rather than editing them here. app.js asks for a living cat through catArt(), which falls back to the plain picture when a painting has no rig yet or the browser has no WebGL (Firefox with webgl.disabled, say, or a machine with hardware acceleration off), and a Cat made straight on a canvas without WebGL draws the flat picture instead of moving it. A sheet with data-cat-scope counts as on screen, so its cats move even over the café.
  • web/app.js, index.html and style.css are the screens. They get around like Forest: the Focus tab is one full screen (the café wall in the theme's colors, the round window with the next cat, the ring you drag for how long, and Start, with everything else in the #options sheet), a running block is the sky (#sky, its colors from the progress in skyAt(), with the dive into the window when it starts), and everything else is in the menu that slides in (#sidebar, opened by #menu-btn). Under that, the lists, sheets and alerts are iPadOS/macOS style, with a Live Activity pill while a block runs. At the end of a block that came home, startHomecoming() brings up today's café behind the card and drops the cat into it, and Again files the block and starts the same one. web/sw.js is the service worker: it shows the pings and records Yes / Drifted tapped on a notification. web/lines.js is the list "Nudge me" draws from, web/sounds.js plays the background sounds, two at a time: the recorded loops in web/audio/ (made by tools/fetch-sounds.py from CC0 recordings on Freesound, listed in Sounds), each fetched the first time it's picked, with a version made on the device standing in until it's there, and the meow when a kitten comes home, and web/news.js is the What's new page.
  • server.js is plain Node with no dependencies: the static files, the API, the ping mailbox, accounts, friends, the groups for napping together (st.naps) and the counts. Web push (VAPID, RFC 8292, and aes128gcm, RFC 8291) is written out by hand in lib/push.js, the sign-in emails go out over a small SMTP client in lib/mail.js, and the state files are in lib/state.js.
  • extension/ is the browser extension for Chrome and Firefox (Manifest V3, no build step): the page sends a kavana-block event when a block starts, pauses, stops or ends (tellExtension() in app.js), kavana.js keeps it, and background.js turns a declarativeNetRequest rule on and off that shows blocked.html in place of the sites on the list. The browser extension has the details.
  • bin/kavana is the admin CLI, deploy/kavana.service the systemd unit, and tools/ the tests and the paintings: paint-cats.js runs a batch through imagegen-cc on a machine with a GPU (over SSH, with paint-runner.py on the other side; the prompts for every cat, kitten and café thing are in it), and cutout.py cuts each one out of its white background into a square WebP (--wide keeps a wide one wide, like the bat garland). fetch-sounds.py makes the recorded sounds: it downloads each pick from Freesound (with a free API key, in a file outside git), cuts a seamless loop, sets its loudness and saves it as an MP3, and check reads them back and draws their seams.

The engine's rules

Every change goes through an engine function that gets the document and now and throws on bad input (E.start, E.answer, E.park, E.setSettings, ...). E.tick moves time forward: check-ins whose time came, blocks that ended, a pause nobody came back to in two hours, an end screen nobody answered in twelve. E.view turns the document into what the screens draw.

For sync, every record carries u, the time it last changed. E.merge keeps the newer copy of each record, keeps crossed-off Later items as tombstones for a month so other devices hear about them, drops finished blocks that started before a "Clear My History", drops blocks that are already in the past (see below), keeps a kitten that wandered off gone even if the other copy is newer (unless it came home first), and if a block was started on two devices at once, the newest one runs and the other counts as stopped. The browser posts its copy to POST /api/sync with the revision it has, and a 409 hands back the server's copy to merge and try again. If the copy is too big for the server, the page folds it with E.normalize and tries once more. Too big is a 413, or a cut connection while the doc is bigger than the engine keeps, because past what the server reads at all it drops the connection instead of answering. If that doesn't do it either, the page says so (a toast, and "Not syncing" under the account in Settings) until a sync goes through, so sync never just stops without anyone knowing.

The server takes 1 MB of JSON per account, so the engine keeps a doc well under that. A block doesn't keep a copy of your own lines (the nudges read them from the settings when they're planned), and once it's filed, it drops the words its nudges said, which are only shown while it runs. E.normalize cleans both out of older blocks too. That was most of the size: a four-hour nudge block with 20 own lines was 6.6 KB, so the 1 MB filled up after about 150 of them, and now it's 1.6 KB. An everyday block is 400 to 850 bytes.

A doc keeps its last 2000 blocks, and only as many of those as fit in 800 KB (E.config.BYTES, in UTF-8 bytes, which E.size counts by hand because Android's JavaScript sandbox has no TextEncoder). That's about 1600 everyday blocks, a year and more at four a day. The 200 KB between that and the server's 1 MB is room for what changes before the next fold: the block that's running, a Later list that fills up, your own lines. If something other than the blocks makes a doc too big, folding them wouldn't help, so the engine doesn't. When there are more blocks than fit, the engine folds the oldest ones into doc.past, and they don't take anything with them: their treats (and whatever the missions of their days and weeks paid) go into doc.tally.banked, each day keeps what the café shows (the minutes by tag, which cats came home and the minutes they brought, and how many wandered off), and the achievements keep their counts. E.stats, the achievements and E.monthly count the past on their own, and E.pastDays lists it by day, which the café adds to whatever period it shows: the room (each cat that came home sits there with its day instead of a block), the cards and the comparison with the period before, the bars and By tag, Who came home, the cats you meet, and the days-in-a-row streak. The past keeps no hours and no blocks, so When you focus and the list of blocks leave those days out and say so in a note, a day's chart (which is by the hour) only has them in By tag, and a period that reaches into the past leaves out the treats card, because the past banks its treats without keeping them by day. The past takes about 100 bytes a day, so in ten years or so it'll need folding into months to leave room for the blocks. doc.past.to is a high-water mark: every block that started before it is in the past and nowhere else, and the days and weeks it reaches into are closed for missions, the same as with "Clear My History". Every device folds the same way, by count and by size, the oldest blocks first and never one that's still running or waiting for "How did it go?", in E.start, E.normalize and E.merge (which measures only the merged doc, since measuring means writing the whole doc out as JSON, about 6 ms at 800 KB). This way two devices that fold at different times agree on everything before the mark, and the one that went further has all the other has. Basically, that's what keeps a block from counting twice: the merge keeps the further mark, drops any block from before it, and takes the larger number for the bank and for every day's counts. "Clear My History" empties the past too (its treats are banked already) but keeps the mark.

The API, briefly

Open to everyone: GET /api/config (the push key), POST /api/devices and /api/devices/forget (reminders on and off; a device proves itself with X-Kavana-Device: id.secret), POST /api/pings (a browser without an account hands over its next pings), POST /api/push/test, POST /api/events (the counts), GET /api/shelter and POST /api/sponsor (the shelter cats everyone sponsored, one number), POST /api/signup and /api/login (a name or an email, and the password), and POST /api/email/code and /api/email/signin (a 6-digit code to an address, then the code back, which signs in, or makes the account and says created). GET /api/config also says whether the server sends email.

Signed in (a cookie): GET /api/me, POST /api/logout, GET /api/sync?rev=N (a 204 when nothing changed) and POST /api/sync, POST /api/account/email and /api/account/email/confirm (add or change the email: a code to the new address, then the code back), POST /api/account/password (set one, or change it with current, or without it for 15 minutes after signing in by email; a change signs out the other devices), POST /api/account/code (a code to the account's email, for deleting an account without a password), POST /api/account/delete (with password or code), GET /api/friends, POST /api/friends/add and /api/friends/remove, and for napping together POST /api/nap/join (put my block in a friend's group), GET /api/nap?block=<id> (the group my block is in, a 204 if none; pages ask every 5 seconds), POST /api/nap/left and /api/nap/back (the page went out of sight and came back; past the grace the group breaks), POST /api/nap/room (give my block's group a 6-letter code) and GET /api/nap/room?code= (what joining would mean; joining with the code skips the friends check), and GET /api/rankings (this week's round, friends and worldwide, worked out from the synced docs). The server never edits anyone's data: each page sends its own kitten off when its group breaks. Every request that isn't a GET must be JSON from the same origin.

The CLI's API is under /admin/. It only answers on loopback, never with a proxy's headers, and only with the token from the state folder.

Running it while you work

KAVANA_STATE=/tmp/kavana-dev KAVANA_PORT=8462 node server.js

Then open http://127.0.0.1:8462. Localhost counts as a secure origin, so the service worker and the notification permission work there the same as on HTTPS. There's no build, so you edit a file and reload. The files are sent with an ETag and Cache-Control: private, no-cache, because Cloudflare rewrites any other Cache-Control on .js and .css to four hours, and "private" is what makes it pass the header through.

The icon is Miso on the sill of the round window at night, from a few rounds of drawings in design/icon/ (the briefs and the round sheets are there; the drafts stay out of git). python3 design/icon/export.py design/icon/final/icon.svg design/icon/final/icon-small.svg <out> renders every size from the drawing (it uses Daf's drawing kit and Chromium): the web icons and favicons, the notification badge, the browser extension's icons, the macOS iconset, and Android's adaptive and themed layers. The files in web/icons/ are named kavana-*, and a new drawing needs new names, because the icons are cached for a day.

How my copy runs

Mine runs as the systemd user service kavana, on 127.0.0.1:8460, and a reverse proxy sends /kavana/* to it, which is how it's public.

  • The live checkout is a second git worktree pinned to a commit, so working on the code in the main checkout never touches the live app. To deploy: git -C <live worktree> checkout --detach <commit> && systemctl --user restart kavana. Never run the live service from the checkout you edit in: a restart in the middle of an edit would load half-done code.
  • The state is all in one folder (mode 700): state.json, docs/<name>.json, analytics.json, vapid.json and admin-token. None of it is in git. If you lose vapid.json, everyone has to turn reminders on again.

The CLI and the limits on sign-ups and sign-ins are in Your own server.

Testing

  • node tools/test-engine.js runs the unit tests for the engine, with no server and no browser: the kitten, the treats and the shop, missions, napping together, the stopwatch, cancelling, pauses, extra time, edits, achievements and challenges, the rankings round, the past (a doc of more than 2000 blocks next to the same history kept whole, two devices folding at different times, and "Clear My History" after a fold), the doc's size (the fullest blocks there can be, in 3-byte characters, never get too big for what server.js takes, not even between folds; everyday blocks fit 1500 and more; two devices folding by size at different times; and your own lines and the nudges' words staying out of filed blocks), that every painting is there, and that every sound has its recording, a price unless it's free, and a free one in its group. The size tests read the limits out of server.js, so they fail if the server's limit drops too close to what the engine keeps.
  • node tools/push-e2e.js sends one real push through Mozilla's push service and decrypts it, acting as the browser.
  • node tools/e2e-server.js <sandbox url> <sandbox state dir> runs the whole server with real pushes, with Mozilla's push service standing in for the devices: anonymous pings and the daily reminder, sign-up and sync conflicts, email codes (an account made from one, passwords set and changed, an email added, a code-confirmed delete, the limit per IP address), friends and the friend ping, napping together (a glance away, a long one, a stop, rejoining), nap rooms joined by code, the rankings, the shelter count, and the counts. Start the sandbox with KAVANA_MINUTE_MS=1000, so a "minute" is one second, KAVANA_NAP_GRACE_MS=2000, and KAVANA_MAIL=outbox, so the codes go to a file the test reads. With quick instead of a state dir, it only registers a device, gets a test push and forgets the device, which is safe on a live server.
  • node tools/shot.js <app url> <api url> <out dir> <state dir> walks every screen in headless Chrome: the first run and a whole block on an iPad, the account, meeting a cat, an iPad in landscape and a Mac, dark mode, a phone, joining a friend, and the usage dashboard. It drives a Chrome in a Docker container called docker-chrome-1 over its DevTools port (KAVANA_CHROME=<container> picks another one with the same image and flags, for when something else is using that one), and ONLY=ipad,meet,friends,... runs just some of the walks (the names are ipad, meet, landscape, mac, dark, phone, cafe, cafe-dark, cafe-phone, missions, missions-dark, halloween, halloween-dark, halloween-soon, halloween-after, nap, nap-host, nap-phone, nap-layout, forest, forest-phone, forest-rank, living, living-phone, living-dark, cafe-past, cafe-whole, sync-big, friends and admin; the living ones try the ring, the sheet, the menu, the dive, the sky, the homecoming, Again, a kitten tiptoeing off, and the 3D café turned and tapped; the forest ones try the stopwatch, cancelling, a pause, extra time, notes and edits, collecting a reward, a theme, two sounds, a shelter cat, the news, your own lines, the mindful minute, the rankings and a nap room; the cafe ones grow a kitten, send one off by leaving, and fill the café by day, week, month and year, cafe-past and cafe-whole grow four blocks a day since January 2025 (more than fit, so the oldest go into the past) and keep the same history whole, and print what the café counts for 2025, a few months, a week and a day, so the two can be compared line by line, sync-big fills an account's history with long notes past the server's 1 MB and checks that it folds and keeps syncing, and that the page says so when the server still won't take it, the halloween ones set the page's clock to that week, and the nap ones nap with friends made through the sandbox's admin API, so start it with KAVANA_NAP_GRACE_MS=4000). Headless Chrome can't subscribe to push, so turning reminders on needs a real phone or iPad.
  • node tools/warp-check.js <app url> <out dir> checks the living cats in headless Chromium (its own zenika/alpine-chrome container on the host network), once with WebGL switched off and once with software WebGL as a control: without WebGL nothing throws and the still paintings stand in on the run screen and the Cats tab, and a Cat made on a canvas still draws its picture; with it, the cats are canvases that painted themselves. The app url has to be http://localhost:<port>/.
  • node tools/ext-check.js <app url> <out dir> loads the browser extension unpacked in headless Chromium (its own zenika/alpine-chrome container on the host network) and tries it against a sandbox on localhost: the welcome list, the Settings row, a block turning the list on, the cat page on a site and its subdomains, a site off the list left alone, adding to the list, the toolbar popup, a pause, "Back to Chataltul", a stop opening the blocked page again, and a block ending while Chataltul is closed. node tools/ext-check-firefox.js <app url> <out dir> does the main ones in headless Firefox (geckodriver from selenium/standalone-firefox). The app url has to be http://localhost:<port>/, because that's where the extension's script runs. With EXT_DEVTOOLS=http://127.0.0.1:<port>, ext-check.js drives a Chromium you started yourself (a fresh --user-data-dir, --load-extension and --remote-debugging-port) instead of its own, which is how I ran it on my Mac over an SSH tunnel, with the sandbox tunnelled to the Mac's localhost.

The rule: any test that writes refuses my live site, and it should stay that way. shot.js makes accounts, and e2e-server.js makes and deletes them, so they only ever run against a sandbox with its own state folder.

Back to the docs index