What this page is. The last round added seven things. Each one is described below with: what it is, how to use it, and what it doesn't do. The "what it doesn't do" is the important part — a feature description without limits is marketing. This page is the usage manual for the features that shipped, written before the ink is dry.

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.

live client-side only zero backend

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).

// basic — a stream of floats var rand = TF.rng('any string you like'); rand(); // 0.712... (first value) rand(); // 0.284... (second value, different) // same seed → same values, always var rand2 = TF.rng('any string you like'); rand2() === rand(); // false — rand() already advanced // but this works: var a = TF.rng('test'), b = TF.rng('test'); a() === b(); // true (both at first value) // the three helpers rand.int(10); // integer 0–9 rand.pick(['a','b','c']); // one element rand.shuffle([1,2,3,4,5]); // deterministic permutation // today's seed — deterministic, generated locally TF.dailySeed(); // → "TF-DAILY-20261113" (UTC)

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() returns TF-DAILY-YYYYMMDD for 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.

What it doesn't do. It doesn't prevent cheating by itself. A player can edit their own score in the browser; the seed only ensures the same game is played. Verified scores need ghost replays, which are still on the stretch goals list. It also doesn't replace Math.random() for cosmetic randomness — rain droplets, particle positions, that sort of thing — where reproducibility isn't wanted.

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.

live backend + bridge opt-in per work

How to declare it

Add daily_seed to the capabilities block in your sidecar:

{ "id": "maze-walker", "title": "Maze Walker", "description": "...", "category": "games", "license": "MIT", "capabilities": { "daily_seed": true } }

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

// read today's top 10 GET /api/daily/{creator}/{slug} // → { ok: true, day: "2026-11-13", seed: "TF-DAILY-20261113", // top: [{rank:1, score:18420}, ...] } // submit a score POST /api/daily/{creator}/{slug} Content-Type: application/json { "code": "ABCD-EFGH-JKLM-NPQR", "seed": "TF-DAILY-20261113", "score": 18420 } // → { ok: true, rank: 3, score: 18420, top: [...] }

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).

// inside the sandboxed work var result = await TF.dailySubmit({ seed: TF.dailySeed(), score: 18420 }); // → { data: { ok: true, rank: 3, top: [...] } }

What it doesn't do. It doesn't verify the score. That's ghost replays, still on the list. It doesn't have a leaderboard UI for players to browse past days. It doesn't persist across days — the board is a fresh slate at 00:00 UTC. It also doesn't send notifications; a player has to reload the page to see an updated board.

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.

live backend + bridge opt-in per work

How to declare it

{ "id": "chess-by-mail", "capabilities": { "match": true } }

The endpoints

// create a match — you become the host POST /api/match/{creator}/{slug} { "code": "ABCD-EFGH-JKLM-NPQR", // your player code "state": { "board": [], "turn": 0 } } // → { match_code: "TF-8X2P-99KM", status: "waiting", turn: 0 } // join an existing match POST /api/match/{creator}/{slug}/join { "match_code": "TF-8X2P-99KM", "code": "WXYZ-2345-6789-ABCD" } // second player's code // → { status: "active", turn: 0, state: {...} } // submit a move POST /api/match/{creator}/{slug}/move { "match_code": "TF-8X2P-99KM", "code": "ABCD-EFGH-JKLM-NPQR", "action": "e2-e4", "state": { "board": ["e2-e4"], "turn": 1 } } // → { turn: 1, state: {...} } // read current state GET /api/match/{creator}/{slug}/TF-8X2P-99KM?code=ABCD-EFGH-JKLM-NPQR // → { status, turn, you_are: "host"|"guest", state, actions: [...] }

How a work uses it

// create var m = await TF.matchCreate({ state: { board: [], turn: 0 } }); // → { data: { match_code: "TF-8X2P-99KM", ... }, player_code: "..." } // join someone else's var j = await TF.matchJoin({ match_code: "TF-8X2P-99KM" }); // submit a move var m2 = await TF.matchMove({ match_code: m.data.match_code, action: "e2-e4", state: { board: ["e2-e4"], turn: 1 } }); // read (poll every few seconds for the opponent's move) var r = await TF.matchRead({ match_code: m.data.match_code });

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 UPDATE inside 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.

