# claygoo > A browser physics platformer. Claygoo is a real soft body: a ring of 24 nodes with a > heavy pilot ball rolling loose inside it, simulated in Matter.js. He has no legs and no > walk cycle, so every move is him hurling that inner ball and letting the rest of the bag > follow. A level is finished by collecting every star in it. Levels are plain JSON in a > 560 by 560 world, and the game ships with an editor that draws that JSON. Play it at https://claygoo.com. That URL serves the game and nothing else: no landing page, no marketing, no docs site. This file is the reference for how the game plays and how a level is stored, written for an agent that has no other context. The source repository is private. There is no public code URL, no npm package, and nothing to install. The game is a set of static files served over HTTP, and the only public artifacts are the pages and JSON under https://claygoo.com. Source paths below are named for precision and are relative to the repository's `game/` directory: `play.html` is the game page, `editor.html` the editor, and `lib/` holds the engine. They are pointers for anyone holding the repository, not URLs to fetch. ## Play it https://claygoo.com opens level 1 of the shipped 40 level campaign. The shipped levels carry no `name`, so the game labels them `level N` by position. - `https://claygoo.com/?level=12` opens level 12. The number is 1 based and clamps into range. Every level change pushes history, so back and forward walk the levels visited. - The left sidebar lists every level as a live thumbnail drawn from the level data. On a coarse pointer it becomes a drawer behind the `☰` button. - Picking a level from the sidebar boots it paused behind a START overlay. Continuing from a completed level, replaying, or restarting skips that overlay. - Progress is per browser, in `localStorage` under the key `claygoo.play.done.v1`. It holds an array of `":"` strings, so a reordered or renamed pack forgets rather than crediting the wrong level. Only the built in pack is saved; an imported pack is a visit. - A level ends when the last star is collected. That freezes the world mid tick and raises the LEVEL COMPLETE overlay. A level with zero stars can never be completed, because the completion check only runs inside a star collection. The bar carries restart, fullscreen and a light and dark theme toggle. `⇪ import levels` in the sidebar, or dropping a `.json` file anywhere on the page, plays your own pack instead, and `built-in pack` returns to the shipped levels. **An imported pack is temporary: it records no completion progress**, because only the built-in pack is tracked under that localStorage key. The shipped pack lives at https://claygoo.com/levels/claygoo-levels.json and is fetched at load, so the page needs a server. Opening the HTML from the filesystem fails on both the fetch and the ES module imports. ## Controls Keyboard, in play mode: | key | action | |---|---| | `←` `→` or `A` `D` | move. On goo these crawl along the strip instead | | `↑` or `W` | jump. On goo this climbs, and a third press away from the goo lets go | | `↓` or `S` | duck. On goo this crawls down. Held alone for 3.2 s it becomes the pancake | | `R` | restart the level (a page level binding, not an engine one) | | `Enter` or `Space` | activate the first button of whichever overlay is up | | `Escape` | close the level drawer or the options menu | The game page passes `escapeExits: false` to the engine, so `Escape` never leaves play mode there. In an embed that leaves the option at its default, `Escape` hands control back to the host by switching the engine to `auto` mode. Space is deliberately unbound as a jump. The engine embeds in scrolling pages, and a window level `preventDefault` on space would steal scroll from a reader who never asked to play. `lib/movement.test.js` asserts that space raises the body by less than 15 units. On a coarse pointer the engine draws four 62px thumb buttons in two horizontal pairs, one per thumb: `◀ ▶` on the left, `▼ ▲` on the right. They sit inside the safe area insets. Ducking scales the whole body by `DUCK.scale` (0.65) over `DUCK.ease` (0.12 s), in the air or on the ground, so he fits through gaps he otherwise cannot. Every size bearing quantity scales together: both spring rest lengths, the pressure target by the square, `MIN_THICK`, and the pilot ball's leash. ### How he moves, measured These are the bands `lib/movement.test.js` enforces against the real engine, driven headlessly. Design against the bands, not against a remembered number. | move | contract | |---|---| | held run for 2 s on flat ground | travels 120 to 420 units, checked over three RNG seeds | | standing jump, centroid rise | 35 to 75 units. `↑` and `W` agree within 8 | | a 190 unit pit | uncrossable in the air on any seed | | a lone vertical wall | one floor jump plus one wall kick, under 150 units of rise, however long you spam | | a 90 unit shaft of two facing walls | gains more than 60 units over 12 alternating kicks | | duck | squeezes below 80% of standing height and recovers above 85% | The alternation rule is the reason a lone wall caps out: one wall yields exactly one kick, and `lastWallDir` refuses a repeat until a floor contact, a rest on goo, or a respawn resets it. Two facing walls are therefore a ladder while one tall wall is a genuine seal. Budget level height against that split. He cannot scoot up a slope; anything uphill has to be hopped. Faces up to 80 degrees from horizontal still count as floors (`FLOOR_NY = 0.17` in `lib/blob.js`), so steep scrambles stay hoppable, and only genuinely vertical rock is a wall. ### The cling and the goo crawl Claygoo carries a `grip` value in `[0, 1]` that welds him to any surface he touches. Grip charges on flat ground and drains under load, where load counts walls at 1.0 and overhangs or ceilings at 1.6. At defaults that is about 1.0 s hanging on a vertical wall and about 0.63 s on a ceiling before he peels, and 0.35 s in clear air to recharge (`STICK` in `lib/blob.js`). A flat floor contributes zero load, so standing still charges him. Ink black **goo** strips painted on a terrain surface (`level.goo`) turn that countdown off. Inside a goo strip the load is zeroed, so the weld holds him to a wall, an overhang or a ceiling indefinitely, and holding a direction drives him along the strip at `GOO_SPEED` (1.9 units per tick) instead of scooting. Direction is screen space and recomputed every tick: the held arrow vector is dotted against the path tangent, so a turn sharper than about 63 degrees stops him and diagonals round a corner without stopping. Getting off goo is graded, by design, so a held key can never launch him. A fresh cardinal press pointing out of the goo loosens the weld one rung; the second press loosens it again and leaves him dangling from a few pinned contacts indefinitely; only a third press in the same direction fires the goo jump. Any movement input re sticks him to full instantly. The jump key is the immediate, ungraded way out. ### The pancake Hold `↓` alone, standing on a floor, with no other key held, for `PAN.charge` (3.2 s) and he morphs over 0.3 s into a flat slab laid along the terrain surface. There is no charging animation of any kind before the morph. While flat he is invulnerable: `spikeTick` and `enemyContactTick` both return early on `flatSafe()`, and that safety covers the whole morph plus a 0.25 s grace after standing up. Enemies walk over him rather than through him. He cannot move as a body; instead `←` and `→` walk his head along the top of him at `PAN.slide` (90 units per second), `↑` launches him out of the pose, and `↓` stands him back up. A surface with no room for a slab refuses the morph visibly and resets the charge rather than silently doing nothing. ### The ice cube Touching an ice cube encases him and freezes him exactly in the pose he is in, hanging where the ice formed. He does not fall, and a moving deck sweeps straight through him. The freeze is a position fact, not a force: the 24 node positions and the pilot ball are captured once and written straight back after every solve. The controls collapse to a single job, aiming. A **fresh** direction press opens a 0.2 s window (`ICE_WIN`); keys already held join the chord the moment it opens but can never open it themselves. Per axis the last press wins, so `←` then `→` is a right launch rather than a cancelled one, and all four directions count. At expiry the casket shatters and throws him about 80 percent higher than an ordinary jump, roughly 89 units against 50. `lib/ice.test.js` pins that as a ratio against a jump measured in the same test, never as a unit count. Ice is not armour. `iceCollect()` runs before the kill checks, so grabbing a cube while touching a spike consumes the cube and then kills you, and spikes and enemies stay lethal for the whole freeze. He does still collect stars while encased, deliberately, and the last one completes the level as usual. `ICE_LOCK` (0.25 s) bars any cube from catching him again straight after a launch, so a cluster cannot eat his takeoff. ### The rotator A rotator is a slowly spinning circled arrow in the same grey substance as the ice cube. It spins in the direction it will turn the world. Touching one turns the whole level about the centre of the square world, carries his live pose through the same quarter turn map, and consumes the item. Gravity never rotates. `turn[].turns` counts quarter turns counter clockwise, so `1` is a counter clockwise quarter turn, `2` is a half turn, and `3` is the clockwise rotator: the data walks three quarters counter clockwise, and the player sees one clockwise quarter turn sweep, which is the short arc to the same place. This works because the world is a 560 by 560 square while the screen only ever shows the fixed central band, `y` from 130 to 430 (`VIEW_TOP`, `VIEW_BOT` in `lib/level.js`). The viewport never moves. The strips of level above and below the visible band are real, paintable space that becomes the left and right thirds of the view after a quarter turn. Rotation is data, not a render effect: `rotateLevel(level, turns)` in `lib/rotate.js` derives a new level record from the canonical unrotated one, and the engine rebuilds the world from it. Derived orientations always come from the canonical record, never by re rotating a derived one, because `560 - x` is not exactly invertible in floating point. ## The world - **560 by 560 units**, fixed, `W` and `H` in `lib/level.js`. The canvas scales to its container with a `ResizeObserver` and DPR handling, but level coordinates never change. - **The visible band is `y` in `[130, 430]`**, 560 by 300. Anything outside it is hidden until a rotator brings it into view. - **`y` grows downward.** Higher ground means a smaller `y`. The shipped levels use `y = 402` as their ground floor. - **Falling past `y = 490`** (`VIEW_BOT + 60`) restarts the level in play mode. - Two static side walls sit just outside the frame, labelled `bound`. They are a fence, not terrain: zero friction, excluded from the cling, so they cannot be clung to, climbed or wall jumped. Terrain drawn flush to `x = 0` or `x = 560` is still real terrain. - One tick is **1/60 s**, fixed. Velocities in the source are units per tick, not per second. - Physics is vendored Matter.js **0.20.0**, loaded as a plain `