--- name: eat-my-face description: "Build the mouse/cheese/cat 3D maze from scratch or maintain Ryan's implementation; use when tuning cat AI, camera, controls, mobile fullscreen, or tests." --- # Eat my face Original authored for Ryan's arcade, with AI assistance, from the project's own game implementation and tests. No third-party skill text is imported, and publication is not a blanket license to the site's code, art, brands, or other content. The portrait on the cheese is Ryan's likeness: use your own photo or an image you have permission to use; never use another person's likeness without their consent. ## Build from scratch 1. Start by deciding whether you are making a fresh static game or extending Ryan's website. For a standalone game, a useful separation is `public/eat-my-face.html`, `public/eat-my-face.css`, `public/eat-my-face.js` for markup/styles/controller, `public/eat-my-face-motion.js` for simulation, and `public/eat-my-face-3d.js` for rendering. Keep auth and shared mobile-screen code separate. Vendor and pin Three.js locally; do not fetch runtime modules from a CDN. Ryan's site uses `public/arcade.html`, `public/arcade.css`, `public/arcade.js`, `public/arcade-motion.js`, and `public/arcade-3d.js` instead. 2. Define the maze and simulation without browser globals. The shipped board is 15 columns by 11 rows. Tiles are integer `(x,y)` cells, stored as wall keys like `"x,y"`; out-of-bounds tiles are walls. The perimeter is closed. Interior walls are at `x=4` for `y=1..7` except `3`, at `x=8` for `y=3..9` except `7`, and at `x=11` for `y=1..6` except `4`. The mouse begins at `(1,1)`, four cats at `(13,1)`, `(6,5)`, `(2,9)`, `(13,7)`, and the cheese at `(13,9)`. Keep the coordinates and model positions in the same orientation. 3. Provide a pure movement API such as `cameraRelativeMovement(input,yaw)`, `orbitCamera(camera,dx,dy)`, and `createMovement(walls,columns,rows)`. The movement object exposes `isWall`, `canOccupy`, `move`, `clearPath`, and `chase`; the controller owns the game state, clock, and outcomes. Use circle-vs-wall checks with `BODY_RADIUS = 0.22`, not point checks. Sweep movement in increments no larger than `0.06` world units and test X and Y separately so the mouse/cats cannot tunnel through corners and can slide along walls. Normalize keyboard/D-pad diagonals while retaining analog stick strength and direction. 4. Keep movement continuous and camera-relative, not tile-step. The shipped `PLAYER_SPEED` is `3.2` world units/second; map forward/back and left/right through the current camera yaw. Cats use `CAT_SPEED = 1.65`, chase the mouse directly when the sampled path is clear, and otherwise follow a path found by breadth-first search over eight neighboring cells. Check diagonal path segments for clearance, repath at about `0.2` seconds, and when a cat loses sight of the mouse, send it straight to the next path waypoint if that segment is clear; recenter on the nearest cell only when it is not, because recentering first can send the cat backwards and makes it visibly twitch. Only update `facing` for moves longer than `0.001` so wall-scrape micro-moves do not flicker the model's heading. Do not let a path or a rendering update move a body through a wall. The simulation should remain deterministic: no random chase decisions or DOM/rendering dependencies. 5. Keep four distinct cats and preserve the `1,500 ms` grace period before they become active. A cat captures at distance `<= 0.42`; the mouse wins when within `< 0.6` of the cheese. Check outcomes during small movement substeps after the mouse move and after the cats move, so a fast frame does not skip a catch or goal. The round starts in `ready`, enters `running`, and finishes once as `won` or `lost`; it also loses when its `45,000 ms` timer expires. Restart from the original positions and reset camera, clock, cats, and held input. The cats should not move during the head start. 6. Use a fixed small update step for simulation and cap unusually long frame updates. Ryan's controller divides each frame into steps no larger than `1/120` second, caps the elapsed simulation frame to `100 ms`, and separately tracks elapsed round time. The pure motion tests exercise 30, 60, and 144 FPS; keep displacement per simulated second independent of render frequency. Pause the timer when the game is hidden or the page is inactive. 7. Build a local Three.js scene with a `15 x 11` floor and cheese-textured box walls. Ryan's renderer uses `CELL = 3`, walls `3 x 2.8 x 3`, a `62` degree perspective camera, hemisphere plus directional light, shadows, ACES filmic tone mapping, and distance fog. Build simple mouse/cat models and a large cheese wheel with a bright goal beacon; the original animals are procedural meshes, not external assets. Keep the 2D radar separate from the 3D scene, with walls, mouse, cats, and cheese visible in distinct colors. 8. Implement an orbiting third-person follow camera independent of movement. The shipped orbit distance is `5.2`, yaw changes by `0.006` radians per horizontal drag pixel, pitch changes by `0.005` per vertical pixel and clamps to `0.18..1.05` radians. Raycast from the camera target toward the desired camera position against wall meshes; sample the center and five nearby offsets to prevent the camera near plane clipping at corners. Pull the camera in instantly when a wall intrudes but ease it back out (exponential, rate about `5`/second) so it never pops. Hide the player model below a `0.95` camera distance and show it again only above `1.3`; that hysteresis stops the mouse flickering when the camera hovers near a wall. Render cats turning more slowly than the mouse (blend rate `11` vs `18`) so 90-degree grid-corner turns read as turns, not snaps. Add a small negative shadow `bias` (`-0.0005`) with the `0.04` normal bias, and enable anisotropic filtering on the wall texture to stop shimmering at grazing angles. Provide a resize observer, clamp renderer pixel ratio to at most `1.5`, and show a useful recovery message on WebGL context loss. 9. Put inputs and presentation in the controller. Support WASD and arrows, normalized diagonal movement, an analog touch stick, independently held touch D-pad directions, pointer drag on the maze and the labeled camera strip, and keyboard orbit (`Q/E`) and tilt (`R/F`). Keep camera drag independent of movement, allow camera adjustment before play and after a round, and support simultaneous stick and camera pointers. Provide the remaining-time HUD, accessible status, radar, start/replay overlay, render-error area, and a clear win/loss result. Clear every held key, stick, D-pad pointer, and drag on blur, hidden tab, pointer cancellation/lost capture, screen-mode change, and leaving play. 10. Reuse shared responsive fullscreen behavior when available: `public/arcade-screen.js` and `public/arcade-screen.css` operate on `[data-game-screen]`, `[data-game-screen-toggle]`, and `[data-game-screen-status]`. On a max-width `900px` or coarse-pointer device, enter viewport focus when the game loads; don't request native fullscreen without a user gesture. The first eligible game-button tap may request native fullscreen. Keep the explicit desktop button and an obvious, rightmost `Exit` at least `44 x 44` pixels. Honor Escape, safe areas, and an unavailable/denied Fullscreen API. Do not implement a competing fullscreen state machine. 11. Ryan's game play is public and does not depend on sign-in. Keep the simulation and maze controls independent of account state. A generic standalone version should use the host's established account policy for any optional shared features rather than inventing credentials or weakening its policy. 12. Follow strict Hosting CSP: load JavaScript and CSS from external same-origin files; no inline handlers, inline executable scripts, `eval`, or new CDN dependencies. Ryan's game imports `./vendor/three/three.module.js` locally (Three.js is pinned to `0.186.1`). The portrait is loaded by `TextureLoader.loadAsync("/images/ryan-mcpherson.jpg")`, not embedded or fetched from a portrait CDN; the applicable Hosting directive includes `img-src 'self' data: blob: ...`, so the same-origin file is allowed. The service worker precaches this public image. A sign-in gate does not make a public image private: get consent, disclose the image's public/offline availability, and do not cache a portrait unless that is intended. If the portrait fails to load, Ryan's async view initialization fails and leaves the start button disabled; handle optional portraits explicitly if your game should work without one. 13. Add sound with the shared procedural module `public/arcade-audio.js`: Web Audio synthesis, so there are no audio files to license, precache, or allow through the CSP. The audio context is created and resumed on the first pointer/key gesture, suspended while the page is hidden, and routed through SFX/loop buses set in decibels with headroom. Call `playSound(name)` from controller events only: `start` on a new round, `cats` when the head start ends, `step` every `0.55` world units moved, `warning` once when a cat comes within `2.2` (re-armed only after every cat is beyond `3.0`), `tick` for each of the last five seconds, and `win` or `lose` at the result. Each sound gets slight random pitch variation and a minimum repeat gap. Bind an accessible mute button (`bindMuteButton`, at least `44 x 44`, placed just before the Full screen/Exit control) that persists to `localStorage` key `arcade-sound-muted`. 14. Add an original looping theme. Generate with a local text-to-music model (we used ACE-Step 1.5 XL-SFT, MIT, via `Generate-Audio.cmd -Style -Prompt "..." -Duration 48 -Seed 42`) using an original instrumental prompt that names no artists or songs and asks for steady energy with no intro, breakdown, or ending. Do not trust the preset BPM: measure the real repeat period with `tools/make-music-loop.py` (onset autocorrelation plus a spectral self-similarity lag curve; the maze take came out at 110 BPM, not the preset 112), cut a bar-aligned 16-bar loop at the most self-similar start, crossfade the seam for 120 ms (equal power), apply linear gain to about -16 LUFS with at least 1 dB true-peak headroom, and encode AAC 112 kbps M4A (roughly 0.4 to 0.55 MB). Record model, preset, seed, steps, prompt, loop start, and loop length in `public/audio/provenance.json`. Play themes with `playMusic(THEMES..url)` from `arcade-audio.js`: fetch plus `decodeAudioData` (covered by `connect-src 'self'`), a looping `AudioBufferSourceNode` on a music bus at -10 dB under the effects, a 1.5 s fade-in, crossfade between themes, and `stopMusic({ fade })`. Each `THEMES` entry stores its exact loop length, and playback sets `loopStart`/`loopEnd` to that window (skipping any AAC priming silence the browser keeps, with a 2 ms tolerance), so the loop never clicks or gaps. Music waits for the first gesture, shares the mute button, pauses with the hidden page, and fails silently if a file is missing. The service worker caches theme files at runtime (cache-first) rather than precaching about 1.5 MB, so filenames are immutable: ship a new take under a new name. The maze calls `playMusic` in `startGame` and `stopMusic({ fade: .4 })` just before the win/lose sting. ## Maintaining Ryan's implementation 1. Before editing, read `public/arcade.html`, `public/arcade.css`, `public/arcade.js`, `public/arcade-motion.js`, `public/arcade-3d.js`, `public/arcade-audio.js`, `public/arcade-screen.js`, and `public/arcade-screen.css`, plus `tests/arcade.test.js`, `tests/arcade-audio.test.js`, `tests/arcade-screen.test.js`, `tests/arcade-mobile-browser.py`, and `README.md` arcade notes. The controller builds the wall set and owns keyboard/touch input, timer, round state, HUD, and radar. The motion module owns geometric movement and pursuit. The renderer owns Three.js assets, camera, and canvas. Keep these contracts separate. 2. Preserve exported motion names and values: `PLAYER_SPEED = 3.2`, `CAT_SPEED = 1.65`, `BODY_RADIUS = 0.22`, and `CATCH_DISTANCE = 0.42`; preserve `cameraRelativeMovement`, `orbitCamera`, and `createMovement`. `arcade.js` owns `COLUMNS = 15`, `ROWS = 11`, `GAME_LENGTH_MS = 45000`, `CAT_HEAD_START_MS = 1500`, the four spawn positions, and cheese coordinate. It calls `createMazeView(canvas,{walls,columns,rows,cheese})`; the view provides `startLoop(frame)`, `draw({mouse,cats,cameraOrbit,gameState},time)`, and `reset()`. 3. Do not merge renderer, DOM, or Firebase into `arcade-motion.js`; its world functions are tested under Node. Preserve collision clearance, direct pursuit plus wall-aware waypoint chasing, independent camera orbit, outcome checks, speed consistency, restart semantics, and the 1.5-second cat delay. If tuning changes the maze or a constant, update focused tests and gameplay copy together. 4. Keep public gameplay independent of the shared account control. The game page uses `arcade.js` and `arcade-screen.js` as external modules/stylesheets; account-backed ride sharing remains optional and separately verified. Input is cleared by blur, visibility, pointer release/cancel, `game-screen-change`, and hidden game content. 5. The face is a self-hosted photo at `/images/ryan-mcpherson.jpg`, used as one texture on four sides of the goal cheese. Hosting CSP `img-src` permits `'self'`; service worker caches the portrait. Respect the subject's likeness and consent when changing the photo, cache behavior, or game visibility. No extra account/ID data is needed in this skill. The published skill must not include user-specific absolute paths or secrets. 6. When adding a game file or asset to the installed site, update the explicit skill publication allowlist and generated pages if the published skills change, and add new files to the service-worker precache with a cache-version bump. Do not claim a release, deployment, or offline support until the corresponding publication and cache changes are tested. ## Testing and verification 1. From the `web` project root, run `node --test tests/arcade.test.js tests/arcade-audio.test.js tests/arcade-screen.test.js`. The simulation-only regression command is `node --test tests/arcade.test.js`; fullscreen/accessibility state tests run with `node --test tests/arcade-screen.test.js`. Add or retain deterministic assertions for cats capturing at the radius, eating the cheese to win, movement stopping at walls without tunneling, cats remaining inactive through `1,499 ms` and activating at `1,500 ms`, and equal movement distance at 30/60/144 FPS. Include blocked-corner and cat-path tests when modifying chase logic, plus the regression that pursuing cats never reverse direction at range. The audio test mocks `AudioContext`: sounds stay silent before a gesture, every recipe synthesizes with positive exponential-ramp targets, repeats are rate limited, and mute persists. In the browser, count `OscillatorNode`/`AudioBufferSourceNode` starts to prove sounds play and that muting silences them; headless audio output itself is not audible proof. 2. Serve the site locally at the browser test's default `http://127.0.0.1:4175`, or set `ARCADE_TEST_BASE` to the preview URL. Run `python tests/arcade-mobile-browser.py`; the script tests all three arcade pages with a stub signed-out session and records browser errors. It covers portrait `320x568`, portrait `390x844`, and landscape `844x390`, checks top-right Exit dimensions/placement and overflow, verifies explicit exit survives resize, and confirms sign-out does not lock gameplay. The separate native-fullscreen pass runs only when browser support permits. 3. Set `ARCADE_TEST_CHROME` to the installed Chromium headless-shell executable when it is not on the script's default path. For example, in PowerShell set `$env:ARCADE_TEST_BASE='http://127.0.0.1:4175'; $env:ARCADE_TEST_CHROME=''` and run `python tests/arcade-mobile-browser.py`. Keep environment values local; never put credentials in the test. The browser test stubs auth and uses SwiftShader, so it is not proof of live Google/GitHub authorization or real-device GPU performance. 4. Also verify the actual game at desktop size such as `1280x800`: start, move diagonally, orbit/tilt, collide with a wall, watch the timer and radar, win, lose, replay, and test keyboard/touch/pointer-release behavior as applicable. Check keyboard focus, screen-reader labels, resize, console errors, and WebGL initialization/context-loss messaging. When changing Hosting headers, confirm portrait requests work under the deployed CSP; a header-free preview alone is insufficient. ## Acceptance 1. Report the exact changed files and behavior, exact commands and pass/fail results, tested viewports, and any unverified manual, account, CSP, or deployment cases. Separate deterministic Node evidence from browser evidence. Never claim real-device performance based on SwiftShader. 2. Be honest about limitations: the browser test's auth is a stub, client-side gating is not a server authorization boundary, fullscreen APIs can be denied, and a public self-hosted portrait remains public even when the game UI is gated. Don't claim a portrait consent flow or a deployment unless one was actually implemented and verified.