What it doesn't do. No live updates — the work has to poll TF.matchRead() to see the opponent's move. No chat — the 64 preset phrases rule still applies, but you'd route them through your own move actions. No matchmaking — you share the match code out of band. No abandonment detection — a host who leaves stays "waiting" forever. No clock or timeout.

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.

live static page + JSON hand-curated schedule

The schedule file

The schedule is a single JSON file at data/schedule.json. Edit it by hand; the page reads whatever's there.

{ "channel": "tinyfiles.tv / channel one", "timezone": "UTC", "shows": [ { "start": "00:00", "end": "06:00", "slug": "retrogames10000/unravel", "title": "Unravel — Folksy" }, { "start": "06:00", "end": "12:00", "slug": "retrogames10000/placate", "title": "PLACATE-7B" }, { "start": "12:00", "end": "18:00", "slug": "retrogames10000/metagrid", "title": "Meta-Grid DevLog" }, { "start": "18:00", "end": "23:59", "slug": "retrogames10000/unravel", "title": "Unravel — Folksy" } ] }

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.

The one hand-maintained artifact. Every other page on the site is generated by catalog.py. schedule.json is edited by hand. That's on purpose — the schedule is a curatorial decision, not a build artifact. Two works every six hours is a reasonable day's television.

What it doesn't do. It doesn't stream video; it iframes a work. It doesn't have a persistent player position — reload the page and the work restarts. It doesn't know or care who's watching. It doesn't prevent a viewer from opening a work directly instead of waiting for its slot.

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.

build artifact public regenerates on publish

The shape

{ "schema": 1, "generated": "2026-11-13T14:22:00Z", "works": [ { "id": "uuid...", "creator": "retrogames10000", "slug": "unravel", "title": "Unravel — Folksy", "category": "music", "description": "...", "tags": ["Web Audio", "Generative"], "badges": ["Music"], "license": "CC BY-NC", "size": 63488, "inGraveyard": false, "votes": 7, "average": 82.4, "path": "/retrogames10000/unravel/", "files": [ { "filename": "index.html", "role": "entry", "size": 63488, "sha256": "a1b2c3..." } ] } ] }

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.

What it doesn't do. It's a snapshot, not a live feed. It regenerates when a work is published or graveyarded. If you need realtime updates, poll the file and diff. It doesn't contain any internal IDs (like the raw UUID for job rows) — just the public identifiers.

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.

live reads existing data aggregate only

The response

GET /api/rate/retrogames10000/unravel { "ok": true, "votes": 7, "average": 82.4, "histogram": [0, 0, 0, 0, 0, 1, 3, 2, 1, 0] }

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."

What it doesn't do. It doesn't reveal who voted. It doesn't tell you which "10-point band" each individual rater fell into — it's an aggregate. It doesn't show distribution over time (you'd need a date column, which rater_votes has). It doesn't move the graveyard threshold — that's still computed from the average, not the histogram.

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.

available, disabled by default needs chromium manual job type

To enable

# 1. Install chromium on the box sudo apt install -y chromium-browser # 2. Enable in config.env echo 'TF_SCREENSHOTS_ENABLED=true' | sudo tee -a /etc/tinyfiles/config.env sudo systemctl restart tinyfiles-jobs # 3. Enqueue a screenshot job for any published work mysql -u tinyfiles -p tinyfiles -e " INSERT INTO jobs (id, type, payload_json) VALUES ( UUID(), 'screenshot', JSON_OBJECT( 'creator_slug', 'retrogames10000', 'slug', 'unravel' ) );" # 4. Check journalctl and the output directory journalctl -u tinyfiles-jobs -f ls /opt/tinyfiles/public/thumbnails/retrogames10000/unravel/

Memory-aware gating. Headless Chrome is the hungriest process on any box. On a low-budget VPS with a model or MySQL or the PHP workers already running, one Chrome instance can spike to ~500 MB. That's why the job is off by default and why the queue processes one screenshot at a time. Turn it on when you've verified memory headroom; turn it off if the box gets tight.

What it doesn't do. It doesn't auto-wire the screenshots into thumbnails on the card grid — that's still auto-thumbnails, still on the list. It doesn't capture video. It doesn't wait for a work to "finish rendering" — it uses a fixed --virtual-time-budget=6000 which is enough for most canvas works but not enough for slow-loading ones. Bump the budget per-work if a specific work needs it.

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 →