Latch

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.

On this page

๐ŸŽ 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 whose boxes keep in touch through a den โ€” see 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 shown on /badges
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:

โš ๏ธ 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

line may use {day}, {days} and {name} and nothing else โ€” no format specs, no conversions, no other names ({{ and }} print a literal brace). Anything more is refused when the playset is checked, and a line that slipped past an older Latch falls back to Day {day} of {name}. rather than being run.

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:

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.

Sharing an event

Sharing comes free with the [event] table โ€” there are no share keys to write. On a share card, everything about the event is derived from the fields above:

A playset written for an older Latch gets all of this with no change. โš ๏ธ The event is read from the playset as it is now: switch it off or remove it and the stamp and the event words leave every card rendered afterwards, old photos included.

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.

Who else sees one (packs, the chat bot) is decided by the rule, not the title: joined and granted need only Your papers; locked_days needs the clock; a count or days rule needs the permission for what it counts โ€” spin the clock, task_verified, task_failed, reflection, checkin or mantra the duties, lock/unlock the lock status โ€” and one counting any other kind is shown to nobody but you. A finished badge says what was reached (locked on 25 of 31 days, reflected on 20), so it needs the permission for every number its rule states: its locked_days the clock, its reflect_days the duties too. Pack-mates can never hold the clock or the duties, so they see an event's joined badge, and a count of locks only if they hold the lock status โ€” never a badge that states the clock or the duties, so not its finished one. The chat room is stricter still โ€” it gets today's duties only as done or not, and the clock only as time left โ€” so it names joined, granted and a count of locks, locked_days and a finished badge without reflect_days only with Show the clock, and never a finished badge with reflect_days or a count of training, reflections, check-ins or spins.

A badge keeps the rule it was earned under. Installing a newer version replaces the rules and keeps the badges, so each earned badge is judged by the stricter of the two โ€” the rule it was earned on and the one in the version you have now: an upgrade can hide a badge from others, and never shows one the older rule kept from them.

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:

๐Ÿ”ด 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:

An unsigned playset installs and renders as unsigned. Signing โ€” so a playset is verifiably its author's โ€” would use the Pup ID's papers; it is not built yet, and 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


This page also ships inside the app, at /guide โ€” so your own instance always serves the guide for the version you are running, with the internet unplugged. Get Latch ยท Something wrong here? Tell me.