Additions.
Everything that shipped in the last round. Not a roadmap — a changelog with usage. Each one is either fully live, gated by an environment variable, or a build artifact that regenerates itself.
1. TF.rng — the seeded PRNG
The function that makes runs reproducible
A deterministic pseudo-random number generator seeded from a string. Same seed produces the same stream on any browser, any device, any day. Lives in app.js, available to every page and every work on the site.
How to use it
Call TF.rng(seedString) to get a function. The function returns a float in [0, 1) when called. It also has three helpers: .int(maxExclusive), .pick(array), and .shuffle(array).
That's the whole API. Three helper functions and one seed string. The .seed property on the returned function echoes the seed back, so a work can display it to the player.
What it enables
Two things that were impossible before:
- Reproducible runs. Share a seed, everyone plays the same dungeon, the same wave pattern, the same loot table. The seed is a string; it fits in a Discord message.
- The daily seed.
TF.dailySeed()returnsTF-DAILY-YYYYMMDDfor today (UTC). Every work that calls it gets the same seed on the same day. Combined with the daily leaderboard below, that's the Wordle primitive.
2. The daily seed leaderboard
One leaderboard per work per day
Any work that declares the daily_seed capability gets its own scoreboard that resets every day at UTC midnight. Players' scores are stored in daily_scores, keyed by day, work, and a hashed player code. Same code produces the same row within a day; a code can't be reused across days.
How to declare it
Add daily_seed to the capabilities block in your sidecar:
That's the whole declaration. The site's work template reads it and enables the daily board on the work's page automatically. It also enables the TF.dailySubmit client call from the work's iframe.
The endpoints
The seed field is validated against the server's today's seed. If it doesn't match, the submission is rejected with bad_seed — that prevents a player from claiming a score for a previous day by mistake.
How a work submits a score
From inside the iframe, the work calls TF.dailySubmit(). The parent page handles the fetch and stores the player's code in sessionStorage automatically (so the same code is used for a full session).
3. Async matches
Turn-based multiplayer, no websocket
A match is a shared state blob, a move log, and two player codes. One player creates; the other joins with a match code. Either player can read the current state and submit a move. The server flips whose turn it is and appends to the log. No persistent connection, no live updates, no new infrastructure.
How to declare it
The endpoints
How a work uses it
The move endpoint is opinionated about a few things
- Turn enforcement is server-side. A host can only move on turn 0; a guest only on turn 1. Submitting out of turn gets
not_your_turn. - Row-level locking. Two simultaneous moves can't race — the endpoint uses
SELECT ... FOR UPDATEinside a transaction. - State is JSON. The server stores it opaquely. It does not validate the shape, does not understand the game, does not judge the move.
- The move log is public to both players. Both players see the full history of actions. If one cheats, the other has the record.
4. Channel One — the broadcast
A schedule page that rotates through works
broadcast.html reads data/schedule.json, figures out what's "on" right now, and iframes that work. Below the stage, it shows the day's schedule and highlights the current slot. Refreshes every 60 seconds.
The schedule file
The schedule is a single JSON file at data/schedule.json. Edit it by hand; the page reads whatever's there.
The times are UTC. A slot with "end": "23:59" covers the last minute of the day. Overlaps aren't validated — if two slots cover the same minute, whichever comes first in the array wins.
5. catalog.json — the machine-readable catalog
Every published work, its files, and its hashes
A single JSON file at https://tinyfiles.tv/data/catalog.json, regenerated on every catalog_regen job. Contains every published work (including graveyard entries, flagged), every file's SHA-256, and the aggregate rating if there are votes.
The shape
What you can build with it
- A Discord bot. Post new works to a channel by diffing consecutive snapshots.
- An RSS-to-Mastodon bridge. You already have
rss.xml; this is the same data in a form a bot can process without scraping. - An alternative frontend. A minimalist catalog page, a randomizer, a mobile app.
- A checksums verifier. The file already contains every SHA-256, so anyone can verify a download against the record.
6. The rater histogram
The distribution of votes, not just the average
GET /api/rate/{creator}/{slug} now returns a 10-bucket histogram alongside the average. Bucket 0 counts scores 0–9, bucket 9 counts 90–100. The work page renders it as a small bar chart above the rater input box.
The response
That's a work with seven votes: one in the 50s, three in the 60s, two in the 70s, one in the 80s. The average is 82.4 — but the histogram shows the distribution is left-skewed within that range. A piece that scored [0,0,0,0,0,3,4,0,0,0] and a piece that scored [0,0,0,0,0,0,0,0,0,7] would have very different averages but the histogram would tell a more interesting story — the first is "adequate but uninspired," the second is "universally loved."
7. The screenshot job (gated)
A worker that captures a work at multiple viewports
jobs.py now handles a screenshot job type. Given a published work, it spawns headless Chrome four times — 320, 768, 1366, and 1920 pixels wide — and saves a PNG per viewport to public/thumbnails/{creator}/{slug}/. Off by default; enable with a single environment variable once chromium is installed.
To enable
What's still on the list
Seven additions shipped. The stretch goals page now lists what remains: ghost replays, the websocket relay for live rooms, render-to-video, shared world state, the asset library, server-side media processing, and the retention / frame-time analytics. Each of those still needs real work or new infrastructure.
The pattern across these additions is: every one reuses something that was already there. TF.rng uses the existing app shell. The daily leaderboard uses the existing MySQL. Async matches use the existing transactions. Broadcast is a JSON file and a page. catalog.json extends the existing catalog job. The histogram reads existing votes. The screenshot job reuses the existing jobs.py worker. None of them required a new server process, a new dependency, or a change to the operational profile of the box.
That's the useful pattern to remember when the next round of stretch goals comes up: prefer additions that reuse the pipeline you already have. The websocket relay would break that pattern; async matches didn't.
See what's still on the list → See what's live today →