🐾 Latch ?

Guide β€Ί Modules

Playsets and events

A playset is a file of content β€” training, prompts, mantras, wheel segments, habits, trance scores and badges β€” written by someone else, installed with one upload, removed with one tap. An event is a playset with a window, a sign, and badges you can only earn inside it. This page is the format, the rules, and how to make your own.

🎁 A playset is a box of things for the game: training to assign, prompts for the Handler to ask, mantras and affirmations in a voice that is not the default one, wheel segments, habits, a trance score, badges to earn. It is one file, made by a person, installed on /playsets, and taken out again exactly as it went in.

An event is a playset with a calendar: it starts, it ends, it has a sign you wear while it runs, badges that exist only inside it, and a Handler that talks about it. Locktober is the first one.

Playsets are not packs

A pack in Latch means a group of pups on separate instances who can see each other's clocks β€” the federation proposal on Packs. A playset is content. The two words are deliberately different so that "install a playset" and "join a pack" never mean the same thing.

The one rule

πŸ”΄ A playset is data. It is never code.

A module is a Python package the app imports; a playset is a TOML file the app reads, checks against a fixed format, and copies into tables that already exist. Nothing in a playset runs. Nothing in a playset can touch the clock, write to the log, reach the network, or read your reflections. A playset that asks for any of that is refused, and the refusal says why.

That rule is what makes it safe to install one written by a stranger β€” and it is what makes sharing yours cheap, because a file that cannot do anything does not need to be trusted.

What a playset can carry

