0.20.0 RELEASE NOTES
Fizlet 0.20.0
- Gallery: the 7 kept pieces, rebuilt by hand as micro games (owner approved, 2026-10-30). Each has its own stage (
stage: "own",site/gallery/stages/<id>.html) and story; page text moves only where the game hits it.- #004 Sparky's workshop (pink sparks): a welding robot strikes an arc where you tap; sparks bounce, scorch and fade, and one landing on the firework's fuse may set it off.
- #005 The rainy neon street (neon rain): taps raise the storm; drops short the real FIZLET sign only where they hit it, and the third short blows it.
- #020 POW! BAM! ZAP!: click the Glitch to land hits with knockback; miss and it hits back; KO it and the next one comes back tougher.
- #021 Here comes the rush (speed lines): a rocket bowls over the real heading's letters, the wind leans the ones it passes, STRIKE for a whole line, and the "!" is a heavy pin.
- #022 Attack of the Halftone: each click prints a plate as a halftone dot wave; the cover is a surprise until the black plate lands; about one sheet in four is a MISPRINT.
- #023 The night patrol (red cape): the gust swings each real pennant only as he passes it; a tap dashes him into the city (bonks, waking windows, a leaking water tower, a cross moon).
- #026 The landing (hero lands): a three-point landing on its own landing page, a crater that fades, a shockwave that knocks only the letters and buttons it reaches, and a cat that may end up on his head.
- Posters are drawn from the scenes themselves (
scripts/gallery-posters.mjs,scripts/pixel-posters.mjs). Their frames-per-second and allocation lines are not re-measured yet, so the cards leave them out.
- Fix: the heroes page's Prev key is served disabled (the row starts on the first card), not disabled only once the carousel's IntersectionObserver runs. The phone key check could list it as enabled and then tap a disabled key (a flake on main too); it also waits for the centre card.
- Status flags on every brick; Fizlet launches as a 0.x public beta. One source,
site/bricks.mjs:STATUS(COMPLETE "done, and always getting smaller and faster", BETA, EXPERIMENTAL, ROADMAP, DEPRECATED, each with written criteria) and per brickstatus,since,tests,used(released games and pages that ship it) andwhy(what keeps a BETA brick from COMPLETE);PLANNEDlists the ROADMAP bricks with phase, purpose, size target and dependencies. At 0.19.0: 14 COMPLETE, 9 BETA (grid, camera, hud, match, presence, steer, coach, timeline, board-page: tested only in the browser, no allocation test yet). A COMPLETE brick'susednames the releases that ship it (rules: DOM Battlefield 1.0.0, Blitz 0.8.0+, Null Invaders and Sherman Bytes 1.0). The README table gains a Status column and a Status flags section; brick pages show the flag as a Board UI status pill with its evidence; the bricks index adds the pills, the criteria and the planned bricks; the home page says "public beta" under the byline.tests/status.test.mjschecks the evidence (test files exist,sinceis a release, COMPLETE names a shipped user, BETA a why, planned bricks aren't exported) and that the README agrees. - README sizes come from the build.
npm run readme:sizesrewrites the "What's in the box" size column and the kernel's measured bytes fromscripts/sizes.mjs(the CI budget's measure). It is a release step, not a PR gate:land.py bumpruns it, the release workflow fails on a stale README (--check), andtests/status.test.mjsproves the generator on a fixture. Corrected at 0.19.0: kernel 3.4 KB (3,439 B), slot 1.8, board-page 4.0, fx 2.7 (the whole module), camera 1.4, presence/check 4.8, arcade 17.5 / 21.5, ui 2.8 + 4.8 KB css. - Website: the roadmap runs E0 to E16, ticked only with evidence. E1, E2 (re-scoped: the 22 bricks shipped; layout, view and actor bricks move to E10/E11) and E4 (re-scoped: two players on the live site; Battlefield matches and AI players move to E12) are done; E0, E3, E5–E8 list what's left; E7 (page interactions) and E8 (the game kit) are new; E9–E16 are planned with their bricks. The games section and README say what's true now: every DOM VIRUS game runs on Fizlet.
- Website: the roadmap has one source,
site/roadmap.mjs. The home page's section 09 renders it (phase range, launch scope, pills and boxes all from the data; no phase written inpages/index.mjs), and a planned phase lists its ROADMAP bricks fromsite/bricks.mjsPLANNEDwith their size targets. Statuses use the engine ROADMAP's words (done, partly, nearly, in progress, started, planned), so the engine side can check that both say the same thing.tests/roadmap.test.mjschecks phases in order, the launch scope (E0–E8) vs planned (E9–E16), ticks that agree with each status, every planned brick under its phase, and that the page shows exactly the source. - fizlet/arcade's skirmish preset runs its flow on
fizlet/match(G15). One owner of the phase: the countdown, pause / surrender and result are match's, on a small engine of its own that lives as long as the overlay (Recall destroys it, and its ledger takes the overlay back). The shell keeps the keys (P, Escape, the bar's pause key) and asks:pause(),resume()and the end go to the HUD's newaskwhen it has one, and the HUD answers throughHudRun.go(pause, resume, won, lost, restart, exit). The countdown is on the flow's clock (was three 600 ms wall-time timers), the game's engine waits for it; the result sticker outlives the game's engine (whose end gives the page back) until REMATCH, EXIT or Recall; a hidden tab pauses with BATTLE PAUSED; surrender waves the white flag (WHITE FLAG / GAME OVER). The preset's own overlay code is gone. Card games (hudleft out orarcadeHud()) behave as before: tests/arcade-hud.mjs still checks the card byte for byte. Sizes: arcade 23,990 B gzip (+2,015 B: match in the bundle, less the overlays it replaced; budget 22,016 → 24,064); the card build 18,033 B (+62 B: the flow contract; budget 17,984 → 18,048). - skirmishHud: the keys a game asks for.
nav: ["auto", "next", "restart"]adds Auto fire (withfire), Next gate and Restart to the bar, as on the card;headertakesfold(hides the title and the text keys). Default: none, so existing bars are unchanged. On phones the bar's keys wrap to another row instead of pushing the exit key off screen. - fizlet/match:
on(phase)andhide, and three contract fixes (brick audit P-items), each tested and each test failing on the old code:- a settling engine stays awake through the countdown and play (
busy()in step); before, the countdown and clock froze once the engine settled; - no layout read on a phase change or countdown beat: the sticker slams by re-insertion instead of the kit's
slam()(anoffsetWidthread, a forced reflow each time); - the white flag's 480 ms wait is cleared by
destroy()(it outlived Recall), and a destroyed engine's match is inert (no transition writes past Recall). on(phase): each phase change once painted (not the first intro);hide: phases that show no overlay. fizlet/match 2,652 B gzip (+54 B).- Tests:
tests/match.test.mjs(settle,on, inert after destroy, the arcade flow's zero allocation over 1M frames, best of 3 windows,--test-concurrency=1),tests/browser.mjs(no layout reads, the slam restarts, the flag's wait cleared),tests/arcade-hud.mjs(the skirmish flow end to end, the nav and fold keys).
- a settling engine stays awake through the countdown and play (
- Fix: a sprite id that names no sprite throws instead of drawing another sprite. In
sprites(), an unknown name, an index past the end or a fraction made a cache key of 0, so once sprite 0 was bakedtile("typo")returned sprite 0's tile anddraw()silently painted nothing.tile()anddraw()now throw aRangeError("fizlet/sprite: no sprite …"), likeneeddoes for bad options. Test:tests/bricks.test.mjs("an id that names no sprite throws");drawstill allocates nothing once baked. fizlet/sprite 1,403 → 1,437 B gzip (budget 1,536). - fizlet/input: intents resolve once;
id()gives a step a number.down,hitandaxislooked an intent up withnames.indexOfon every call: a scan of the names in the step. Each intent now resolves once, at setup, in a Map keyed by its name and by its number, so a lookup is one Map read, never a scan. Newc.id("fire")returns the number for hot paths (c.hit(FIRE)in a step); a name that is no intent throws there, at setup. Names keep working everywhere. Test:tests/bricks.test.mjs"input: intents resolve once…" (handle and name read the same over 65 intents, a typo throws, 0 young GCs over 1M frames by number). Size (gzip): input 1,287 → 1,335 B; page build 7,126 → 7,161 (budget 7,168 kept); arcade 21,973 → 22,015; arcade (hudomitted) 17,967 → 18,024. The arcade budgets rise by exactly those deltas (22,058; 18,041), with the reason inscripts/sizes.mjs. - Fix: an on-screen key wakes a settled engine.
controls().press()andsteer()now callengine.wake(), as a real key or pointer already did. Afizlet/padkey is[data-fizlet-ignore], so its tap never reached input's own wake: on an engine withsettle, a tap was latched but not stepped until something else woke it. Test:tests/bricks.test.mjs("press() and steer() wake a settled engine"). fizlet/input 1,283 → 1,287 B gzip; arcade 21,970 → 21,973; arcade (hud omitted) 17,964 → 17,967; page build 7,127 → 7,130 (all in budget). - Fix: the fps cap counts its slot from the frame's start, not the end of its work. A capped engine napped
1000/fps − 4ms after its hooks finished, so a frame whose work took longer than the 4 ms slack missed its slot by a whole vsync: at 4× CPU the battlefield.domvirus.com hero (fps: 30, ~13–28 ms a frame on a 390 × 844 phone) ran at 21.6–21.9 rAF/s instead of 30. The nap is now shortened by the frame's own work time (timing[1]), never below 0. Test:tests/kernel.test.mjs("a slow frame still meets its next slot": 20 ms frames, 20 → 30 fps; the fake display's timers now count each frame's work). Kernel 3,441 → 3,443 B gzip (a negative nap is left tosetTimeout, which clamps it to 0); page build unchanged (budget 7,168). - Fix:
destroy()clears the fps cap's pending wake-up. An engine withfpswaits off the shared clock on a timer between frames;destroy()left that timer to fire once more (harmless, but it held the engine until then). It now clears it, so nothing of the engine outlivesdestroy(). Test:tests/kernel.test.mjs("destroy() clears the pending wake-up timer"). Kernel 3,439 → 3,441 B gzip; page build 7,127 → 7,128; arcade 21,970 → 21,973; arcade (hudomitted) 17,964 → 17,963. - Website: gallery numbers are given at land time, never in a PR. Parallel PRs each took "the next number" and kept claiming the same one (three PRs on #047).
scripts/gallery-number.mjsholds the rule, pure and shared with the engine's land step (fe anim number --write). Existing numbers stay; a live entry without one takes the next free number by approval date, then merge order, then id; a pending entry has none. When two entries share a number, the first to land keeps it. The build shows a live entry's coming number (so a PR builds) and fails on a shared number, naming the entries and the fix. Tests:tests/gallery-number.test.mjs. - fizlet-rooms: hardened for launch (brick audit 2, S1–S5). Needs
wrangler deploy(owner's go).- First input wins at the relay (S1). A seat's
INPUTandHASHframes must rise: a second, different input for a frame, or an older frame, is never relayed, so one peer can't split the others (fizlet/presence keeps the first one too, in its own PR). - Invite codes out of URLs and logs (S2).
POST /invitewith the code as a text body, and the WebSocket subprotocols["fizlet-rooms", "invite." + code](the101answersfizlet-rooms).?code=/?invite=still work for one release.wrangler.tomlturns invocation logs off (they record URLs); the worker logs nothing itself. Codes that travelled in URLs should be rotated after the deploy (DEPLOY.md). - Rate caps and longer codes (S3).
GATE_RATE(30): invite checks + joins per IP a minute, counted in each Worker isolate, refused with429before the KV read.JOIN_RATE(10): joins per IP a minute into one room, counted by the room before a socket is accepted. Both are free (no binding, no KV), with ceilings insrc/limits.jslike every cap. Codes are now 10 to 64 characters (was 6); the invite helper makes 15. - No room squatting (S4). Only relayed messages and seats taken keep a room alive; a lone player's refused messages and "room full" joiners no longer do, and a room not full
LOBBY_SECONDS(90) after its first seat closes with1001 room not filled(it could be held open for 30 min). - Threat model (S5):
rooms/THREAT-MODEL.md, what is protected and what isn't. CI runscd rooms && npm test. - Tests:
test/room.test.mjs(new: the Worker and the Room on platform fakes; each test fails on the old code), relay and limits tests;test:wrangleradds the lobby timeout,POST /inviteand the subprotocol invite through real workerd. Ran:npm test16/16,npm run test:wrangler✓.
- First input wins at the relay (S1). A seat's
- fizlet-rooms: rooms of up to 4 players, and
/invitesays the room size.wrangler.tomlMAX_PLAYERS2 → 4 (the code ceiling stays 8 insrc/limits.js);GET /invitenow answers{"ok":true,"players":<MAX_PLAYERS>}so a page offers only room sizes that can join (Blitz's multiplayer page shows its 2 · 3 · 4 pick from it; an older answer withoutplayersmeans two). Relay unchanged: it already seated any count. Cost: a 4-player room relays each input to three sockets instead of one (MSG_RATE per socket is unchanged). Needswrangler deploy(owner's go).test/relay.test.mjs: a four-player room (seats, fan-out to the other three, a leave told to all). - CI: one run per change, not two.
Fizlet checksran onpushandpull_request, so every push to a branch with an open PR ran the whole workflow twice. Push now runs onmainonly (tag-versionandreleaseare unchanged); branches are checked by their PR run. On 2026-10-30, 35 of the 90 runs were these duplicates: 159 of 401 wall minutes (40 %). PERF-LOG.md: every perf win, one measured line each. Generated fromtests/perf/wins.jsonbynode scripts/perf-log.mjs --write(never by hand); each row has before → after, how it was measured, the test that holds it and the PR. First entries (0.19.0, #67): the fps cap caps rAF (home at 1440, 119.6 → 28.5 rAF/s) and the demo settles (60.4 → 0 rAF/s).tests/perf-log.test.mjsfails when the log is stale or an entry has no measured number, PR or test.- Phones: taps and swipes never zoom or pan the page (owner: "for mobile all of our pages right now need to lock down double tap and pinch to zoom… the pages get all out of shape").
fizlet/touch(new brick, 310 B gzip, budget 512):lockTouch(doc, sel), one delegated, non-passive listener set that cancels iOS Safari'sgesturestart/gesturechange(its pinch ignoresuser-scalable=no) and any multi-touch start or move on the surfaces matchingsel(defaultSURFACES: slot stages, game hosts, the pad, draggable grips,[data-touch-lock]). One finger is never touched, so taps, drags and scrolling stay.nulllocks the whole page (a full-screen board). Returnsrelease().- Board UI kit (CSS):
.buiistouch-action: manipulation(no double-tap zoom) withtext-size-adjust: 100%; game hosts and[data-touch-lock]aretouch-action: none, slots (.ae-slot,[data-fizlet-slot],[data-touch-lock="y"])pan-y, alloverscroll-behavior: none. Slots staypan-y, notnone, so a hero that fills a phone never traps the page's scroll; a swipe game setsnoneon its stage. - Every arcade game, automatically: while a run is live the page is the board, so
arcadecallslockTouch(document, null)(no pinch or iOS gesture anywhere) and setstouch-action: manipulationon the page through the ledger (no double-tap zoom, scrolling stays); the engine's end releases both, so the page comes back exactly. Budgets rise by the measured cost (owner-approved):fizlet/arcade+174 B (22,089 B, budget 22,190), the card build +181 B (18,093 B, budget 18,101).tests/arcade.mjschecks the lock and its release. - fizlet.com: every page runs
lockTouchon the demo surfaces (home demo, brick demos, timeline, labs, slots); they arepan-yand the full-screen heronone. Content pages keep pinch-zoom on the copy (WCAG 1.4.4); only the double-tap zoom and the surfaces are locked. The fizz example does the same. tests/touch.mjs(Chromium, 390 × 844, touch, mobile; intest:browser): pinch, double-tap and swipe on a slot and a game host leavevisualViewport.scaleat 1 and the page 390 wide; a game host never scrolls, the page still scrolls through a slot, the copy still zooms, and the unlocked control page does zoom and scroll.test:sitechecks every page on a phone:touch-action: manipulationon the page, every demo surfacenoneorpan-y.- Status: BETA in
site/bricks.mjs(since 0.20.0; no released game ships it yet) and a README row.tests/status.test.mjsaccepts asincethat is the coming release (newer than package.json's version, with an Unreleased section) as well as a released one.
- Fix: fizlet/hud's events zero-allocation test no longer flakes; an event frame reads no DOM. The feed and banner read
el.childrenon every frame with an event, not only on the first. On the test's fake DOM each read built a new array, about 12 MB per 1M frames; a realchildrenis a cached live collection, but still a DOM call per event. The GC count of the same bytes scaled with the young generation V8 chose: 3 scavenges at ~4 MB each, 14 when a loaded machine had shrunk it to ~1 MB (--trace-gc). Ring numbers passed toline/textwere not it (whole numbers stay small integers). Rows and text nodes are now found once, on the first event. The events window measures 0 young GCs and its bound drops from 10 to 1 (the test's banner returns an array it holds); a new check countschildrenreads on later event frames (fails on main). fizlet/hud 1,320 → 1,325 B gzip; arcade 21,970 → 21,980. - Fix: fizlet/pad's
stickAtanddpadAtallocate nothing (K5).stickAtsaid "never allocates" but calledMath.hypot, which boxes its arguments: 23 young GCs per 1M fractional drags.stickAt,dpadAtand the stick drag now use fizlet/steer's exacthypot(the same bits asMath.hypot): 0. Test:tests/bricks.test.mjs"pad: stickAt and dpadAt allocate nothing" (fails on main with 23). Size (gzip): pad 1,995 → 2,073 B; arcade 21,970 → 22,037; arcade (hudomitted) 17,964 → 18,040. Each budget rises by exactly that delta (pad 2,126; arcade 22,083; card 18,060), with the reason inscripts/sizes.mjs. - Fix: fizlet/board-page's
hitcomputes its query radius with the exacthypot(K5). It usedMath.sqrt(dx * dx + dy * dy), which differs fromMath.hypotin the last bit for about 40% of inputs (the games'distis exact, so a replay keeps every bit). It now uses fizlet/steer'shypot. Test:tests/bricks.test.mjs"K5: no plain-sqrt distance in a brick" scans every source (fails on main atsrc/board-page.ts:261). Size (gzip): board-page 4,097 → 4,162 B; arcade 21,970 → 22,023; arcade (hudomitted) 17,964 → 18,024. Each budget rises by exactly that delta (board-page 4,193; arcade 22,069; card 18,044), with the reason inscripts/sizes.mjs. - Tests: no wall-time waits of 300, 500 or 1000 ms; the fx "full pool" check counts work, not milliseconds.
tests/browser.mjswaits on the launch burst being gone (was 1 s), on 30 engine frames for "one rAF for many engines" (was 500 ms) and on the offscreen / onscreen pause state (was 300 ms twice);tests/arcade-hud.mjsholds fire until a shot is out (was 500 ms);tests/labs.mjs,tests/virus.mjsandtests/layers.mjscount page frames (was 300, 500 and 300 ms). Every wait is capped.tests/bricks.test.mjs"a full pool stops the blast's work" no longer assertsms < 20: two pools on one visual seed show that ten 1e6-power blasts on a full pool draw exactly what ten tiny ones do (it fails if the work scales with power). No source change. - fizlet.com: the two stylesheets every page loads are minified.
npm run sitewritesboard-ui.cssandsite.cssthrough esbuild's CSS minifier instead of copying the readable sources: board-ui.css 8,301 → 4,884 B gzip (exactly thefizlet/ui (css)number the bricks page quotes, now what the site serves), site.css 11,530 → 9,091 B; 5,856 B gzip less on every first visit. The npm package'sdist/ui/board-ui.cssis unchanged (consumers minify). - Gallery: DOM Virus, two hand-built micro games (pending the owner's approval). A new theme,
dom-virus, with #047 Terminal virus: a worm types itself into a terminal, crawls onto the README beside it and eats its real letters (each bite rewrites a character of the page's own text through the ledger), forking a new pid every four letters. Tap a worm tokill -9it: it bursts, the blast knocks its neighbours tumbling, and every letter it chewed flies home as a packet and is written back exactly. Let it eat half the page and the kernel panics, then Recall rolls the whole page back. And #048 Petri dish: virus cells drift, collide (circle collisions, mass by size) and split under a microscope; a zap bursts a cell, and its shockwave bursts any cell caught mid-split, so a well-timed zap sets off a chain. Gold mutants split faster and take two zaps; at 40 cells a UV sweep sterilises the dish. Dark is a fluorescence scope, light a brightfield one. Bricks: kernel,fizlet/steer's exacthypot, slot; both settle to no frames, no allocation in step, a still poster under reduced motion. Live sizes (gzip, measured): terminal virus 8,076 B, petri dish 7,608 B (kernel 3,353 B of each).tests/virus.mjs(intest:browser) plays both: kills and zaps have their consequences, the README reads exactly as before once every worm is dead, both settle, a tap plays again,destroy()mid-play gives the page back byte for byte, and reduced motion loads nothing. - fizlet/presence: first input wins (security). A peer that sent two different inputs for one frame could split the other peers' sims: one ran the frame with the first input, another got the second before its step (brick audit 2, S1). Now a second INPUT or HASH for a frame a seat already sent is dropped and counted in
dropped, and the pre-filled frames beforedelaycan't be rewritten. Honest peers send one input per frame, so nothing changes for them. fizlet-rooms drops the same duplicates at the relay (its own PR). Test:tests/presence.test.mjs(a cheating seat 0, one peer stepping between the two inputs; fails on the old code). +14 B gzip (1,792 → 1,806 / 2,048), with: - fizlet/presence:
join(url, signal, protocols). The protocols go to the WebSocket handshake, so a page can hand fizlet-rooms its invite code as["fizlet-rooms", "invite." + code]instead of in the URL (URLs land in request logs). Optional;join(url)is unchanged. - Fix: fizlet/sound no longer crashes on an event or cue named like an
Object.prototypekey.o.cues[ev.type]with an event type such as"constructor"or"toString"(a timeline label can be anything) found a function,NOTES[fn]was undefined, andfor…ofthrew in render, so the engine'sonErrorstopped the game;play("toString")from JS did the same (brick audit 2, P10).play()now plays only cues that areNOTES' own keys (Object.hasOwn), so unknown cues are ignored for the synth and a customplayalike (Cuealready names the nine). An owncues.constructormapping still plays. Test:tests/sound.test.mjs(new; the old code throws "NOTES[cue] is not iterable"). Sizes: sound 1,000 → 1,015 B gzip; arcade 21,970 → 21,981; the card build 17,964 → 17,979, so its budget rises by the same 15 B (17,984 → 17,999, reason inscripts/sizes.mjs): with fizlet#103 (+7 B) it would be 2 B over the old budget. - Fix:
slot("page#slot", …)finds the Astro page engine's current slot markup. The engine now renders<figure class="ae-slot-figure"><div class="ae-slot" data-slot-target role="img">…</div><figcaption class="ae-slot__fallback">(the caption outside the role="img" box), andfindSlot()looked only forfigure.ae-slot, so the target found nothing. It now matches any.ae-slot[data-slot-target], the figure or the box; the caption outside the box stays visible while the scene runs.tests/browser.mjsmounts a scene in both shapes. fizlet/slot 1,852 B gzip (−4 B); page build 7,122 B (−5 B). - Docs: no private pointers. The README no longer links a private repo, and
rooms/DEPLOY.mdmakes invite keys with the publicinviteKey()(a one-line command) instead of a helper outside this repo. No code change. - Website: Make one (
/make/), v0. Type a prompt, get one drop-in file: adata-fizletmount and one module script with only the bricks the scene uses, its gzip size measured into its own header, by Chris Cooley, MIT. It all runs in the browser: the prompt is matched against the gallery's recipe set (site/make/make.mjs+table.json, generated by the engine, the same matcherfe animruns), the scene's template is prebuilt at build (make/templates/), and the size is measured withCompressionStream. Live preview in a sandboxed frame, Copy snippet and Download; the page shows which words matched and says unknown words are logged nowhere (no server, no tracking). Every gallery entry made from a prompt gets a Get the snippet key (the same file). Snippets settle to zero frames once still, never run under reduced motion, andel.fizlet.slot.destroy()gives the page back (tested intests/site.mjs). - Gallery: the five how-it-works labs (#042–#046) approved by the owner and live.
- Fix: a dragged scoreboard no longer jumps when its board is not the viewport.
drag()(fizlet/arcade's panel drag, also used by Battlefield Blitz) setleft/topfrom viewport coordinates and replaced the card's whole inline style, so a board under a page header, mid-page or scrolled made the card jump on the first move. It now measures the card's containing block (its offset parent's padding box) once on pointerdown and sets onlyleft,top,right,bottomandtranslate.tests/drag.mjsdrags a card in a bordered board under a header, scrolled. Arcade withhudomitted: 17,964 B gzip (+52 B; budget 17,920 → 17,984). - Gallery #051 "Big things, small packages" (micro game, new theme Dev humor; approved by the owner). A pixel dev drags a package.json crate across a Board UI arena while dependencies keep raining onto it (twelve a round, never past seven high). Tap one to cut it: it shatters into shards that bounce off the floor, the stack, the crate and the dev (
fizlet/collide), and what was above it drops with a bounce. Cut them all and the crate shrinks to the glowing Fizlet box, which flies to his hands: "Big things come in small packages", with the kernel's gzip size measured by the same build (scripts/site.mjsputs it on the figure asdata-kernel-gzip; without it the scene shows no number at all). Tap again to play again.- One world, two outputs:
site/gallery/pieces/small-packages/world.tspaints palette indices (Board UI tokens, dark and light; the page's theme picks) for the live canvas and for the two build-time posters (SVG runs). Reduced motion: the poster is the whole joke and a tap shrinks it (a checkbox and:has(), no script; the scene never loads). - Bricks: kernel, collide, slot. Live chunk 8,557 B gzip (≤ 9,728 B); settles to 0 frames, pauses off screen, Recall exact (Chromium 153).
tests/small-packages.mjs(intest:browser) plays a round to the end with real clicks.
- One world, two outputs:
- Brick audit (2026-10-30), Board UI, coach and arcade. Fixes with tests, no API change:
- Fix: the coach card's ring gives a control its own outline back.
card().show(…, target)set the target's inlineoutlineandoutline-offsetand cleared them when the ring moved on, so a control with its own inline outline lost it (Recall not exact). It now puts back what was there. A card made with an already-abortedsignalis removed at once instead of staying on the page.tests/coach.mjs. fizlet/coach 1,192 → 1,220 B gzip (budget 1,536). drag()reads layout only on a press and while dragging. Everypointermoveandpointerupover a panel (a plain hover) calledgetBoundingClientRect(), forcing a layout after the page's own writes: 11 reads for 10 hover moves and a release, now 0 (a press and each drag move read once, as before).tests/drag.mjscounts them.- The skirmish preset no longer ships the overlay CSS twice. Its
cssrepeatedBASE, which the shell already mounts before every preset's CSS (same cascade, one copy).tests/arcade-css.test.mjs. launch()reads the key's look once, not once per copy. The burst's seven copies each re-read the key's computed style after the burst layer went in (a style pass each): for a key with one child, 16getComputedStylecalls, now 4. Same look on every copy (tests/browser.mjs).press()'s doc comment sat onreadouts(): moved back topress().- Sizes: fizlet/arcade 21,970 → 21,975 B, with
hudomitted 17,964 → 17,971 B, fizlet/ui (js) 2,884 → 2,893 B gzip (all inside budget).
- Fix: the coach card's ring gives a control its own outline back.
- Brick audit (2026-10-30), timeline, presence and camera. Fixes with tests, no API change:
- Fix: timeline's
repeatmust be a whole number ≥ 0 (or Infinity).repeat: -1passed the setup check and looped forever (it took the Infinity path);repeat: 0.5ended mid-play. Both now throw aRangeErrorat setup, like every other bad option.tests/timeline.test.mjs. fizlet/timeline 2,050 → 2,060 B gzip (budget 2,304). - Fix: presence refuses a seat outside 0..players-1. A negative or fractional
slot(or more than 32 players: seats are bits inlost) never stored its own input, so the sim stalled for good without a word. It now throws aRangeErrorat setup, likeslot ≥ playersdid. - presence's WebSocket transport allocates nothing per message.
sendcopied each 12-byte message (slice) beforeWebSocket.send, which copies it again, so every sim step allocated an array: 22 young GCs per 400k sends, now 0.tests/presence.test.mjs(fake socket, best of 3). fizlet/presence 1,792 → 1,818 B gzip (budget 2,048). tests/camera.mjswaits on frames until the engine settles instead of a 2 s wall-clock sleep.
- Fix: timeline's
- Sizes with release train 0.20.0's changes together (#103, #96, #113, #115, #114, #117–#122, #111, #105; measured on the integrated branch against main with #116; the numbers in the bullets above are each change on its own). fizlet/arcade 23,999 → 24,334 B gzip (budget 24,064 → 24,399); the card build 18,038 → 18,351 (budget 18,048 → 18,361); fizlet/board-page 4,097 → 4,162 (budget 4,193); fizlet/pad 1,995 → 2,073 (budget 2,126); kernel 3,441 → 3,443; the page build 7,129 → 7,164 (budget 7,168, unchanged: the hero's bricks stay ≤ 7 KB). Pad's and board-page's exact
hypotshare code, so arcade rises 73 B for both, not 135.