Skip to content

Latest commit

 

History

History
223 lines (180 loc) · 107 KB

architecture.md

File metadata and controls

223 lines (180 loc) · 107 KB

Architecture

How Terrakin works today. For why it's built this way, see the decision records.

Shape

             ┌──────────── protocol (schemas, SKILL.md, OpenAPI) ────────────┐
             │                                                               │
 phone ── client (Vite, canvas) ──WebSocket /v1/live──┐                      │
                                                      ├── server ── sim (rules, pure)
 agent ── any HTTP client ───────REST /v1/*───────────┘      │
                                                             └── Store (input log, sessions, snapshots)

The sim, protocol, server, and client are workspace packages in packages/, imported by name (@terrakin/sim), beside the staff app (packages/admin), the shared UI (packages/ui), the link preview cards (packages/cards), and the character package published to npm (packages/figure, decision 0253) (decision 0111).

  • sim knows the rules and nothing else. apply(state, { actor, command }) returns events or a rejection.

  • protocol defines every message on the wire with zod. Server and client both import it, so they can't drift. Every REST route is one entry in the route table, ROUTES in packages/protocol/src/routes.ts, joined from one file per area in packages/protocol/src/route-table/, and the server's handlers are split the same way in packages/server/src/handlers/. The server's dispatcher, the OpenAPI document, and the API reference in SKILL.md and llms.txt are generated or checked from the route table (decision 0017).

  • server turns HTTP and WebSocket traffic into sim inputs, persists accepted inputs, and broadcasts events. On terrakin.org it runs inside one Cloudflare Durable Object; locally it's a Node process. Both wrap the same Api (deploy.md).

  • social (RFC 0003) is profiles, handles, posts, mentions, reactions (a like is a heart), reposts, quotes, follows, praise (once a UTC day per pair, a count on profiles, no economy), karma, blocks, notifications, and uploads (decision 0025), plus invites, private letters, gestures, and streaks for couples and friends (decision 0024). It lives in SQLite tables next to the world log, outside the sim: nothing social replays or decides a game rule. Uploads go to R2 (or a directory locally) and are served from /media/<id>. A resident can connect an X account by posting a line with a one-time code; the server reads that post through X's public oEmbed endpoint and keeps only the handle and the post's link (decision 0022).

  • Verified characters (RFC 0007) live in the social tables too. A resident proves it is an ERC-8004 agent: the server reads the agent's card URI and holder from the allowlisted network's registry with plain JSON-RPC (packages/server/src/chain.ts), fetches the card under strict rules (packages/server/src/agent-card.ts), and links when the card's terrakin service names the resident's profile. Partners are reviewed config (packages/server/src/partners.ts); MUSEGOD matches by who holds the agent and the binding contract's bindingOf, never by the card's text, and gets a badge, an avatar ring, and flair on profiles and post authors. A person who owns a partner character's resident (decision 0031) gets keeperOf on their profile and posts ("Keeper of Saddlebag"), worked out on read from owner links and agent links; the ring and design stay the character's. For a partner's character it also reads who holds the character, kept only as a hash, so a sale ends the link. Links are rechecked about hourly from the Durable Object's alarm (a timer on Node), within daily read pools kept in SQLite, one for rechecks and one for new links (decision 0050). An ask by partner and subject that the card doesn't answer yet is kept for 14 days and tried again in the same run, so a link finishes once the owner confirms, without a second ask; only the resident's own ask is ever finished (decision 0128). A partner's character also gets a curated profile design the client draws, and, when it has no picture, its own art copied from the partner's site by the card rules and stored through the upload checks as its avatar (packages/server/src/partner-art.ts, decision 0058). Partner wear (the muse halo, and promo pieces) is the one perk the sim sees: the server works out each linked resident's list from partner config and the server clock and logs set_entitlements when it changes, and the sim refuses partner wear without one and takes it off when it goes (decision 0061). GET /v1/partners/{id}/residents lists each partner's residents, linked or only claimed in the partner's own words in a bio (or a name an old log holds, since a name is now letters, numbers and underscores, decision 0379), with this week's counts read from the tables the rest of the server keeps, and the discovery log counts reads of /skill.md and /llms.txt that came with a partner's ?from= (decision 0127).

  • The Town Hall (RFC 0004) is in the sim: who may take part, proposals, votes, closes, and Commons builds are rules, and every one is a logged input. A Commons build is a plan in build's four lists at world tiles: blocks (building blocks, decor, or furniture), paths, and what to take away, 40 changes at most, built from nobody's things (decision 0101), and once the server logs keep_table_spots, no block on the four spots where game tables stand (decision 0126). Time comes in as new_day {day} and close_proposal {proposal} inputs the server appends (decision 0026), and the townsfolk list as set_townsfolk (decision 0027). The notice board and petition answers live in the social tables. packages/server/src/town.ts builds the /v1/town views from both. Once the server logs propose_in_person (RFC 0042), filing a proposal needs the caller at the hall's door; voting stays callable from anywhere, since a proposal closes on a clock.

  • client keeps a read-only mirror of the world built from the snapshot plus events, and draws it.

  • The docs at /docs are static pages the client build renders, whose only script gives their copy lines a Copy button (decision 0211) (decision 0195): the guides generated from docs/guides/, SKILL.md, and the OpenAPI document, then the API reference's index, with a page for each area of the API at /docs/api/<area> and every shape at /docs/api/models, all from the committed OpenAPI document. The agent quickstart there keeps only SKILL.md's getting-going sections and links the rest to /docs/skill, SKILL.md as a page (decision 0193). The Worker's assets and the Node server both serve docs.html at /docs.

  • Coins (RFC 0008) are in the sim: purses, the town treasury, the daily allowance at your hearth, the welcome gift, townsfolk budgets, and give_coins with its caps, all whole numbers, with sum(coins) + treasury + held == minted - burned after every input, where held is what bounties and Commons bookings hold. The server opens them once with open_economy and logs the maintainers as set_maintainers and each owner link or unlink as add_owner_pair or remove_owner_pair (the whole list as set_owner_pairs only at boot, when it differs, decision 0042), so the sim can apply the townsfolk rules and the pair exemption. Purses are private: WorldService.run sends each coins event only to its resident, broadcasts a gift event (who and whom, no amount) and treasury events to everyone, and a quiet event when nothing else is public, so every client's seq keeps counting. packages/server/src/coins.ts builds GET /v1/purse and the treasury on GET /v1/town. The numbers were tuned with scripts/economy-sim.ts (decision 0039).

  • Karma (RFC 0008 phase 3) is scored in the social layer (packages/server/src/karma.ts) from 90 days of reactions, praise, hearted replies, upheld reports, and the gifts and votes WorldService reads from the log. It is worked out once a UTC day, up to yesterday, and shown on profiles. Its only way into the sim is daily_awards: early each UTC day tick() counts yesterday's appreciation coins (a coin for each Neighbor who reacted to your posts, up to 20) and logs them, and the sim mints them (decision 0055).

  • Plots to visit (RFC 0020) are social data next to praise: packages/server/src/plots.ts keeps a row for each admire of a plot (once a UTC day per resident per plot, only from on it or from beside it after a visit this week, never your own, your household's, or across a block), a row per visitor, plot, and day for the week the counts cover (a visit, or a resident's own step or putter onto someone else's plot, never across a block or onto a suspended owner's plot), plot_changes, the newest time something on each plot changed, and plot_news, the newest UTC day's worth of what arrived there as a count a kind, both written by WorldService.onCommitted from the events of every committed input. packages/server/src/plot-news.ts turns a day's counts into each plot's news, one public line of what is new on it ("A roof went up on Tuesday"), in our own words from counts alone and never from anything a resident wrote (decision 0336). GET /v1/plots reads them with the world, building its list at most once a minute and again after an admire or a visit (one plot's read is always current), and the web draws each plot from the world snapshot it already has, on /visit, in the home wall's "Plots to visit" card, and as a card in the world while you stand on someone's plot. Admiring a plot notifies its residents and earns no coins and no karma (decision 0092).

  • The collection book (RFC 0021) is social data too: packages/server/src/collection.ts keeps one row per resident and kind (or piece of wear) with the day they first had it, written from the same WorldService.onCommitted hook (any inventory event that brings a kind, and wear put on or bought) and filled once from the world as it was when the book arrived. No rule reads it, so it never reaches the sim or the log. GET /v1/collection and GET /v1/residents/{id}/collection group it by the catalog's families with badges for finished ones, profiles carry collected, and the web shows it at /r/:id/collection (decision 0100).

  • The town shop (RFC 0008 phase 2) is in the sim too (packages/sim/src/shop.ts): a catalog keyed by item kind, shop_buy (5% of the price to the treasury, the rest burned; the share is SHOP.treasuryShare, switched by a logged set_shop_share so earlier purchases replay at theirs), and sell_to_town (minted), which buys only a few kinds a day from a fixed rotation over the day number, each up to a daily count per resident. Decor bought there is a block kind that also stacks in your things, so place uses one and remove gives it back; shop wear is kept in shop.wardrobe. The server opens it once with open_shop, and packages/server/src/shop.ts builds GET /v1/shop. The shop stands on the Commons' south edge (shopTiles), and /shop is its page. Prices were tuned with the same script, which now plays gardens and shopping (decision 0052). Seasons (RFC 0017) follow the UTC calendar through seasonOf(state.day), a pure function of the logged day: some stock is sold only in its season (SEASON_STOCK, refused as out_of_season otherwise), and a season adds things the town buys every day of it after the rotation (SEASON_BUYS). Autumn brings pumpkins, pumpkin pie and soup, hay bales, and scarecrows, and winter cranberries, cranberry jam and hot cranberry punch, snowmen, strings of lights, little firs, and sleds. The starter seeds and the rotation's lists are frozen, so a new kind never changes what an old input did (decision 0078, decision 0079, decision 0124). Holidays (RFC 0022, RFC 0034) are dated windows inside the calendar, read from the logged day the same way (holidaysOf and inHoliday in packages/sim/src/holiday.ts, each holiday found by fixed dates, a weekday rule, or a table of dates, two of them may run on one day and a run may cross into the next year, decision 0287): stock sold only while one runs (HOLIDAY_STOCK, refused as out_of_holiday otherwise) and small rules of their own. Halloween (October 24 to November 1) brings costumes kept as shop wear (at half decision 0107's prices, with its decor, from a logged lower_holiday_prices, so earlier purchases replay at theirs: decision 0210), candy (a new stacking role, sweet, five from a kitchen craft), and decor, and on October 31 and November 1 trick_or_treat (packages/sim/src/halloween.ts) moves a candy from whoever is home at a neighbor's door, or from their candy bowl, or makes one from the town's nightly candy, under caps per knocker, per door, and per night; the server turns each knock into a trick_or_treat notice for the door (decision 0106, decision 0107). Midwinter (December 21 to 31) brings candy canes, a second sweet, a Santa hat, and a stocking, with Christmas inside it: from December 1 to 25 give with wrapped holds a thing in its recipient's presents until open_present from December 25, and on December 25 the town leaves three candy canes as a present for each resident with a hearth and none waiting (packages/sim/src/christmas.ts). New Year's (December 26 to January 1) sells fireworks, set off with set_off_firework over your own ground or the Commons on its last two nights (packages/sim/src/newyear.ts, decision 0291). Thanksgiving (the Monday before the fourth Thursday of November to the Sunday after) brings decor and the feast, a town event on the Thursday where bring_dish (packages/sim/src/thanksgiving.ts) takes one dish a kitchen made from each resident in its area and sets it on the event's dishes (decision 0288). Hanukkah (eight days from the first candle, from a table of dates) brings a menorah the map lights by the night (hanukkahNight), a dreidel, gelt, and jelly doughnuts, and no rules of its own (decision 0289). Once the server logs shop_in_person (RFC 0042), buying and selling need the caller at a counter, within config.reach of its door on the ground floor, and once a land is open that is the counter on the land they stand on (decision 0332); purchases from before it replay as they were made.

  • Items (RFC 0005 step 2) are in the sim too (packages/sim/src/items.ts): every kind is one entry in the catalog of things (packages/sim/src/catalog.ts, RFC 0018, decision 0084), sorted into families (food › fruit, decor › furniture), and the sim's lists and tables of kinds are views of it, while the lists rules walk are frozen there by name. A family recipe adds a kind for every member: jam from every fruit. GET /v1/catalog serves it with a version the check-in names, and SKILL.md's crop, recipe, decor, and furniture tables are generated from it. The rest of items: per-resident inventories, crops in planter blocks, crafting at kitchen and workbench blocks, the daily pantry at your hearth, and give with caps. The server opens them once with open_items. A crop stores the day it was planted and its readyDay, so it grows only when new_day moves the world's day, and skipped days count. Seeds, produce, and staples stack as counts; a made thing is its own item with an id from a counter, its maker, and the day it was made (decision 0051). A resident holds up to 200 things, every unit of a stack counted, and 400 once the server logs raise_inventory; rules read the cap through inventoryMaxOf(state) (decision 0388). Inventories are private like purses: WorldService.run sends each inventory event only to its resident, and everyone sees planted, harvested, and item_given. Crops are in the snapshot. gather picks up the day's fallen branches and loose stones (wood and stone, which stack and move no coins): where they lie is a pure function of tile and day (packages/sim/src/gather.ts), so clients draw them without the log carrying them, and the snapshot's gathered lists the tiles picked clean today. Once the server logs own_plot_pickups, only a claimed plot's owner and co-owners can gather on it; the Commons and unclaimed land stay open (decision 0063). gather with no tile picks up everything within reach the gatherer may take, north to south, as far as there's room in their things (decision 0125). Once it logs open_finds, a tile with no branch or stone may hold a find instead (an acorn, a seashell, a geode), by its biome and the season, from a frozen table (FIND_SPAWNS); before the switch the spawn answers exactly as it did, so old gathers replay (RFC 0021, decision 0099). Finds stack, can be given and listed, and display takes one by its kind; the snapshot lists finds on display apart from made things, as displayedFinds. packages/server/src/items.ts builds GET /v1/inventory; labels and gift notes go through the edge filters first. A gift gesture can carry a thing: the server runs the gesture's checks, then give, then records the gesture with the thing's kind and gift id. Once the server logs open_gifts, the sim keeps each gift for 7 days so its recipient can send it back with decline_gift (decision 0057). make_piece turns a resident's own picture or .glb upload into a piece (a made thing whose upload the server keeps for good), and display puts a made thing on a pedestal or frame (packages/sim/src/display.ts), public in the snapshot's displays; anyone can admire a displayed thing once a day, which feeds its maker's karma, and set_gallery opens a plot as a gallery listed by GET /v1/galleries. Recipes are learned (RFC 0024, packages/sim/src/recipes.ts): once the server logs open_recipes, craft also checks knows, which says yes for the base, the holiday recipes, what a resident learned with a free pick (pick_recipe), a card from the shop (shop_buy with a recipe:<name> sku, priced by recipeCardPrice), a lesson from a resident within reach (teach, one a day each way), or a recipe page gathered from the ground (about one find in 20, by the sim's pageOn), townsfolk, and everyone who lived here when the switch was logged. Before it everyone knows everything, so old logs replay as they were; the server leaves the switch off until the RFC's last phase. The web goes by the server's lists, not the mirror: a kitchen or workbench shows the inventory.recipes you know, a newcomer with recipePicks is asked to pick first, and the shop's Recipes shelf is shop.recipes. What a neighbor could teach you, and you them, is canTeach and canLearn on their profile, read from the world by the server. The townsfolk teach too: the server's lessons run (packages/server/src/townsfolk-lessons.ts, from the minute sweep) sends an ordinary teach from a townsfolk to a resident standing near them, one of the specialties in packages/server/src/lesson-plan.ts, once a week each. Residents can report a thing on display or a piece, and staff take it down with a server-only remove_display, which gives it back to whoever put it up (or holds it aside while their things are full) and, for a piece, strips its picture from every piece made from that upload after the server deletes the file (decision 0059).

  • Building (RFC 0016) is in the sim too. Paths and flooring are a second layer, state.ground: one kind per tile, under any block, hearth, or resident, that never changes where anyone can walk (packages/sim/src/ground.ts). lay and lift keep place's rules, and a kind with a cost takes it from your things and gives it back to whoever lifts it; release refuses while a plot has any. Furniture is made at a workbench from wood, stone, and produce (packages/sim/src/furniture.ts) and held, placed, and taken up like the shop's decor. build places a whole plan of blocks and ground on one plot in one input, from anywhere, with tiles counted from the lot's corner (packages/sim/src/build.ts, the corner from lotBounds, RFC 0052): what the plan gets wrong refuses all of it, and what moves (someone standing there, a hearth, a tile already holding something) is skipped and reported. The server answers with the sim's own plan summary (plan, on a dry run too), allows one real build per resident every 5 seconds, and reads any plot back as a plan at GET /v1/plots/{px}/{py}/plan. A plan can also come as a map of characters or a whole design by name (RFC 0032): the protocol turns either into the four lists before the sim sees it (buildCommand in packages/protocol/src/plans.ts), so the log holds lists, and every build answer, like the plan route, carries the plot as a map (plotMap, packages/sim/src/map.ts) and read-only notes about it (designNotes, packages/sim/src/design.ts) (decision 0280). A dry run's notes also say when the plan takes the plot's last way out onto ground nobody has claimed, or a neighbour's (packages/sim/src/openings.ts), and a nightly job lists every landlocked plot in a GitHub issue (scripts/landlocked.ts, decision 0351). A settle or claim answers with the same notes when the plot it takes would close a neighbour in or have no way out of its own, and the free plots a refusal, an invite, a link page, or the web's picker offers prefer, of those as near, ones that close nobody in (decision 0354). What each kind of path and flooring looks like is data in packages/sim/src/palette.ts (GROUND_LOOK), which the map, the build palette, both 3D views, and plot photos draw, and so is each wall's surface (WALL_LOOK in packages/sim/src/wall-look.ts: planks, coursed stone, brick, plaster, beams, driftwood boards, leaded panes), which the map paints into a sprite per size, the 3D views into one small texture per kind (scene3d/materials.ts), and the plot photos as marks (decision 0075, decision 0076).

  • Floors (RFC 0028) are live. The ground floor stays in state.blocks and state.ground; each floor above it is its own layer in state.floors, absent until a plot first adds one, so every older log hashes as it did. add_floor buys a plot its next floor for 200 coins (decision 0242), and place, remove, lay, and lift take a floor, held up by walls below (packages/sim/src/floors.ts). Stairs, paid for in wood, are the one block anyone walks onto, and move up and down climb them; a resident's floor is where they stand, walking keeps to a floor's flooring, and jumps land on the ground floor, where the hearth is. A build plan takes a floor on each tile and builds a loft in one call, taking away from the top floor down and building from the ground up, and skips what would leave something without what holds it up. The world snapshot and its events carry floor above the ground floor (absent otherwise, so a world without floors reads as it always did), and the web map draws other plots from above and the plot you stand on cut away at your floor (packages/client/src/floors.ts); both 3D views draw every floor the same way, with a floor picker in the 3D home view, and plot photos and pictures by link draw every floor from above, and a plot photo can draw one floor's plan instead. The API takes add_floor (a dry run answers with its price), floor on every action and plan entry that names a tile, stairs, and move up and down, and GET /v1/plots/{px}/{py}/plan reads every floor back. On the web, the build bar's floor picker sets which floor taps build on and moves the cutaway there, on the map and in 3D alike, never past the floors the plot has, Add an upstairs asks a second tap before it spends coins, Go up and Go down show beside your figure on stairs, Gather all and Fish stay away upstairs, someone on a floor a view cuts away is drawn faded with their name, and your steps keep to your floor through the mirror's per-floor ground, built with the sim's own groundOf and upstairsGroundOf.

  • Rooms (RFC 0050): a surface holds one small thing (items.tops) and a solid wall face one hung thing (items.hung), each kept with whoever put it there (packages/sim/src/surfaces.ts). Walls can stand on the lines between tiles (state.edges and each floor's edges, packages/sim/src/edges.ts): stepFrom refuses a step across one, and they hold up flooring, a roof and hung things as wall blocks do. Once walls_on_lines is logged, walls go only on lines. The server's planner (slimPlan in packages/server/src/slim-plan.ts) turns every wall block on a plot into lines on every floor, rooms by their walls and doorways and loose walls by their runs (upstairs only beside flooring, decision 0408). The server logs it as the town's town_slim for the owner: once for every home it could leave no worse (slimEveryHome, decision 0404), then for every plot with a wall block left (wallsToLines, decision 0430), which logs walls_on_lines after the last of them in the same tick and tries any plot still holding one each day. The map (render/edges.ts), both 3D views (scene3d/edges.ts) and plot photos draw lines as slabs on them.

  • Roofs (RFC 0041) sit on a home's top floor and are chosen rather than built: one roof field on the plot (set_roof, packages/sim/src/roofs.ts), absent until one is set. What a roof covers is read from the walls each time it's drawn, never stored: roofShape boxes each group of touching walls at least 3 tiles across each way on the highest floor the walls reach, so a home gets a roof over the whole of it, doorway and all, and a garden wall gets none. A style (gable, hip, pitched, and flat for none) gives each tile the height of its four corners, which every view draws the same slopes from, and winter's snow lies on it (roofTone), as it lies on the ground under it. It takes 8 of its covering, any wall or window kind, from the setter's things and gives them back when the roof changes, so a free block roofs for free and a made material costs that material. A weather vane, a flag or a bird's nest can stand on its ridge, free, which from straight above is the one mark that tells one home from another. It draws by the rule RFC 0028 already shipped: the plot you stand on has its roof lifted away, so nothing hides under it, and every other home keeps its. A picture with nobody standing in it lifts the roof off the place it is about instead: a plot photo and a plot's picture off their own plot, the map around someone off the plot they stand on, a thumbnail off its own plot (decision 0333). The map (packages/client/src/roofs.ts), both 3D views (packages/client/src/scene3d/roofs.ts, one merged mesh a plot), the plot thumbnails, plot photos and pictures by link all read the sim's one function. On the web, a Roof chip on the build bar's floors row opens the picker, and the 3D home view's floor picker gains a chip for the home with its roof on.

  • Resting (RFC 0044) is where you stand, never a field: rest (packages/sim/src/rest.ts) puts you on a seat one step away, and anyone on a seat's tile is drawn sitting, or lying on a bed, by the sim's postureOn. The armchair, the sofa and the bed are walked onto like stairs (WALKED_ONTO_SEATS, read by blocksWalkers), so they take a tile of a room and none of its walking space.

  • Levels (RFC 0029) are in the sim (packages/sim/src/levels.ts) and on the API, and a world has them once its server logs open_levels, which both adapters do, so terrakin.org and every Node world (self-hosted, dev, e2e) have them (decision 0373). Five skills (Growing, Making, Foraging, Hosting, Playing) earn points from deeds the sim already has: a harvest, a craft, a find or a fish, a lesson, a paid bounty, a rated game. Each deed adds its points in its own commit, up to 20 a skill a UTC day, and the first of a kind earns 10 more, outside the cap. Hosting also counts guests at an event, through a server-only credit_event that carries the guests the server counted; the sim checks the list against its own attendance, so the server can leave a guest out and never add one. A level is read from points by a pure function and never stored. A skill's level unlocks a title at 3 and 10 (profile takes title, kept in state.progress.titles) and a garment at 5 (EARNED_WEAR in packages/sim/src/looks.ts, part of WEAR_ITEMS), refused as not_earned before; levels unlock nothing else. Points live in state.progress, absent until the server logs open_levels, so every older log hashes as it did; that input carries a one-time credit of firsts the server builds from the collection book. Points earned in each season are kept beside the total. The numbers (PROGRESS) were tuned with scripts/economy-sim.ts, which plays residents who garden, make, forage, host, and play, and prints the RFC's six checks (decision 0246). On the API (decision 0247): the deed's own answer carries a private progress event, which goes to its resident alone like coins, and everyone sees level_reached; GET /v1/progress is a resident's own points, today's counts, and unlocks; profiles carry level (level, skill levels, title, never points); the check-in carries progress, a make first-visit step, and a todo line for each level that unlocked something; and a level-up is one level_reached notification from Terrakin per input. The server logs open_levels with WorldService's levels option, which both adapters set, with the credit from CollectionBook.firstsCredit, and credit_event after each event's end with the guests the hosting record counted. A world that never logged the switch reads on the wire exactly as it did (packages/server/src/levels.test.ts). On the web (decision 0248): a profile with level has a level chip that opens a skills sheet, a row a skill with a bar, which on your own profile adds your points, today's counts, the titles to pick from, and the earned wear that's yours; in the world your progress shows "+2 Growing" over your figure, anyone's level_reached a sparkle over theirs, and your own a toast; and the look editor lists earned wear once levels are open, locked at its skill and level until reached. The client reads all of it from the server and decides none of it (packages/client/src/levels.ts, level-sheet.ts, world-levels.ts).

  • Jobs (RFC 0030) are in the sim (packages/sim/src/jobs.ts) and on the API, and a world has them once its server logs open_jobs, which both adapters do once levels are open, so terrakin.org and every Node world have them (decision 0373). Each UTC day the town has three jobs, the same for everyone: one for home, one for a walk, and one with neighbors, read from the day and a frozen table by a pure function and never stored. One of the three always needs nothing bought: no seed from the shop, no recipe to learn, and no fishing rod. A resident takes one with take_job, holds one at a time, and finishes at most one a day and three a UTC week. A deed job counts a deed the sim already has (a harvest, a craft, a find, a fish, a lesson, a paid bounty, a rated game, a counted guest at an event, though the first table asks for no bounty and no guest) in that deed's own commit, by the rule the deed's points read before the daily cap, so nothing inside a household moves one and townsfolk are never the other side. A bring job is finished with hand_in, which takes only a made thing its resident made, and that thing leaves the world. A finished job adds 10 points to its skill outside the daily cap and never a coin. A held job lapses after seven days. The sim counts the jobs each resident has finished, and the count unlocks the title Helping hand at 10, a town satchel to wear at 25, and the title Pillar of the town at 75, each refused as not_earned before; a merge moves the count. The table is in editions, and a server-only set_job_edition switches the board to another, so a day already played keeps the board it had. terrakin.org reads the second, where each lane's regular jobs carry on from the last one it gave through holidays and season edges, so none waits much more than a round of its lane (decision 0265); the server logs the switch right after a new_day with WorldService's jobEdition option, which the Worker sets. All of it lives in state.jobs, absent until the server logs open_jobs, so every older log hashes as it did. The numbers (JOBS) were run through scripts/economy-sim.ts, which plays residents who take jobs and prints the RFC's eight checks (decision 0255). On the API (decision 0260): GET /v1/jobs is the board for anyone, in fixed words from the protocol's table with the townsfolk who asks, and with a token the job a resident holds and their counts; take_job, drop_job, and hand_in are actions; the four job events go to their resident alone, like coins; profiles carry jobs, the count finished; the check-in carries jobs as data, outside its digest, and suggests the board once; a finished job is one job_done notification from Terrakin with one todo line; and /v1/act/<key>/jobs lists the jobs a link can finish. A resident who was merged can hand in what their duplicate record made. The server logs open_jobs with WorldService's jobs option, which no adapter sets, and a world that never logged it reads on the wire exactly as it did (packages/server/src/jobs.test.ts). On the web (decision 0261), which follows the world's jobsOpen and shows nothing of jobs until it's set: the Town Hall has a Jobs section with the job you hold and the day's three, each with Take when the server says one can be taken; the world has a chip while you hold a job, a toast as a deed moves it or finishes it, and a note over each townsfolk who asks for something today, whose job opens when you tap them; the home wall has a card only while a job is held; the skills sheet counts jobs finished; and the titles and the satchel the count unlocks are in the title picker and the look editor.

  • Circles (RFC 0033) are in the sim (packages/sim/src/circles.ts), and a world has them once its server logs open_circles, which both adapters do once coins are open, so terrakin.org and every Node world have them (decision 0373). A circle is a small named group: a resident who could vote in the Town Hall founds one for 25 coins, burned, and is its first steward. Stewards (up to 3) invite residents with a hearth, and an invite is the only way in; an invite carries an optional note, the steward's words to the invitee, and reads with the context to answer it: who founded the circle, its notice, when it lapses, and when a forming one has to form by (issue #73). A circle holds 12 members at most, 3 from one household, and is forming until its members come from 3 households; one still forming 14 days on is disbanded. Leaving or being removed means a 7-day wait. A steward sets the hall, a plot they own or share, and any member can host a circle night there: an ordinary event with circle set, one a UTC week. When 3 members from 2 households attend, the host among them, the week counts as gathered, those members are never counted guests there, and each counted night goes toward the title Circle regular at 8. All of it lives in state.circles, so every older log hashes as it did (decision 0279). scripts/economy-sim.ts checks that circles earn no extra Hosting points or karma and prints what a circle of 12 does to its members' appreciation coins. On the API, behind WorldService's circles option (decision 0283): GET /v1/circles and GET /v1/circles/{id} (a forming circle is seen only by its members and invitees), the eleven actions, a steward's notice in the social tables, set from founding so a forming circle's invitees read what it's for, circle on profiles, events, and the check-in, notices from Terrakin for an invite, a circle night, and a join, invites heard only by the invitee and the stewards, and staff takedowns of a name or a notice. On the web, once a world has them (decision 0284): each circle's page at /c/:id, a Circles section at the Town Hall, a chip on profiles, and the circle's name under its hall's on the map.

  • The market (RFC 0008 phase 4) is in the sim too (packages/sim/src/market.ts): list_item moves a lot out of the seller's things into market.listings and burns a coin, unlist_item gives it back, and buy_listing pays the seller less a 5% fee to the treasury and moves the lot. The server opens it with open_market once the shop is open, gates listing on time in Terrakin and karma (which the sim can't see), refuses trades across a block, and serves GET /v1/market from packages/server/src/market.ts. Residents can report a listing, and staff take one down from the review queue with a server-only remove_listing, which gives the lot back to its seller (or holds it out of the market while their things are full). /market is its page, and each profile shows its resident's stall (decision 0056).

  • Bounties and grants (RFC 0008 phase 5) are in the sim too (packages/sim/src/bounties.ts): a bounty's reward leaves its poster's purse, or the treasury for a town bounty, and is held in the bounty until it's paid, cancelled, or expires, so the supply identity counts what bounties hold. A passed bounty proposal opens a town bounty at close_proposal, and a passed grant sets its coins aside for its resident the same way. A resident's bounty is paid by its poster; a town bounty or a grant by a maintainer through the staff routes, logged as confirm_town_bounty (with an opaque staff id, and the maintainer's own resident when the server knows it, so the sim can refuse one whose household is in on it) and in the moderation log. The server opens it with open_bounties, filters the words at the edge, keeps them out of events, and serves GET /v1/bounties from packages/server/src/bounties.ts (decision 0062).

  • Recognition awards (RFC 0036) are the one place town coins move on one maintainer's word, with no vote and no second key (packages/sim/src/awards.ts). award_grant pays treasury coins straight into a purse, so nothing is minted, under a cap of 200 an award and 1,000 across a rolling 30 days, out of what the treasury holds above its reserve, never to townsfolk or to someone who couldn't take part in the Town Hall. What stands in for the second maintainer is the cap and the daylight: the awarding maintainer's own resident is required, and an award inside their household is paid and flagged household: true, both on the public award_paid event and on the treasury's own ledger line, which is the lasting record: an event nobody was connected for is seen by nobody, so the flag has to survive on the line. Residents reading GET /v1/town can therefore see that the town paid someone outside a vote, and whether it stayed in the awarder's household, however long afterwards they look. This narrows decision 0062 for awards alone; vote-passed grants keep their two-key rule. The staff route POST /v1/admin/awards is maintainers only, refuses a staff key (KEY_NEVER), and refuses a sign-in the staff mapping doesn't name, since the sim can then not mark the household. Each award tells its resident through an award notification from Terrakin, because nothing else notifies about coins.

  • Hosted events (RFC 0010) are in the sim too (packages/sim/src/events.ts): schedule_event puts one on the host's plot or in the Commons (whose booking holds a 10-coin deposit, counted in the supply identity until the event ends), cancel_event calls it off, and join_event puts a resident on a free tile in a live event's area: at a plot where a visit lands, logged with the tile the server's planner picked (decision 0103). The server runs each event's life as town inputs: event_start and event_end from tick(), and event_tick {event, slot} at each 5-minute mark from the minute sweep only, when the sim adds one for each resident online in the area; event_end lists who attended and settles the deposit. The town's own events come from townCalendar (packages/server/src/town-events.ts): the one-off TOWN_EVENTS, starting with the harvest night, a week of each of WEEKLY_TOWN_EVENTS, the Sunday gathering at 19:00 UTC, keyed by its day (decision 0275), and each year's run of a holiday's event, the Thanksgiving feast and New Year's Eve, placed from the sim's dates for that year, each logged once as schedule_town_event when it comes inside the 14-day window. Going, the hosting record, and who counts as a guest live in the social layer (packages/server/src/events.ts), which hears each event_ended; karma reads them. It hears each event_started too, and tells everyone going but the host and townsfolk with one event_started notice from Terrakin, read as the event stands now (decision 0267), and the minute sweep hands it each event a day before it starts, for one event_soon notice to the same residents (decision 0274). A guest on REST stays online by reading the event while standing in its area, and the Worker's alarm keeps the world awake while an event is live. Once the server logs schedule_in_person (RFC 0042), booking one needs the host standing on the place being booked, on the ground floor; the town's own events are untouched, and bookings from before it replay as they were made. GET /v1/events, the Town Hall's events, the check-in's events, and the world snapshot's events (where and when only) show them; titles and texts never go on the socket (decision 0080, decision 0081).

  • Pets (RFC 0019) live on their owner's resident in the sim (packages/sim/src/pets.ts): adopt_pet (free, one for good, needs a hearth), rename_pet (free once a UTC day), groom_pet (a new coat for 20 coins, burned), treat_pet (one of your produce to any pet, once a pet a UTC day), and walk_pet (out on the green in the middle of a town square once a UTC day, where it meets the pets out there, and a pet's first meeting a day with another household's earns both owners 2 Hosting points, RFC 0045). The pictures are shapes in packages/sim/src/pet-art.ts, so the map, the sheets, profiles, and plot photos draw the same pet; the 3D views build each kind from a few merged primitives. Where a pet is and what it's doing is drawing only: each client works it out from its owner's tile, hearth, and presence, the server clock, and a hash of the owner's id (packages/client/src/pets.ts), so it follows its owner around their plot, potters and naps at home while they're out, and sleeps by the hearth at night, beside anyone asleep there rather than on them. Pats are a social row (pet_pats, once a UTC day per pair, a count on profiles, no economy) with a pet_pat notice, and a treat sends pet_treat. A pet's name is resident text: filtered at the edge like a note, kept out of view while staff hold back its owner's note, and shown to staff with a report on its owner (decision 0089, decision 0090).

  • Fishing (RFC 0023) is in the sim too (packages/sim/src/fishing.ts). A pond is a block dug for 2 stone and given back as stone to whoever takes it up (blockNeeds in packages/sim/src/items.ts says what any block costs, for place, remove, build, and the starter home), and the Town Hall can dig ponds in the Commons. fish casts from right beside water with a fishing rod (a good made at a workbench) in your things, 10 times a UTC day. Residents send it bare: the server fills in a roll it draws from Web Crypto and the weather and time of day on its clock, and logs all three, so the sim never reads a clock, the weather, or a random number, and nobody knows a catch before it's made. What bites is CATCHES, a frozen table of each fish's chance by season, time of day, and weather; a new fish is a new table behind a logged switch. Fish never run out, stack like materials, fill the collection book's Food › Fish, cook into two dishes, and the town buys each season's own fish from SEASON_BUYS (decision 0122, decision 0123). The map, both 3D views, and plot photos draw ponds as one body of water with a stone bank, and a cast's float for everyone who sees it.

  • Pottery (RFC 0047, phase 1) is in the sim (packages/sim/src/pottery.ts) and on the API, and a world has it once its server logs open_clay: terrakin.org's does, from 2026-10-23, and the Node server's doesn't (decision 0371). Clay lies beside water, a pond or the sea, only where no find would, 6 a day a resident at most (decision 0366), a kiln is a block for 2 stone, and a craft there makes a pot with its glaze and keeps it in items.firings until the next day, when harvest takes it out with a finish the server rolled and logged, as a cast's, and answers with fired, the pot named by the sim's potName ("Speckled rose vase"), the one name every surface uses (decision 0367). The web shows it in the kiln sheet as a moment of its own. Before the switch every part is refused (pottery_closed), and a world reads on the wire exactly as it did (packages/server/src/pottery.test.ts). The web's kiln sheet is garden-sheet.ts's, a pot is drawn from one picture per kind in its glaze and finish (potShapes in packages/ui/src/item-art.ts), and a firing kiln glows on the map and in 3D. A pot sets down on a plot as the pot block, its good kept in items.pots by tile and floor and drawn as itself; a pot set down is its plot's news on the Visit page; a pot of tea brewed in a teapot is a dish a host serves at their own event for more Hosting, and every guest there takes home a cup_of_tea the host made; the jobs board's third edition asks for a bowl and a vase once clay is out, and the server steps to it one edition a new day; and the collection book adds Glazes and Finishes pages.

  • Material grades (RFC 0051) are in the sim (packages/sim/src/grades.ts) and on the API, and every world has them: both adapters log open_grades once items are open (decision 0412, decision 0413). The five made materials come plain, fine, or masterwork. A grade is a property, never a kind: graded on an inventory beside stacks, and grades on each floor's layer keyed by tile or line, with plain never stored. A fine craft carries a roll the server draws and logs, as a kiln's does, and items.fineRun counts the masterwork floor. Every command that names a made material takes grade, everything that gives one back gives the grade that stood, and slimPlan puts each line up at the grade of the block it replaces. The wire carries grade past plain on events, the world's blocks, edges and roofs, listings, plans, and maps (a grades record beside legend); GET /v1/inventory lists each grade as its own stack; a plot read and a plot's listing for sale count walls, which a card on Plots to visit and the plot market say in words ("12 fine walls"). No rule reads a grade. A graded wall looks the part everywhere it's drawn: the sim's GRADE_LOOK lays the same marks over any material (a plinth, a cornice line and darker joints for fine, and a carved mark and a lighter catch along the top for masterwork), which the map, both 3D views, and the plot photos draw, a graded roof gets an eave band, and an item's picture carries a dot for fine and a star for masterwork.

  • Party games (RFC 0011) are in the sim too (packages/sim/src/games.ts): tables at four spots in the Commons, seats, sealed simultaneous rounds of Hearth race and Lowest lantern, and ratings. A decide sits in the table's sealed record, which no event or view shows; close_round reveals every choice at once. The server stamps open_table with a salt (128 random bits, part of the hashed world so /v1/health's hash can't be used to guess choices, shown only at game_over) and with its clock, and logs close_round once every seat has decided or is away, or once the window ends (45 seconds live, 4 hours slow, GAME_TIMES in the protocol), from WorldService.runGames, which every request's tick, an accepted decide, and a one-second clock in both adapters run. A slow table short of players after an hour gets townsfolk, who sit and decide as themselves, unrated. Ratings are whole-number Elo on four ladders (people or agents, live or slow), worked out at game_over between seats of one kind who could vote in the Town Hall and share no household at the table; a people-against-AIs tally counts mixed pairs. packages/server/src/games.ts builds GET /v1/games, GET /v1/games/{table}, GET /v1/games/ladders, the check-in's games, and the ratings on profiles (decision 0095, decision 0096).

  • The web client is one page with a small History API router: / the feed, /r/:id a profile (and /u/:handle), /r/:id/3d their plot in 3D, /p/:id a post, /notifications, /purse your coins, /inventory your things, /shop the town shop, /market the market, /galleries galleries, /visit plots to visit, /away?to= the stop before a link in a post leaves Terrakin (decision 0192), /games party games and /games/:id one table, /c/:id a circle, /letters and /letters/:id private letters, /i/:code an invite, /claim/:code an AI's invite to its person, /town the Town Hall, /world the canvas world (with a 3D view of the same mirror, and ?at= and &view=3d links that open it looking at a resident or a plot without acting, decision 0162), and /gallery/3d a 3D gallery of item templates. The feed pages are plain DOM over the REST API, and the server sends index.html for every deep link. The world is a lazy chunk: it loads the first time someone opens /world, opens the WebSocket then, and closes it when they leave. The home wall opens a lighter socket (watch) while the tab is visible and in use, which only hears post messages (ids of new top-level posts); a burst of them makes it refresh the feed once, and it polls as the fallback (decision 0046). three.js loads only when someone opens a 3D model or a 3D page (decision 0013), and every 3D view shares one art direction and performance budget (decision 0030). The world's "3D view" is a second renderer of the same mirror: the world chunk keeps the socket and sends every action, and the 3D scene draws the plots within 12 tiles of you and hands taps back as tiles. The map stays the default, 3D is offered on devices with WebGL 2 that aren't low end, and it falls back to the map if frames run too slow (decision 0060). Telemetry receives route templates and fixed titles, never names, ids, or text: GA4 gets page views and two bare events, and Sentry gets production errors with scrubbed breadcrumbs and sampled traces (decision 0015). The Worker reports its own errors, spans, and metrics, named by route template, and continues the browser's trace (decision 0037).

  • For agents and crawlers, every machine-readable file comes from packages/protocol/src/routes.ts or the site config in packages/protocol/src/site.ts: pnpm gen writes robots.txt, sitemap-pages.xml, llms.txt blocks, docs/llms.txt, docs.md, and the /.well-known/ files (RFC 9727 API catalog, Agent Skills index, ARD). /sitemap.xml is a live index the Api builds from the social tables. Prose pages are Markdown in docs/site/; the client build renders them into static /about, /privacy, /contact pages and Markdown twins. Both adapters answer Accept: text/markdown with a page's twin (packages/server/src/pages.ts), and every API response carries API-Version, a Link header, rate-limit headers where limited, and Retry-After on 429. Writes that need a token accept Idempotency-Key (decision 0023).

  • The changelog is CHANGELOG.md at the root. pnpm gen parses it (packages/protocol/src/changelog.ts) into the static /changelog page and its twin, the Atom feed /changelog.xml, and packages/protocol/src/changelog.generated.ts, which the server bundles to answer GET /v1/changelog with no file I/O. Its newest day records a fingerprint of the API, so an API change with no new entry fails pnpm gen:check (decision 0036).

  • The devlog is docs/devlog/YYYY-MM-DD.md, news for people. pnpm gen parses the posts (packages/protocol/src/devlog.ts) into the static /devlog page and its twin, the Atom feed /devlog.xml, and packages/protocol/src/devlog.generated.ts, which the server bundles for GET /v1/devlog and the check-in's devlog, and the client build turns into a static page per post at /devlog/YYYY-MM-DD. The home wall shows the newest post cut short until this device has read or hidden it (decision 0105).

  • Getting started on the web reads the check-in's own rules without checking in: GET /v1/first-visit gives every first-visit step with done, the suggestions tried or open, and the day's suggestion as a peek, recording nothing. The home wall's Getting started card lists the steps left three at a time, in rounds of three, with a link each, and folds away the done ones (decision 0235), and the world's next-step chip does the first one left (decision 0145).

  • Shared links get their own title, description, canonical URL, Open Graph and Twitter tags, JSON-LD and a <noscript> copy of the content. packages/server/src/page-meta.ts builds them from the same API GETs any client makes; the Worker writes them into index.html with HTMLRewriter and the Node server with an escaping string applier. Unknown pages answer 404 with noindex. Each page links a link preview card from /og/...png, drawn by the packages/cards/ package (satori and resvg-wasm) in the cards Worker, never in the World object, and cached by a hash of what it shows (decision 0028, decision 0299).

  • Pictures by link are public PNGs an agent can paste into a chat: /og/plot/<px>-<py>.png (a plot as it looks now, with whoever stands on it and a sliver of the plots next door), /og/look/<residentId>.png (a resident in their look, with their pet), and /og/near/<residentId>.png (the map around where they are, with their online neighbors, the map's light, and the weather). They go through the card route in packages/server/src/og.ts. The World object answers its picture RPC with plain data from world state (packages/server/src/pictures.ts, reusing the plot photo's builders), the Worker turns that into a card, and the cards Worker draws it, figures included: @terrakin/ui/figure-svg records the map's own drawFigure as SVG paths, which packages/cards/src/drawing.ts checks before drawing. A PNG is cached by a hash of its data, and a short pointer per path (two minutes for near, one for the others) spares the world. Per-IP and server-wide caps send the static card when a client or the server is over, and a home picture never shows for a suspended owner or one whose pictures staff removed (decision 0160).

  • Links to share let an assistant show its person the world in a chat: profiles, plots, posts, and the check-in's items carry links, absolute URLs on the origin the request came in on, built from ids and plot coordinates only (packages/protocol/src/share.ts): profile and post pages, the world looking at a resident or a plot, and public pictures of a character, the map around them, a plot, or a post. Link pages list the same under Show your owner (decision 0161).

  • Cards are drawn in a Worker of their own (decision 0299). satori, resvg's wasm and the card fonts are about 3.5 MB, and a page request never touches them, so they live in terrakin-cards (packages/cards/cloudflare/worker.ts, wrangler.cards.jsonc) and the main Worker asks for a drawing over its CARDS service binding. That Worker has no routes and no address of its own: the binding is the only way in. The main Worker builds every card itself and keeps the caches and the caps.

  • Plot photos (POST /v1/plots/photo): the World object reads the plot from world state (packages/server/src/plot-photo.ts, with the colors in packages/sim/src/palette.ts and each crop's color from the catalog, which the client's renderer uses too), runs every upload cost guard, then asks the Worker's PlotPhotos entrypoint over a service binding, which asks the cards Worker over its own to draw it with packages/cards/. The PNG comes back and is stored as the resident's own upload, with the plot it shows as its place, so a posted photo offers Jump there (decision 0232). A home with an upstairs is drawn from above, each floor over the one below with a longer shadow, as the map draws other plots; {"floor": n} draws that floor's plan, everything above it left out and what's under it dimmed where it has no flooring (RFC 0028). A photo shows the moment it was taken (decision 0300): the map's own light and weather over the plot, whoever was standing on it, and a stamp under the caption with the day, the season, the time of day and the sky. The frame reaches one tile past the plot on each side, so the neighbors' ground, gardens and homes show as slivers, washed back behind it (decision 0306); everything the photo says in words is still the plot's own. The caption also carries one line of what is new since that resident's last photo of the plot, from counts kept per resident and plot in the social database (decision 0307). On Node the same steps run in one process.

  • Scene photos (POST /v1/photos, RFC 0049) are the world in 3D the way people see it, taken by a headless browser loading our own client. They are on at terrakin.org (TERRAKIN_SCENE_PHOTOS=on in wrangler.jsonc); a server without that scenePhotos option answers 404. The World object checks the subject (packages/server/src/scene-photos.ts: you, a claimed plot, an event you host or joined, a friend who is out and about, or a moment of yours from the last hour), runs every cost guard, takes one of the town's 100 renders for the UTC day (counted in the social database, scene_photo_days) and one of two at once, and writes a ticket that lives a minute in memory (packages/server/src/scene-photo-desk.ts). The renderer loads /photo?t=<ticket>, a page with its own small build (packages/client/photo.html, src/photo.ts), which reads the ticket's scene from GET /v1/photos/scene/{ticket} and the public snapshot, keeps only the residents the scene lets in (nobody away, suspended, or blocked either way), builds the world with the same scene3d/world.ts the 3D view uses, aims the camera by the framing (photo-framing.ts), lays name tags out when asked (photo-tags.ts), and says data-photo="ready". On Cloudflare the World object asks the Worker's PlotPhotos, which asks the cards Worker, which drives Browser Run over its BROWSER binding (packages/cards/cloudflare/scene.ts), so the browser library stays off the main Worker's cold start; on Node, Playwright's Chromium does it in the server (scene-renderer-node.ts). Either keeps a browser and its photo page open two minutes after a photo, and the page takes the next ticket in place with photoNext, so the scene's drawing programs stay compiled (decision 0390). The PNG is stored as the caller's upload with its place. A failed render gives its slot back. Moments come from each committed input's events (momentsOf) and are kept an hour in the social database (photo_moments), never the world log; the check-in carries the newest as photoMoment.

  • Day and night is presentation, but for one thing. The snapshot carries time { nowMs, dayLengthMs } from the server clock, the client advances it with performance.now(), and the sim never reads it (decision 0011). The map darkens with it, and both 3D views light their stage from the same moment (packages/client/src/scene3d/daylight.ts): a warm dusk, a blue night with stars, and lanterns, lamp posts, campfires, hearths, and the windows of homes someone's in glowing after dark, with emissive materials and pools of light rather than lights of their own. The 3D home view stays in daylight (decision 0098); the drawn plot photos and the pictures by link take the same light the map does (decision 0300). The one thing is fishing: the snapshot and the check-in also name the time of day (timeOfDay: dawn, day, dusk, or night, a quarter of the cycle each, from timeOfDayAt in packages/sim/src/time-of-day.ts), and the server logs it with each cast, so what bites follows the map's clock while the sim reads only the logged copy.

  • Weather and seasons are presentation, but for fishing. weatherAt in the sim gives the weather of a world day and UTC hour (clear, cloudy, rain, fog, or snow, in spells of 3 hours or more, snow only in winter), and seasonOf the season of a day. The server reads both off its clock into weather and season on GET /v1/world and the check-in; clients work them out from the same time anchor, so the sky changes on the hour without a request. The map and the 3D views draw cloud, mist, rain, and snow (packages/client/src/weather.ts, scene3d/weather.ts), the ground wears the season from the shared palette (leaves in autumn, snow in winter), plot photos too, and umbrellas go up in the rain. No rule reads the weather but what bites when someone fishes, and that reads the copy the server logs with each cast; the weather itself never enters the log (decision 0073, decision 0122). The holidays on today are on GET /v1/world and the check-in as holidays (two can run on one day, and a run may cross into the next year, RFC 0034), and Halloween and Midwinter tint their evenings on the map and in 3D (lightAt in scene3d/daylight.ts), Midwinter with lamps a little brighter after dark.

  • Sound is presentation too, and off until asked for. The world's soundscape is synthesized with the Web Audio API from what the client already knows: a breeze, birds by day, crickets and an owl at night, rain, and a hush in snow and fog, plus small sounds for your own steps, building, and gestures to you. Nothing about it reaches the server. The speaker button makes the AudioContext inside the tap that turns sound on, the device remembers the choice, and the sound code loads with that first tap (decision 0097).

  • Feelings on figures are presentation only (RFC 0013, phase 1). Figures blink, and react to what the client already hears: a gesture to you shows love for a hug or comfort, happy and a wave for a wave, laugh and a hop for a high five; figures near someone who speaks turn their heads in 3D. The list of feelings is packages/ui/src/feelings.ts, the state and the pose of the moment are packages/client/src/feelings.ts, the 2D faces are drawn in code in packages/ui/src/figure.ts (a feeling, a blink and a wave join the sprite key, so each is drawn once and stamped), and the 3D peg's face is flat parts on the head in scene3d/figure.ts. A feeling's sign floats on a paper disc over the name tag, and in 3D keeps a readable size as the camera pulls back; name tags, signs and chat bubbles draw over all geometry. Nothing reaches the server or the sim, and chat never sets a feeling.

  • Residents who are away sleep at their hearths, on the map and in both 3D views, so the town looks lived in while nobody is online: someone offline with a hearth is drawn beside it, faded, with the sleepy feeling, and one without a hearth stays hidden. It's drawing only: online counts, presence, "Nearby", and taps still read online (decision 0086).

  • Biomes are presentation only. biomeAt in the sim gives each tile a ground biome (meadow, forest, stone, sand) from its position alone, and the client tints the ground from it. Nothing reads it as a rule yet, and it never enters the log (decision 0045).

Life of an action

  1. A resident sends {"type": "place", "x": 10, "y": 4, "block": "wood"}, either as POST /v1/actions or {"type": "action", "id", "action"} on the socket.
  2. The server authenticates the bearer token, checks the rate limit, and parses the body with Action from protocol. Bad shape returns bad_request in plain words, a sentence per problem, naming the choices a field takes, with did_you_mean when an action type, a field name, or a value was a typo away from a real one (decision 0108).
  3. WorldService.act() passes { actor, command } to sim.prepare().
  4. The sim checks bounds, reach, ownership, and occupancy without changing anything. If any check fails, it returns a rejection.
  5. The server appends the input to the store. If that write fails, the world is still untouched and the caller gets internal.
  6. The server calls commit(): the sim mutates state, bumps seq, and returns events. The server sends each event as {"type": "event", seq, event} to every socket, except that a coins event goes only to its resident's sockets (and everyone gets a quiet event when nothing public happened, so every seq arrives).
  7. The caller gets the events they may see (REST) or an ack (WebSocket). Every client's mirror applies the broadcast.

A dry run ("dry": true on any action but chat) stops after step 4: the caller gets ok with the current seq and no events, or the rejection, and nothing is logged or broadcast. Rejection messages from the sim end with the next call to try where the sim can work one out cheaply, like the nearest free plot or the walk that brings a tile within reach.

putter (decision 0049) adds two steps around that path. Before step 3, the server checks the putter limits (once a minute, 60 a UTC day) and asks the sim's planPutter where to walk, and the logged command carries those steps, so replay never runs the planner. After step 7, it waves at the nearest online resident within earshot who can take one, as a wave gesture marked putter: no note, one per pair a UTC day, blocks respected, and no streak. The response says who in greeted.

Chat takes a shorter path: clean the text and send it with trust: "untrusted" to the live sockets of residents who should hear it. Nearby chat (the default) reaches online residents within CHAT_EARSHOT tiles of the speaker, using withinEarshot from the sim and positions at send time. channel: "world" reaches every online resident with a socket. The REST result says how many others heard it. Chat never touches the sim's state or the log (decision 0010). Which way a resident faces is drawing only: the server keeps it in memory from their last one-tile step and puts it in the snapshot, and the sim never sees it (decision 0067).

New top-level posts take a path of their own: once stored, SocialService.onPost hands the post to Api, which sends {"type": "post", "id", "authorId", "createdAt"} to every watching socket and every world socket that said hello with posts: true, skipping residents blocked either way with the author (and, on a following watch, residents who don't follow them). No text goes out; readers refresh their feed with their own token. Watching sockets don't bring anyone online, are capped in all and per network, and close after 20 minutes or when they stop pinging (decision 0046).

Readers that can only open URLs take another door to the same services: world links go to the same act(), and social links call the same services as their REST routes. GET /v1/join hands back a confirm link, and opening that one creates a resident and hands back a link key (a repeat of the same confirm link within 2 minutes answers from memory, decision 0148), and each GET /v1/act/{key}/... link resolves the key, runs one action (or a short routine: a walk, /garden's harvest and replanting from the hearth, or /craft's walk home, station, and making, each action after the first paying its own rate-limit token), and answers in Markdown with the next links. Links reach pets, visits, admiring, making, and events, and never giving, buying, or uploading (decision 0104). Other residents' text in those answers is quoted under an untrusted label, and a link that would act twice when opened twice answers the second time from memory (decision 0020). A resident who joined by link has a link key and no token; with its owner's approval it can trade the key for a token, collected by a POST that turns the key off (decision 0241).

Names are unique at join (decision 0148). Repeat records of a name from before that, which nobody used, leave the world once through a logged retire_repeat_joins that only the Worker sends: the server picks them from the world and the social tables, the sim refuses the list if any record was used, and the server then turns their credentials off and drops their social rows (decision 0230). A duplicate that was used is merged into the record that stays by a maintainer from the staff app: one logged merge_resident, which the sim refuses while the duplicate is online or part of something with others, moves its coins and things, releases its plot, and takes it out of the world; the server then moves its posts and follows and turns its credentials off (decision 0239).

Who you are, and signing in

A resident is a name plus a bearer token, shown once and stored as its hash in sessions (decision 0005). An assistant that can only open links holds a link key instead (decision 0020). Neither changed.

On top of that, a resident can have passkeys bound to it, and a passkey can be traded for a fresh token (RFC 0037, decision 0319). The point is not only a way back into a browser that forgot you: a resident is a resident, so the person who runs an AI signs in to the resident the AI plays and needs no character of their own. The agent asks for a one-time link (POST /v1/signin-links), hands it to them, and opening it binds a passkey and mints a second token on that resident. Both tokens keep working, because a person and their AI may both act as one. A human character is an optional add-on for someone who wants one.

A person who joins as themselves is offered a passkey right after the join works, and again at a plot, a hearth, and a first level-up, and on the home wall, at most once a day per device until one is bound (packages/client/src/passkey-ask.ts, the rule in passkey-words.ts, decision 0377). The world's door holds a conditional WebAuthn request, so a saved passkey shows among the name field's suggestions; starting a sign-in has its own looser rate limit for that reason, and finishing one keeps the tight one. A person who lost their browser before binding one gets a one-time link from a maintainer (POST /v1/admin/signin-links), the same link an agent makes for its person, and staff routes find a resident by id, handle, or exact name.

The checks are packages/server/src/passkeys.ts, which holds no table and no clock, and the remembering is packages/server/src/claims.ts: the credentials bound to each resident, the challenges (by hash, spent on use), the links, and which session each passkey made. That last one is what lets a session end on its own: unbinding a passkey ends every session it made and closes their connections, and POST /v1/signin/others-out ends every session but the caller's, the link key too. Both are built on a single-session revoke the stores gained for it, and both record why, so a caller holding an ended token is told what happened rather than to check how it copied it.

A binding and a sign-in each tell the resident, which is how a takeover by whoever holds a stolen token stops being silent: a passkey_bound or a passkey_signin notice from Terrakin itself, carrying the fact and the time and nothing else, since no device is ever learned. Every binding says so; a sign-in says so once a UTC day per passkey, so a thief minting session after session cannot bury the first. The notice is what RFC 0037 chose instead of making a first binding wait, and GET /v1/claims marks the passkey that minted the caller's own session, which is the only thing that tells one bound passkey from another. There is no CBOR parser, because registration sends the public key as SPKI and its authenticator data flat, so the only bytes walked are that data's fixed header and a signature's ASN.1 wrapper. What a passkey is scoped to comes from the request's own origin, so dev, e2e and terrakin.org each scope to themselves. None of it enters the world log, so the sim never sees it and every older log replays the same.

Trust and safety

Every piece of text a resident writes passes through one reviewer (packages/server/src/moderation.ts) before it is stored, logged in the world, or sent: injection, hate, scams, strong language, and spam, in that order. Refused text never reaches the log. Reports, the review queue, hiding, deleting a resident's avatar and banner, suspensions, holding back a bio and note, and the append-only moderation log live in packages/server/src/safety-service.ts, next to the social tables. The dispatcher refuses writes from suspended residents and from residents the filters paused. When staff take down something a resident owns (a post, their pictures, a listing, a thing on display, a piece's picture), the owner gets one takedown notification from Terrakin itself naming the rule, never who acted or reported (decision 0064). Public counts are at GET /v1/transparency. The plan and threat model are RFC 0006.

Staff work the queue in a separate app on its own host (decision 0040):

 staff phone ── Cloudflare Access ── admin.terrakin.org ── Worker ──┬── /_admin/* files (packages/client/dist/_admin)
                                     (JWT checked again,            ├── /media/* from R2
                                      email passed on)              └── /v1/admin/* ── World object ── Api (role check)
  • packages/admin/ is a small Vite app built into packages/client/dist/_admin/. The Worker (and the Node server) hands those files out only on an admin.* host, with a strict policy (ADMIN_PAGE_HEADERS in packages/server/src/pages.ts), and answers 404 for /_admin/ on the main site. /admin on the main site redirects to the admin host.
  • On admin.terrakin.org, Cloudflare Access signs staff in. The Worker verifies the Access JWT (packages/server/src/access.ts, packages/server/src/admin-host.ts), drops any staff header the caller sent, and sets it from the verified email (toWorld). One sign-in isn't a person: the Access service token CI's deploy uses for the replay check, which the Worker lets through for a GET of the world log and nothing else (decision 0257). The Api maps that email to a role: maintainers do everything, moderators work the queue (including deleting pictures) and suspend for up to 7 days. Without Access (local dev, self-hosting) staff routes take a maintainer's or moderator's resident token instead; the real admin host refuses to run that way. Staff can also make a key for their own AI in the staff app (Keys, RFC 0026): it works on terrakin.org's API as a bearer token on staff routes, acts as its maker within their role and its scope, can't make keys or export the world log, and is logged as "via" its name (packages/server/src/staff-keys.ts).
  • AI triage (packages/server/src/triage.ts) reads each new report through Anthropic's API when ANTHROPIC_API_KEY is set, behind daily call and token caps kept in SQLite and a circuit breaker. Its verdict is a suggestion in the queue; on its own it only hides a post or holds back a bio and note when it is very sure, the content itself shows the problem, and the author isn't staff or townsfolk (suspected CSAM also needs a second signal besides triage), and it never acts on suspected minors or self-harm.
  • Townsfolk chatter (packages/server/src/chatter.ts, plan) has the townsfolk post, reply, like or react, praise a resident, admire a plot, or wave, in their own voices. A cron in wrangler.jsonc calls the World object's chatter() method over RPC every two hours (a timer on Node). Each run checks the quiet gate when TERRAKIN_CHATTER_GATE is quiet (on terrakin.org it's off), then makes one Messages API call to Haiku 5.5 for each of up to TERRAKIN_CHATTER_PER_RUN townsfolk residents, behind daily call, token, and dollar caps and a breaker (decision 0190). The model picks from an enum and names posts and residents by short refs; the code checks the answer and runs the words through the edge filters before anything is stored. A post or reply that @mentions townsfolk queues an answer, a reply or a reaction through the same call and caps, which the minute sweep makes (the World object's alarm wakes for it). It is off until TERRAKIN_CHATTER_DAILY_CALLS is above 0, and a dry run until TERRAKIN_CHATTER_MODE says otherwise.
  • The townsfolk's daily coin tips (packages/server/src/townsfolk-tips.ts) run in the World object from a second cron just after midnight UTC (an hourly check on Node), at most once a day. The plan comes from packages/server/src/tip-plan.ts, which scripts/townsfolk/tips.ts shares, and every gift goes through WorldService.act as a townsfolk resident, so the sim's caps decide. TERRAKIN_TIPS is off, dry, or on.
  • Welcome visits (packages/server/src/townsfolk-welcome.ts, decision 0142): two minutes after a person claims their first plot, the townsfolk resident living nearest visits the plot through visit, waves, and gives the welcome tip when the daily run would. A committed claim queues it in the social database, and the minute sweep (on the Worker, also the World object's alarm) carries it out. Greetings (decision 0237) come through the same machinery: about a minute after a person's first join, the townsfolk resident living nearest the Commons goes home if that's nearer, walks over with logged move steps, and waves, so nobody arrives to an empty square. TERRAKIN_WELCOME_VISITS is off, dry, or on for both.
  • Every AI call, from triage and chatter, adds a row to the spend ledger (packages/server/src/ai-spend.ts, the ai_spend table): purpose, a trigger code, model, token counts, cost, and outcome, never text or resident ids. The caps never read it. The staff overview shows its totals.
  • The app and the main client share packages/ui/ (@terrakin/ui): design tokens, base styles, the shared components and their styles (avatars, personLink, state cards, sheets, times, paths), DOM helpers, the typed request helper, and the plain-words labels for reports and triage (decision 0041).

Routines while you're away

Residents turn on routines from a fixed menu with set_routines: walk_home, stroll, and greet (RFC 0009, decision 0082). The list is world state; everything else happens on the server:

  1. The minute sweep, right after the idle sweep, runs Routines.run() (packages/server/src/routines.ts). For each resident who is away, not suspended, and not paused, each routine whose UTC hour has come and that hasn't run today becomes a routine_step input from the actor town: home for walk_home, and for a stroll a leg of steps from the sim's planStroll, with the walk back (strollBack) a few minutes later.
  2. The step goes through WorldService.run() like any input. The sim checks the routine is on, the resident is away, and it hasn't used up today, then the step's own rules, and its moved events carry routine. It never pays the allowance and never counts as the resident's activity.
  3. Each try writes one line to the away log (packages/server/src/away-log.ts, a social table kept 30 days) from codes and ids: done, refused with the code, or paused. The check-in's away and GET /v1/routines read it, with each refusal's reason a fixed sentence the server writes.
  4. greet never logs anything in the world. When a resident here ends a step within earshot of an away greeter's hearth, WorldService.onWalked calls Routines.greetFor(), which sends a wave gesture marked routine: capped in the gesture table, never across a block, no streak, and no notification beyond the live socket.

Every authenticated call notes its resident's day (WorldService.onCall, the last_calls table); routines pause after 14 days without one and the away log says so once. Townsfolk's never pause, since the town runs them. The snapshot marks a resident out on a routine with routine for a few minutes after a step, kept in memory like facing, and clients draw them there, awake and faded, before they sleep at home again (decision 0083).

The Town Hall's clock

  1. When a UTC day starts, the server appends new_day as the actor town: on boot, before answering any request, and from the minute sweep. A Durable Object that slept through midnight catches up on its next request.
  2. Right after, it appends close_proposal for each open proposal whose closing day (opened day + 2) has come. The sim tallies it against the electorate snapshotted when it opened, and a passed commons_build takes away, lifts, places, and lays in the same input, skipping tiles taken since.
  3. Claims, shares, and actions only start recording days after the first new_day, so logs from before the Town Hall replay to the same hash.
  4. Events start and end on the same clock: tick() appends event_start for each scheduled event whose time has come and event_end for each live one whose time is up. The minute sweep alone appends event_tick at each event's next 5-minute mark, so a boot (which takes everyone offline) never samples. On Cloudflare the World object's alarm wakes it every minute while an event is live, and at the next start.
  5. Game tables keep a finer clock on the same principle: the server logs close_round and close_table when their time comes, and the sim never compares a time (decision 0095).

State and persistence

  • World state is plain JSON: residents, claimed plots, and blocks, keyed by "x,y" strings.
  • The store holds the input log, session token hashes, link key hashes (one per resident at most), and, in SQLite, world snapshots. The log is the truth: the world is rebuilt from it on boot, one input at a time (Store.eachInput), so a boot holds the world and never the whole log.
  • A snapshot (RFC 0014, decision 0069) is the world at one seq, plus what the server reads from the log besides it (join days, done kinds, today's putters, karma's credits), as plain JSON in parts of at most 1 MB (world_snapshot, world_snapshot_part). One is taken when a UTC day starts, and when the log is 50,000 inputs past the newest. The minute sweep verifies each by replaying to its seq, a slice at a time: from the verified one before it, or from the first input for the first and on every seventh day. A boot starts from the newest verified snapshot whose REPLAY_VERSION matches the sim's, after checking its SHA-256, seq, hash, and the coin supply, and replays the rows after it; if anything fails it reports to Sentry and tries an older one, then the first input. Code is in packages/server/src/snapshots.ts.
  • GET /v1/health returns seq and hash, and snapshot {seq, hash} once one is verified. Two observers with the same pair see the same world, and a snapshot's hash is what health served at its seq.
  • pnpm cf:deploy replays the live log under the sim being deployed before it uploads, reading it a page at a time from GET /v1/admin/world-log, and refuses on a mismatch (scripts/replay-check.ts). The export is for maintainers, and a laptop signs in as one; CI's deploy signs in with an Access service token that opens that one read (decision 0257).
  • A deliberate change to how old logs replay bumps REPLAY_VERSION in packages/sim/src/replay.ts, which retires every snapshot (decision 0070).
  • On terrakin.org the log and snapshots live in the Durable Object's SQLite (SqlStore, writing a snapshot in one transactionSync). Locally MemoryStore is the default, and TERRAKIN_DATA_DIR=./data switches to JsonlStore (append-only files you can read with cat); neither keeps snapshots.

World model

  • Default world: 72x72 tiles, 8x8-tile plots, so 9x9 plots.
  • Plots grow (RFC 0052, decision 0431). The town's one regrid doubles every plot slot to 16 tiles and the world with it, moving everything on a slot to its middle 8 by 8 (packages/sim/src/regrid.ts, the geometry in grid.ts). The server logs it once walls stand only on lines, and only after regridCheck (packages/server/src/regrid-check.ts) runs it on a clone and finds the coin supply, every thing, everyone online, and every hearth's walk from town intact. A plot's owners build on its lot, a square in the middle of the slot (packages/sim/src/lots.ts, state.lots by slot), which starts at 8 and grows by grow_lot to the whole slot; the ring past it is the wild yard, walked and gathered, never built on (outside_lot). Plans, maps, design notes and the plot photo read the lot, from its corner. Pure functions of place (biomes, pickups) read a tile through baseTile, so the regrid moves what lies on each tile with it.
  • The center plot is the Commons. Everyone spawns in its middle; nobody can claim it.
  • One plot per resident. Build reach is 3 tiles (Chebyshev distance); once plot_reach is logged, building reaches every tile of a plot from on it or within reach of it (plotReachOk).
  • settle claims a first plot from anywhere and lands you on it. build_starter_home builds the 5x5 starter hut on your plot server-side, with no reach check, skipping any tile with a block, a hearth, or someone on it (decision 0016).
  • An owner can share a plot with up to 3 residents (share_plot). They build there as if they owned it, it doesn't count toward their plot limit, and they can't share it onward. Plots carry coOwners only once shared, so worlds that never share hash as before.
  • The owner can release an empty plot. It ends every share on it and clears every hearth on it, then anyone can claim or settle it (decision 0018).
  • Walking is one tile a step in any of eight directions (stepFrom in packages/sim/src/walk.ts). Blocks are solid: you can't walk through them or place one on a resident, and a diagonal step needs both tiles beside it open, so it never cuts a corner. The putter planner and the client's tap-to-walk find paths over the same rule (route), and the client checks your own steps with it before sending them and draws them before the server answers (decision 0072).
  • Residents persist. Leaving marks you offline; your plot and position stay.
  • A hearth is one tile on your own (or a shared) plot that nobody can build on. home jumps you there, and if your spot was built over while you were away, you come back at your hearth.
  • A plot's name lives on the plot in the sim (name, with namedBy and namedDay, absent until set, packages/sim/src/plot-names.ts): name_plot {px, py, name} from its owner or a co-owner, 1 to 40 characters, once a world day with two free renames a plot for a change the same day, and null clears it. The server cleans and filters it as the plot_name surface before logging it, sends plot_named marked untrusted, and holds a name back while staff hold its writer's words back. A report on a plot's owner shows staff its name, and staff take names down with a server-only clear_plot_name. release drops the record, name and all. The map draws names over plots near you, and the cards, profiles, plot photos, and the 3D plot view's title show them (decision 0121).
  • visit {px, py} jumps you to the edge of someone else's plot, at its door: the sim's planner (visitTile in packages/sim/src/visit.ts) picks a free tile on the outermost ring with the shortest walk to the plot's hearth, the server logs that tile with the command, and the sim checks it, so replay never runs the planner (decision 0091).
  • Residents have a color, a shape, and an optional public note (untrusted text, 80 characters). A look adds a theme, a pattern, up to five things to wear (a hat, a top, an accessory, a bottom, and feet), a pattern and color per garment (wearStyle), a hair style and color (hair, hairColor), and optionally their own uploaded art; look fields are absent until set, so older logs hash unchanged (decision 0029, decision 0053, decision 0074).
  • The world can grow beyond the first grid (RFC 0038). The server-only open_land adds an island east of the world's edge with a plot-width of water between, carrying the new width and height, the plot slots it makes claimable, and the walkable tiles that bridge the water, so the rules work none of it out for themselves (packages/sim/src/lands.ts). An island is a region of the same coordinate plane, so every stored tile key, biomeAt, move, visit and the routines are unchanged, and crossing is an ordinary walk. Water is what is left rather than anything stored: a tile in bounds, outside downtown's rectangle (state.lands.home), on no land's plots, square or streets, and not a bridge is sea, which groundOf refuses as an obstacle, settle turns down, and pickupOn leaves bare. GET /v1/world carries the same three facts as lands, and both the rules and the web read the water off them through one function (seaFrom). WorldService opens the next land when the claimable plots left fall to LANDS.openAt, a count and never a date, behind an option only the Worker sets (decision 0326, decision 0329). A land also brings streets (RFC 0046): whole plot slots in open_land's streets that are the land's ground, never claimed or built on, and walked by anyone. The server's planner lays each land as a long cross of them with the square where they cross and the bridge landing on one, and planProblem refuses any plan without streets, or where, with every plot claimed, a plot has no way out onto public ground that joins the bridge and the counter (decision 0356, decision 0357). Downtown has public ways: tiles of plots that hold no block on the ground floor (state.ways, the server-only open_way and close_way, refused in place, build and build_starter_home), counted as open ground for a plot's exit. The server plans the network by town rule (planWays in packages/server/src/way-plan.ts) and a maintainer lays it through POST /v1/admin/ways, which clears each block on it, and each wall on a line between two of its tiles, back to its owner with clear_block, rebuilds a home it cuts through with rebuild_home, and sends each owner a way_laid notice (decision 0358, decision 0359, decision 0411).
  • The Town Hall stands on six Commons tiles north of spawn (townHallTiles), and the shop on six to the south once it's open (shopTiles). Every land the town opens beyond downtown brings a square of its own with a hall and a counter laid out the same way, and those two helpers carry all of them (decision 0332). Which counter a resident is sent to is nearestCounter in packages/sim/src/doors.ts, the square of the land they stand on. Proposals are filed only at downtown's hall (townHallTile), wherever the resident stands: an island's hall is a building and takes no filings (decision 0352, decision 0353). The town's centre is pinned to downtown's own rectangle (downtown(state)), since open_land grows the world. Nothing is built on any of them. Once the server logs solid_buildings nobody walks onto them either; logs from before it replay with residents walking across. Proposals can only build in the Commons, at most 40 changes of blocks and paths, and at most 5 are open at once.

Presence

A resident is online while they have an open socket, or for 10 minutes after their last action through REST or a link. A sweep marks idle residents offline. Any accepted action (REST, link, or socket) brings them back, including after DELETE /v1/session, since tokens aren't revoked; a socket brings them back when it connects. After a restart everyone starts offline.

A signed-in GET /v1/events/{id} from someone online and standing in that live event's area counts as being here too, without logging anything, so a guest on REST stays counted at an event (decision 0080).

Once the server has logged implicit_presence (both adapters do), a REST or link action from someone offline brings them back in the same input: the sim moves them online with join's rules and emits joined before the action's events, so a check-in logs one row. Chat still logs a join first, and live sockets still send join when they connect. The idle sweep and the boot take everyone offline in one leave_idle input, with a left event each (decision 0071).

Limits

  • Bodies and socket messages: 16 KB.
  • Actions: about 10 per second per resident, burst 20. putter: once a minute and 60 a UTC day on top of that.
  • New sessions: 3 per minute per IP, burst 20. Each session adds a resident to the log permanently, so this limit is much tighter. Behind a reverse proxy, set TERRAKIN_TRUSTED_PROXIES to the number of proxies so limits key on the real client IP from X-Forwarded-For.
  • New residents: 30 an hour for the whole world, burst 60, whatever door or IP they come from (JOIN_LIMITS, RFC 0055). An invite from a resident with a hearth who has lived here at least a day takes from a share of its own, 10 an hour, burst 20. A join past it is rate_limited with the minutes to wait, and the web keeps the person a guest. The World object keeps both buckets in memory, like the per-IP ones.
  • Rate-limit buckets and idle-tracking entries are pruned every minute, so memory tracks the active population.
  • Names: letters, numbers, and underscores, up to 24 (decision 0379). Pet names: 1 to 20. Notes: up to 80. Chat: 1 to 280.

Not built yet

Postgres, horizontal scaling, account-bound identity, and everything after Phase 1. Optional wallets come after that. See plans/.