GravityPrism / agent mode
Let an agent play the prism
Add ?agent=1 to the game’s URL and the page exposes window.gpAgent, a small JavaScript API for playing GravityPrism without a keyboard: a language model driving a browser (Claude in Chrome, a ChatGPT agent, Claude Code with a browser tool) or a plain script. I made it because the game is a decent small test of 3D spatial reasoning: moves are relative to the camera, pieces rotate about three axes, and holes get buried under overhangs.
Without the flag nothing is exposed and the game is the one humans play. With it, a yellow “agent mode” badge sits in the corner, and the first-game tips are off.
How it works
The game is paused between calls. The engine is deterministic and runs at a fixed 60 ticks per second; in agent mode the page never steps it by itself, and each call advances it exactly as far as it needs. Slow thinking costs nothing in game time, and nothing falls while you think. Every call draws the result before it returns, so screenshots show the game too, and sound and the game-over screen work as usual. Everything runs in your browser tab; the server is not involved.
Add &watch=1 and calls play out at a human pace: the simulation runs in real time frames (a hard drop takes a quarter second or so of animation) with a short pause after each action, so a person can follow. Without it a call is instant.
All methods return plain JSON as a Promise, except observe(), which is synchronous. Wait for window.gpAgent to exist, call start(), then one act() per piece is enough.
Tips
- Every method except
observe()returns a Promise: await it. leftand friends are relative to the camera, not the grid. Readcontrols.movein the observation before you move, and again after any camera action.- Actions are single taps, so a move is one cell. Put several in one list:
act(["left", "left", "rollRight", "hardDrop"]). Rotations that would hit something may be nudged by up to a cell or two (“kicks”) or do nothing; checkresult.appliedand the newpiece.cells. - Pieces fall only when you let them: hardDrop (or advance, or softDrop). Plan with
piece.landing, which is where a hard drop ends up for the current position. - A boon offer pauses everything:
next_stepbecomes “pick” and act() stops withstopped: "boonPick"until you callpick(i).
What the agent can see
The whole grid, including holes buried under overhangs. A human sees a prism from one angle and gets small hints for holes; an agent gets board.layers, the exact contents of every cell. That is deliberate: the test is what the agent does with perfect information about the board, in 3D, with camera-relative controls. It is not a test of reading a rendered image. If you want that, give the model screenshots only and ignore the JSON.
Nothing is exposed that the game does not have: the next pieces shown are the three the game shows, and the upcoming random pieces beyond them are not in the observation.
Fair comparisons: seeds
Same seed, same board size and same calls give the same game, tick for tick (a test checks it). start({seed: 7, size: 4}) or &seed=7&size=4 in the URL deals the same pieces and boon offers to every model or policy. Use summary() at the end for a scorecard.
The world
- Coordinates are grid cells
[x, y, z], y up. Cell (0, 0, 0) is a floor corner. The base is width x depth (4, 6 or 8 square), 14 rows tall, with 4 hidden rows above. A new piece spawns with its lowest cell 6 rows above the highest column, or on the first hidden row (y = 14) once the stack is 8 or taller. - Pieces are tetracubes (four cubes): I, O, T, L, S flat, and the Tripod, which has a cube on top of a corner. Kinds 1 to 6 in that order; 7 and 8 (screws) are not dealt by default. Kind 9, the single cube, comes from a boon.
- A layer clears when every cell at that height is filled. Score per clear is 500, 1400, 3000, 6000 or 10000 for 1 to 5 layers, times the level, with a bonus for chains. Every 3 layers is a level.
- Level 1 gravity is about 0.8 cells per second, faster per level. A piece resting on something locks 0.5 s later; with taps and hard drops this rarely matters.
- The game ends when a new piece cannot appear or a piece locks entirely above the playable rows.
Actions
| action | what it does |
|---|---|
| left, right, away, toward | Move one cell. Camera-relative: observation.controls.move gives each as a grid step [dx, dz]. The meaning changes with every camera stop, so read controls again after any camera action. |
| spinLeft, spinRight | Quarter turn about the vertical axis. spinLeft takes the side facing the camera to the left. |
| tiltAway, tiltToward | Quarter turn about the screen-right axis. tiltAway takes the top of the piece away from the viewer. |
| rollLeft, rollRight | Quarter turn about the away axis. rollRight takes the top of the piece to the right. |
| softDrop | Holds the soft-drop key for 10 ticks by default (about 5 cells at 20x gravity). Use {action: "softDrop", ticks: n} for more or less. |
| hardDrop | Drops the piece straight to piece.landing, locks it, and runs the game until the next piece is in play (or a boon offer or game over appears). |
| hold | Swap with the hold slot (once per piece). The new piece starts at rotation 0. |
| cameraLeft, cameraRight, cameraTop | Turn the camera one stop of eight, or toggle the top-down view. This changes what left, right, away and toward mean (not the board). Useful if you are looking at screenshots. |
| boon1 ... boon6 | Use a boon: slow, xray, ghost, cube, flip, split. Needs a charge (observation.boons.charges). Ghost + cube (either order) makes a ghosted single cube that falls through the stack into the first buried hole below it. |
Methods
| call | what it does |
|---|---|
| observe() | Synchronous. The current observation (JSON). Does not change anything. Before start() it says so. |
| start({seed?, size?, daily?}) | Begins a new game (also after a game over). seed: integer 0 to 2147483647 (default: the URL's &seed=, else random; the observation reports the seed so you can replay it). size: 4, 6 or 8 (default: the URL's &size=, else 4). daily: true plays today's challenge instead (4x4, seed from the local date). Returns the first observation. |
| act(actions) | Applies a list of actions in order, then returns the new observation with a result block. Each action is a name, or {action, ticks} to hold the key for that many ticks. Each is one key tap (the key goes down for one tick and up), so a move is exactly one cell and nothing repeats. Between pieces act() first waits for the next piece. It stops early if a boon offer appears (result.stopped: "boonPick") or the game ends. An unknown action rejects the whole list and changes nothing. |
| advance(seconds) | Lets time pass with no input, 0 to 60 s of game time. Pieces fall by gravity (about one cell per 1.25 s at level 1), so doing nothing for a while drops the piece and locks it. Rarely needed. |
| pick(i) | While next_step is "pick": take card i (0-based index into boons.offer) or give the boon's name, e.g. pick("slow"). If the pick would exceed a charge cap it turns into points. |
| summary() | A scorecard for comparing runs: seed, size, score, level, layers, pieces, bestClear (most layers in one clear), boonsPicked, boonsUsed, simSeconds (game time), wallSeconds (real time since start), calls, over. |
| resume() | If a person paused the game (Esc), resume it. The other calls refuse while paused. |
If a call is not valid it changes nothing and the observation comes back with an error string. There is also window.gpAgent.version, .docs and .actions.
The observation
| field | meaning |
|---|---|
| next_step | "act": a piece is in play, call act(). "pick": a boon offer, call pick(i). "over": the game ended, call start(). "wait": between pieces (act() waits by itself). |
| over, phase | phase is one of falling, spawning, clearing, transition (flip/split animating), boonPick, gameOver. |
| score, level, layers, chain | Score, level (1 + layers / 3), layers cleared so far, and the current chain length. |
| seed, daily | The game's seed; daily is the date key when it is the daily challenge. |
| tick, time | Simulation ticks (60 per second) and seconds of game time. Only your calls move them. |
| board.width, depth, height, hiddenRows | Base size, playable rows (14), and the hidden rows above the rim (4). |
| board.spawnRow | The row the next piece's lowest cell appears on: 6 above the highest column, or 14 (the first hidden row) once the stack is 8 or taller. |
| board.layers | The whole grid. layers[y][z] is a string of width characters, x = 0 first. "." is empty, a digit is the kind of the piece that filled it. y = 0 is the floor and the list stops at the highest filled row. Pieces do not appear here. |
| board.heights | heights[z][x]: 1 + the highest filled y in that column (0 = empty). |
| piece | The falling piece: kind and name, rot (orientation 0 to 23, 0 = spawn), pivot [x, y, z] (it rotates about this cell), cells (absolute [x, y, z]), offsets (cells relative to the pivot), landing (where a hardDrop would leave it), lockFrac, ghosted. null between pieces. |
| next, hold, holdUsed | Upcoming pieces (nearest first), the held piece, and whether hold was already used on this piece. |
| boons | enabled boons and their action names (keys), charges, layersToNextOffer, offer (the cards while picking), and slowSeconds / xray / split when active. |
| camera | { stop 0 to 7, topDown, yawDeg }. A game starts at stop 1. |
| controls | What the actions mean now: screenRight and screenAway as grid steps [dx, dz]; move.left/right/away/toward as [dx, dz]; rotate.<name> as { axis, dir }, a quarter turn about a grid axis (dir 1 = right-handed, counter-clockwise looking down the axis toward the origin). |
| result (act, advance, pick) | applied[] (action, changed), ticks, scoreDelta, layersCleared, locked[] ({kind, cells} of pieces that locked), and stopped / error if the call ended early. |
| screen, hint | The shell's screen (play, pause, gameOver, ...) and a one-line suggestion. |
{"phase":"falling","next_step":"act","over":false,"score":0,"level":1,"layers":0,"seed":1,
"board":{"width":4,"depth":4,"height":14,"hiddenRows":4,"spawnRow":7,
"layers":[["....","2200","2200","...."]],"heights":[[0,0,0,0],[1,1,0,0],[1,1,0,0],[0,0,0,0]]},
"piece":{"kind":1,"name":"I","rot":0,"pivot":[1,7,2],"cells":[[0,7,2],[1,7,2],[2,7,2],[3,7,2]],
"landing":[[0,1,2],[1,1,2],[2,1,2],[3,1,2]],"lockFrac":0,"ghosted":false},
"next":[{"kind":3,"name":"T"},{"kind":5,"name":"S"},{"kind":2,"name":"O"}],
"camera":{"stop":1,"topDown":false,"yawDeg":35},
"controls":{"screenRight":[1,0],"screenAway":[0,-1],
"move":{"left":[-1,0],"right":[1,0],"away":[0,-1],"toward":[0,1]},"rotate":{"spinLeft":{"axis":[0,-1,0],"dir":1}}}}(Abridged and illustrative; the real one has every field above.)
A minimal loop
A naive policy that carries every piece to the lowest column and ignores rotations and holes. It is bad at the game, and a good baseline for what “better than naive” has to beat.
// Paste into the devtools console of /games/gravityprism?agent=1
// (or run it with page.evaluate in Playwright). A naive policy: carry every piece to the lowest column.
async function play(maxPieces = 40) {
const A = window.gpAgent;
let o = await A.start({ seed: 1, size: 4 });
for (let i = 0; i < maxPieces && !o.over; i++) {
if (o.next_step === "pick") { o = await A.pick(0); continue; } // a boon offer: take the first card
let best = [0, 0, 99]; // [x, z, height] of the lowest column
o.board.heights.forEach((row, z) => row.forEach((h, x) => { if (h < best[2]) best = [x, z, h]; }));
const [px, , pz] = o.piece.pivot;
const dx = best[0] - px, dz = best[1] - pz;
const acts = [];
// "left", "right", "away", "toward" are camera-relative: o.controls.move says what each means in the grid
for (const name of ["left", "right", "away", "toward"]) {
const [mx, mz] = o.controls.move[name];
const n = mx !== 0 ? Math.max(0, Math.sign(mx) * dx) : Math.max(0, Math.sign(mz) * dz);
for (let k = 0; k < n; k++) acts.push(name);
}
acts.push("hardDrop");
o = await A.act(acts);
}
return A.summary();
}
play().then(console.log);A prompt for a browser agent
To have a model play it itself, one decision per piece, paste this into Claude in Chrome, a ChatGPT agent or similar. The &watch=1 in the URL lets you watch it play. To have it write a bot instead, point it at the loop above and ask it to improve the policy.
You are playing a 3D falling-block game through a JavaScript API, window.gpAgent.
Open /games/gravityprism?agent=1&watch=1 (docs: /games/gravityprism/agents).
Run JavaScript in the page to call the API. Every method except observe() returns a Promise: always
await it and print the result (a returned Promise that is not awaited looks empty).
Rules: pieces of four cubes fall into a tall square prism (4x4 base). Fill a whole 4x4 layer with no gaps
and it clears. Pieces can be rotated in 3D. Cells under an overhang stay empty and are hard to fill
later (holes): drop pieces so they leave none. The game ends when a new piece cannot appear.
PLAY IT YOURSELF, PIECE BY PIECE: call start({seed: 1, size: 4}), then read each observation and
decide yourself. Do not write an automatic loop or script that plays for you. For every piece, look at
board.layers (layers[y][z] is a string over x, y = 0 is the floor, "." is empty), piece.cells and
piece.landing, decide where it should end up and which rotations it needs, then call
act([...]) with a short list such as ["left", "left", "rollRight", "hardDrop"]. The moves are
relative to the camera: controls.move tells you which grid direction "left", "right", "away" and
"toward" mean right now, and controls.rotate which axis each rotation turns about. result.applied
tells you whether each action changed anything (changed: false = blocked). After a hardDrop you get the
next piece. When next_step is "pick", choose a boon with pick(i). Keep going until over is true, then
report summary() and what you would do differently.
Use only window.gpAgent. Play fair: do not edit the page or the game state.Fair play
It is a toy, so be nice. Use only window.gpAgent: do not poke at the game’s internals. A person can still press keys while the page is in agent mode; the clock is the agent’s, but keys act on the same piece. If you build something fun on it, or an agent finds a nasty bug, I would like to hear about it.