--- name: playtest description: Playtest a studio's game the way Homie holds its own games to a bar — real browsers on a computer and a phone held both ways, the first ten seconds timed, the look measured (black or flat frames, contrast, detail), how much of the screen the UI covers, the game's real sound captured and measured, a round played with one person trying and one doing nothing, the owner control tests and two strangers finishing a round — then a blind review by a fresh reviewer that never saw the code, and a ranked list of what is weak. Use when someone asks to playtest, test, review, critique or judge a game, asks what is weak or what to fix next, before calling a game finished or publishing it, and after every change that should make it better. --- # Playtest a game The question is never "does it run". It is: **would a stranger who pressed Play stay for a whole round, and come back?** Instruments measure what can be measured; a fresh reviewer who never saw the code answers the rest. A builder never grades its own work. One script: `scripts/playtest.mjs` in this skill's folder (Claude Code: `node "${CLAUDE_PLUGIN_ROOT}/skills/playtest/scripts/playtest.mjs" `), run from inside the studio. It needs Node 22, Chrome and the studio's `@homie-rocks/studio` (for puppeteer-core and its checks). It opens at most two browsers at a time, all muted: nothing plays out loud. ## 1. Run the site and the instruments Start the site as a background task that outlives the command (Claude Code: the Bash tool's `run_in_background`), wait until the play page answers, then: ```sh npm run dev # background: http://127.0.0.1:8787 (another port: npx --no-install homie-studio dev --port ) node run --url http://127.0.0.1:8787 ``` It takes five to ten minutes; poll its output, never end your turn while it runs, and do not rebuild while it runs (a rebuild under `npm run dev` can stop the dev server). `--only first,look,ui,sound` runs some rows; `--seconds 30` plays longer. The live site works too (`--url` the studio's address). Stop the site afterwards with `npx --no-install homie-studio dev --stop`. | row | what it does | fails when | | --- | --- | --- | | `first ` | opens the play page like a stranger on a computer, a phone, a phone on its side, and times four separate things: a seat, the page's first paint (the loading card, not the game), the loading cover lifting, and the first picture of the game itself (cover gone, the game's own frames advancing); frames per second; the GPU | no seat, a cover that never lifts, or no picture of the game in 10 s | | `move ` | waits for a live round and a body that is the player's, then presses right and times the body moving; when nothing moves it presses left too and reports both, with the position, round phase and time left at each press | the body answers after 1.5 s, or neither way moves it in a live round. WARN when only the opposite way moved (a wall on that side looks like this). BLOCKED when no press fit inside a live round with a free body | | `look ` | plays like a person (holds a direction most of a second, sometimes the action, changes its mind) and shoots the screen: brightness, contrast, colour, visible detail, black or one-colour frames, holes where nothing drew, frames that did not change | a black or one-colour frame, pure-black holes; WARN for dark, flat, featureless or still frames | | `ui ` | waits for active play, holds the thumb on the stick, hides the world, paints the page black then white, and counts what stays: the share of the screen the DOM UI covers and what is opaque in the middle third. Both frames are saved with the game's state at each | over 12% covered, or anything opaque in the middle, during active play. N/A when the screen was the results card, a spectator view, the loading cover or a browser cut off from its room (measured, labelled, not judged by that bar). BLOCKED when the game changed state between the two frames. WARN when it would pass and a DOM HUD element sits under one of the play page's own controls (the room button, the server or chat pill, the banner): the row names the element and the control | | `sound` | copies what the game sends to its speaker while it is played (no autoplay flag: the real first-touch rule); loudness overall and through a phone speaker, true peak, clipping, gaps, whether each action press answers with a sound, whether music started | silence, clipping; WARN for quiet, gaps, actions without sound, what a phone loses | | `play` | a computer plays (direction holds and the game's primary action) and a phone does nothing, in the same public room, for a round that starts after both are in: places, scores, round length against `game.json`, lead changes, and which inputs the script really pressed | no round finishes; WARN when doing nothing scores as well as playing, everyone ties, or a bot is far ahead (a measured gap in one scripted round, never "people cannot win") | | `controls` | the owner tests from `homie-studio port check`: hold a direction 5 s (one straight line, the camera's yaw moves under 10°), alternate directions 10 s (every press goes the pressed way), real touch on Android Chrome (and iPhone WebKit when Playwright's WebKit is installed; otherwise that row says skip), a killed host, a late joiner, the big screen, audio unlock, errors | any of them (`port` skill: `references/CHECKS.md` says what each failure usually means) | | `round` | `homie-studio check`: two fresh browsers press Play, meet in one room and both see a round finish with both of them in the results; reconnects are reported beside completion | they do not. WARN when the round finished and a browser reconnected on the way | | `errors` | uncaught errors and failed requests seen along the way | any uncaught error | **BLOCKED is never PASS.** A browser rendering at a few frames a second (a software renderer, a machine under heavy load) makes any game look stuck: the rows say BLOCKED and judge nothing. Run again on a quieter machine. "Could not test" and "tested and fine" must never sound the same. The same goes for the instrument's own faults: a screenshot decoder that does not finish in 20 s is stopped, that device's rows say BLOCKED, the browser is closed and the run exits non-zero; a site address this computer's Node.js cannot look up (a browser may still open it) stops the run as BLOCKED, "network preflight failed, before any page or game was opened" (the words `check`, `perf` and `port check` use for it too), and offers the local site. **N/A** means the row did not apply to what was on screen. One scripted run is one run: report a gap as measured once, and repeat before concluding. ### What the game tells the instruments Every press and every picture is recorded with the game's state, read from its port probe (`exposePort`, the `port` skill) and the play page: round phase and time left, whether the body is the player's to steer (`busy`), where it is. A game with no probe still runs, and its rows say the phase was unknown. Three optional things make the rows sharper; none is required: - In `exposePort(net, { extra: { ... } })`: `alive: () => boolean` (false: spectating until the next round), `mode: () => string` (the control mode on screen), `loadout: () => string` (what the player holds, when it changes which controls show), `touchHeld: () => boolean`, and `drawCalls` / `triangles` (`renderer.info.render.calls` / `.triangles`: the look row then reports measured runtime scene cost, which is not the asset inventory's estimate). - In `game.json`, the primary action, so a shooter is played by firing and not by the space bar: ```json "playtest": { "primary": { "label": "fire", "computer": { "mouse": "left" }, "phone": { "selector": "[data-action=fire]" } } } ``` `computer` is `{ "key": "" }` or `{ "mouse": "left|right|middle", "at": [x, y] }`; `phone` is `{ "selector": "" }` or `{ "region": [x, y, w, h] }` (fractions of the screen). Undeclared, the script presses the space bar and taps a fixed spot, says that was a guess, and qualifies anything it concludes from the scores. It does not aim. - In `game.json`, `"scoring": "together"`: every result row carries the room's one shared total, so the play row marks the active-against-idle comparison not applicable. The UI row measures DOM coverage only. A HUD, labels or hints drawn inside the canvas are hidden with the world and are not checked for coverage or overlap; REPORT.md says so beside the result. The UI row also lays the game's visible DOM HUD against the play page's own controls, which the page draws over the game's frame and reports as `window.__shell.rects` (the game reads the same as `net.shell` / `net.on('shell')`). A HUD element under a control that stays is a WARN with both named; under the "N playing" chip, which fades, it is a note. Fix it by laying the HUD out around `net.shell`'s rectangles, or by moving the page's controls (`game.json` `screen.share`, `screen.chat`). A page that reports no layout (an older studio) is said to be unchecked. A reading taken while the browser was not in its room is labelled, never judged as play: the helper's link (`window.__homieNet.link`, and `window.__shell.link.state`) says `reconnecting`, `alone` (the room never answered and the browser hosts by itself), `offline` or `closed`, and a press, a picture or a UI frame sampled then is BLOCKED or N/A with "CUT OFF from its room" in its state. The round row says a browser "was seen cut off" beside completion. The run writes `.playtest//