Table What it adds Lands in
[[tasks]] Training β€” a title, what to do, minutes on or off the clock, XP, whether a photo is required, how long it is due in the training library
[[prompts]] Lines for the Handler: reflection questions, checkin questions, mantras, affirmations, meditation scripts the prompt library
[[segments]] Wheel segments β€” a label, a weight, an effect (add, remove, freeze, task, nothing) and its minutes the wheel
[[activities]] Habits β€” a named, coloured thing to do, with a rhythm and a target the activities list
[[scores]] A trance score, in the same shape as the built-in library, checked by the same validator the trance library
[[badges]] Badges, each with a rule from the fixed menu below the badge catalogue
[event] A window, a sign, a Handler line, and day-by-day training the dashboard and the Handler
art/*.png Badge art and an event banner shown on /badges and the cards
README.md Whatever the author wants to say shown on the playset's page

Everything is optional except the manifest. A playset with one mantra in it is a playset.

Installing, removing, switching off

/playsets lists what is installed. Upload a .zip (or a bare playset.toml) and it is checked, then copied in. Every row it adds is tagged with the playset, so:

  • Remove takes back exactly what the playset added β€” its tasks, prompts, segments, habits, scores and rules β€” and nothing you wrote yourself. πŸ”΄ Badges you earned stay. A badge is a record of what you did, with its title and evidence frozen at the moment it was earned; the rule that awarded it leaving does not un-do the doing. Training you actually did from a playset stays in your history too; the task row is kept, disabled, and marked.
  • Switch off hides everything the playset added without removing it. Switch it back on and it is all there.
  • Install again at a newer version replaces the content and keeps the badges and history.

⚠️ The app never fetches a playset. The core makes no outbound calls, and a playset downloader would be one. You bring the file β€” from the catalogue, from a friend, from your own folder. A module that fetched one could exist and would have to say so, like every module that reaches the network.

Events

An event playset carries an [event] table:

[event]
name    = "Locktober"
starts  = 2026-10-01          # local dates, on your own clock
ends    = 2026-10-31          # inclusive
sign    = "πŸŽƒ"                # worn in the header and on the cards while you are in
accent  = "#ff7a00"
line    = "Day {day} of {name}."   # the Handler's opener, every day of the window
voice   = "replace"           # or "mix" β€” see below

You join an event from its card on the dashboard, and you can leave it. Joining is an event in your log (event_join), leaving is another, so the record of what you took part in is as honest as everything else here. Nothing joins you automatically.

While an event is running and you are in it:

  • Its sign sits in the header next to the clock, its card leads the dashboard with the day count, and every share card and stamped photo can carry event_name and event_day (LOCKTOBER Β· DAY 12/31). That is the obvious mark of taking part β€” on the things you show.
  • Its prompts take the Handler's voice. With voice = "replace" (the default), any kind the event has lines for β€” mantras, affirmations, reflection questions β€” is drawn only from the event's lines for the whole window; a kind it has no lines for falls back to your library. voice = "mix" blends them instead. The Handler's daily line opens with the event's line.
  • Its training is in the library, and [[event.days]] can name one task per day: on the first tick of that local day it is assigned, due at midnight, once. A day you were not in the event is not assigned later.
  • Its wheel segments, habits and score are live.
  • Its badges are earned by your log inside the window (below).

When the window closes, the event's content leaves the pools but stays installed β€” the badges point at it β€” until you remove it. A 502 from a Halloween mantra on the 1st of November is not a bug; the event ended.

Badges from a playset

A playset does not get to write a predicate. core/badges.py is deliberate about that: every badge needs its own sentence and its own evidence, and a rule language that could express the engine's real rules (favourite toy of a closed month, ties share it) would be a programming language. What a playset gets is a menu β€” six templates, each of which writes its own evidence string:

rule.kind Awarded when Fields
joined you join the event β€”
count n events of event (an event kind: task_verified, spin, reflection, photo, mantra…) β€” inside the window when the playset is an event, lifetime otherwise event, n, module (optional)
days n distinct days with at least one event of that kind β€” same window rule event, n, module (optional)
locked_days n distinct days on which a lock was running at any moment β€” same window rule n
finished πŸ”΄ only after the window has closed: locked on at least locked_days days of it, and β€” if given β€” reflect_days days with a reflection locked_days, reflect_days (optional); event playsets only
granted the moment the playset is installed β€” an issued badge: nothing in your log earns it, a person gave it (a contributor's mark, a supporter's thanks). Plain playsets only β€”
[[badges]]
slug  = "locktober-halfway"
title = "Halfway"
icon  = "πŸŒ—"
art   = "art/halfway.png"        # optional; the emoji is the fallback
xp    = 20
blurb = "Locked on sixteen days of the month."
rule  = { kind = "locked_days", n = 16 }

granted is the odd one out: it is not earned, it is given. The catalogue uses it for the badges a maintainer hands to people who made playsets β€” a tiny personal playset holding one granted badge is the whole mechanism, and until papers (M7) can sign it, it is honestly the author's word. joined, count, days and locked_days award themselves the moment they become true, so a milestone lands on the day you hit it. finished obeys the engine's period rule β€” a badge that says you did the whole month is decided when the month is over, not on the 20th, for the same reason a monthly superlative is. Every badge from an event carries the event as its period, so next year's Locktober is a different trophy, not a re-award of this one.

The manifest

spec = 1                                # the playset format version; a newer one is refused

[playset]
slug     = "locktober-2026"             # a-z, 0-9, hyphens; 2–40 chars; unique on an instance
title    = "Locktober 2026"
version  = "1.0.0"
author   = "Lockedtrainee"              # a handle, never a legal name
license  = "CC-BY-SA-4.0"               # an SPDX id, or "all-rights-reserved"
summary  = "Thirty-one days. One cage. The Handler is watching."
nsfw     = true
latch    = ">=0.12"                     # the oldest app version it is written for
tags     = ["event", "october"]
homepage = "https://…"                  # optional; a link the page shows, never a fetch

Then any of the tables above. Field references:

  • tasks β€” title, description, tags (list), reward_minutes, penalty_minutes, xp, photo (bool), due_hours.
  • prompts β€” kind, text, weight (default 1), tags (list).
  • segments β€” label, weight, effect, minutes (ignored for task and nothing).
  • activities β€” slug, title, icon, color, cadence (none, daily, weekly_n, every_n_days), target_n, xp, emits (bool β€” true makes it a habit you log).
  • scores β€” the trance score shape exactly as in Trance; or file = "scores/x.json".
  • badges β€” as above.
  • event.days β€” day (1-based), task (the title of one of this playset's tasks).

πŸ”΄ Unknown keys are refused, at every level. A playset written for a newer Latch fails loudly rather than installing three-quarters of itself and looking fine. The refusal names the key. The same validator is on the command line, so an author sees the exact sentence before anyone else does:

python -m latch.playsets check locktober-2026.zip

Making one

  1. Start from the smallest thing that is true: one [playset] table and one [[prompts]]. Run check. Grow it.
  2. Keep one voice. A playset is a character as much as a list β€” a Handler who talks like the author. Read every line aloud.
  3. Test it on your own instance for a week before anyone else sees it. Numbers that felt right in the editor (a 12-hour penalty, a thirty-day badge) feel different on the clock.
  4. If it has badges, give them art if you can β€” a 512Γ—512 PNG with transparency, one idea each, under 300 KB. The emoji works; the art is what people show off.
  5. Write the README for a stranger: what it is, what it assumes, what it will do to their clock.
  6. Zip it with playset.toml at the root. Name the file <slug>-<version>.zip.

Hard limits: 2 MB of TOML, 16 files, 300 KB per PNG, 8 MB in total. Files outside art/, scores/ and README.md are refused.

Sharing one, and the catalogue

Your playset is your file; give it to whoever you like. The project keeps a catalogue β€” a repository on the public forge with one directory per playset, checked by the same validator, with an index the site renders. To have yours listed:

  • Open a pull request adding your directory, or an issue with the zip attached.
  • It must pass check, carry a license the author can actually grant (your own words, your own art), say nsfw honestly, and contain nothing about a real person.
  • The maintainer reads it. Curation is a person, not a rule engine, and a listing is a recommendation: this one is good.

An unsigned playset installs and renders as unsigned. Signing β€” so a playset is verifiably its author's β€” is part of the Identity proposal and uses the same papers; the manifest keeps a place for it.

Nothing about a playset's origin is the app's business. Where you got it, whether you paid for it, who you are to the author β€” none of that is checked, because a licence check in an AGPL app is DRM, and it would be patched out in an afternoon. What the app checks is that the file is a playset and cannot hurt you.

What is not here yet

  • Audio. A playset cannot yet carry a recorded track for the trance module β€” a hypno file you made, a guided sit in your own voice. That is spec 2: the format, the player and the duty-of-care questions (a file the app cannot inspect, playing for an hour to someone lying still) are written up on the issue. Scores β€” the synthesised kind β€” travel today.
  • A downloader. A module that reads the catalogue index and installs from it would be convenient and would be the first thing in the core's orbit to make a network call about content. If it is built it is a module, it declares itself, and it is off by default.
  • Signing. See above.
  • Art from the forge. The badge forge on the maintainer's machine makes the drawn chassis and the generated icon; playset art today is whatever PNG the author ships.

This page is docs/playsets.md in the repo. Spotted a mistake? Tell us how to report it β†’