a survey of one small world — a game-jam entry on the theme explore.
▶ Play it: https://tonydowney.github.io/periplus/
Needs WebGL2, a keyboard and a mouse. No install, no server, and no network calls once the page has loaded.
A periplus is the older thing a map is made of: a list of what you passed, in the order you passed it, written by someone who was actually there. This one is blank.
You are set down on an unmapped moon with an instrument that writes down only the ground you personally stand next to. Everything else — every hill, coast and mountain range you can see from here — is drawn as what it currently is to you: contour lines, index contours and a ten-degree graticule, floating on the real geometry. Walk toward it and it becomes ground: colour, grass, trees, surf.
The moon does not rotate. There is a permanent day side and a permanent night side, and the walk between them is the point.
Eight cairns are out there, stacked by the surveyor who came before you. They stopped surveying at ninety-one percent. The cairns say why.
![]() |
![]() |
| An unwalked moon is pure contour line. | Landfall. |
![]() |
![]() |
| The night side — glowing contours are unwalked ground. | A coastal cairn site. |
W A S D |
walk |
| mouse | look (click the canvas to capture the cursor) |
Shift |
run |
Space |
hop / paddle |
| scroll | camera distance |
J |
journal |
M |
enlarge the chart |
H |
hide the interface |
Esc |
release the cursor |
Water is swimmable. The whole moon is reachable on foot.
Development — unbundled ES modules, an importmap, and a vendored three.js. This is the version worth reading.
npm install
npm run dev # → http://localhost:5173/
Single file — everything inlined, three.js included, ~894 kB. This is also
what the live site serves, and it opens straight off a file:// URL.
npm run build # → docs/index.html
Built by Claude Opus 5 in Claude Code, in one session, from four human messages. Nothing else was specified. The prompts are reproduced verbatim below with what I took each one to mean — including where I read something into them that was not literally there.
https://www.anthropic.com/news/claude-opus-5 - "Finally, Opus 5 is capable of producing much stronger visual outputs:" https://assets.claude.ai/brand/artifacts/blog/opus/5-aeolus-demo.html & https://assets.claude.ai/brand/artifacts/blog/opus/5-sectio-demo.html#cell --- so, with that in mind, make me a small "gamejam" game. you're not allowed to ask me for clarification or direction, you have to do this start to end yourself. The theme of the game jam is "explore" which means you'll have to make it so the player can explore, and then give them something to explore! And the only other requirement I have is that it's using 3D, because I want to see Opus 5 do 3D stuff. OKAY; you have 1 million tokens budget, Opus 5 live, bypass permissions on, and a good number of MCPs live, like Chrome. I'll check on the results tomorrow.
Hard constraints extracted: a small game-jam-scale game; theme explore; 3D; no clarifying questions; finish end to end unsupervised.
How I read the two demo links. These were the only art direction on offer, so
I fetched both and read their actual CSS rather than guessing at it — with curl,
not the markdown converter, because the converter strips the stylesheet and the
stylesheet was the entire reason for looking. What I took from them: dense
design-token theming, precise letter-spacing, small type (10–13px), monospace
reserved for anything numeric, one restrained accent against a dark ground, and
code that explains why in its comments. I did not copy their palette — those are
instruments and this is a game — but the discipline is theirs. It is where
PERIPLUS's two-ink rule comes from: cyan is the instrument, amber is the lantern,
nothing else gets a hue.
How I read "explore". The weak reading of this theme is a big map with collectibles on it. I wanted exploring to be the mechanic rather than the setting, so the world is literally unfinished until you walk it and walking is the only thing that can write to it. The chart in the corner and the ground under your feet read from the same canvas, so there is no gap between "the map fills in" and "the world becomes real" — they are one operation. That single decision generated nearly every other one in the project.
How I read "3D". As show me real 3D work, not use a 3D library. Hence: a sphere you can walk all the way around with gravity toward its centre; a terrain field evaluated as a pure function of direction; an equirectangular projection painted at runtime and sampled in-shader; an analytically ray-traced planet and ring system in the sky; and movement carried in a parallel-transported tangent frame so that the poles are places you can actually stand.
How I read "small". As a real constraint rather than a hedge. One mechanic, one world, eight destinations, ~4,300 lines. I deliberately did not add caves, weather, crafting, or a second moon.
How I read "you're not allowed to ask for clarification". Every fork became
mine to settle, so I settled them by measurement rather than taste wherever that
was possible — throwaway Node probes that reported the land/sea ratio, the biome
distribution and the cairn spread before any of it was wired to a renderer. About
a dozen numbers in src/config.js were set that way instead of by eye.
(note you may have to wait for Chrome MCP access, other claude code sessions may use it today; but it's temporary. You should be temporary too, when you're done with Chrome, free up the Chrome MCP for other sessions)
Interpretation: a courtesy constraint on a shared resource. It ended up mattering more than it looks. The Chrome MCP was held by another session for most of the build, and it went unavailable at exactly the point I needed a browser to diagnose a black screen.
Rather than block on it, I wrote my own CDP driver — about seventy lines against
Node's built-in WebSocket — pointed at a separate headless Chrome on its own
profile, and never contended for the shared instance again. That harness produced
every screenshot in this README and is the reason the game could be tested at
all: navigate, wait, evaluate arbitrary JS in the page, screenshot, read the
console. It is also what made the window.PX debug surface worth building.
You're at 47% context; what more do you want to do
Interpretation: neither "stop" nor "keep going" but a request for an honest assessment of what was still unverified. So I led with the actual gap instead of a feature list: I had never played it. Everything to that point was headless screenshots, which meant two entire systems had never once executed.
That produced four targeted verifications, all of which passed:
- Audio — ~260 lines that had never made a sound, because headless Chrome
blocked the AudioContext on every run. Re-ran with autoplay overridden, then
tapped the master bus with an
AnalyserNodeto confirm real signal (peak 0.46, RMS 0.14) rather than merely nodes constructed without throwing. - Mouse-look — 100 px of movement yields exactly 13.2°, and after 220 look events the heading vector is still unit-length and tangent to the sphere within 1e-6. That last check is the one that matters: it demonstrates the tangent frame does not drift, which was the whole reason for building it that way.
- Compass bearings — checked against cases with known answers (N=0°, E=+90°, S=180°, W=−90°). A sign flip here would have sent "north" south, and no screenshot would ever have caught it.
- Pole traversal — walked from 88.6°N over the pole to 75.4°N with no NaN anywhere, exercising both the parallel-transport frame and the pole-fill branch of the survey painter.
Okay, your next task is to make this into a public repository on my personal github account, TonyDowney, and find a way to make the game playable via the internet. I typically spin up a firebase project for free Spark hosting but I don't particularly care; free and stable is the way to go. Make sure the repo includes a README that goes over all human prompts made to create this game, and how you interpreted it. And for reference, we're at 48% context of the million tokens; well under.
Interpretation of "free and stable": Firebase Spark was offered but explicitly
not required, so I chose GitHub Pages served from /docs. It lives in the
same repo, needs no second project and no separate CLI login, and has no
credential that can quietly expire.
I deliberately did not build it in GitHub Actions. Two reasons: the available
token carried no workflow scope, and a committed artefact cannot fail to deploy
because a runner image changed underneath it. npm run build regenerates
docs/index.html, and that one file is both the live site and the thing you can
download and open offline.
One thing this prompt made necessary that the game had not previously needed: a public link attracts phone traffic, and this is a pointer-lock-and-WASD game. The title card now says so on touch devices rather than letting somebody download 894 kB and only then discover they cannot walk.
The moon is a pure function. src/field.js answers one question — given a
direction from the centre, what is here — and every other system asks it: the
mesh, the prop scatter, the chart, the ground under the walker's feet, the noun in
the readout. There is no baked heightmap anywhere. Domain-warped fBm makes the
continents, offset ridged noise carves ranges and basins into them, and
e²/(e²+k) maps elevation to displacement — chosen because its slope at the
waterline is zero, which is where flat, walkable beaches come from for free.
The survey is a canvas. src/survey.js owns a 1024×512 equirectangular 2D
canvas that starts black. Standing somewhere paints a soft white disc onto it — as
an ellipse, because equirectangular projection stretches by 1/cos(latitude) and
a circle on the moon is not a circle on the chart. That canvas is uploaded as a
texture and every shader in the scene samples it to decide whether the ground it
is drawing is real yet. The same canvas, multiplied against a baked shaded relief,
is the chart in the corner. One source of truth: if it is inked on the chart, it
is coloured in out there.
The horizon set the design. On a 155-unit sphere the horizon at eye height is about 27 units away, which is why the survey radius is 15 and not 40 — any larger and the reveal would consume everything visible and the mechanic would erase itself. Climbing is how you buy sight: from a 20-metre summit you can see 95 units, and almost all of it is still a drawing.
Lighting is geography. The sun is fixed in world space, so the sky is a
function of where you are standing rather than of what time it is. Terrain, sea,
props and the sky dome all resolve their distance haze by calling the same
skyBase() — the fog is never a colour someone picked, it is literally the sky in
that direction, which is why the horizon dissolves instead of ending.
Everything is generated. No textures, no models, no audio files. The gas giant KEL, its banding, its storm, its ring and the ring's shadow are ray-traced against the sky dome in a fragment shader. Sound is oscillators and shaped noise through one synthesised reverb, and the chord drops a fourth on the night side.
src/
config.js every number that changes how it feels
noise.js Perlin 3D, fBm, ridged multifractal
field.js the moon as a pure function
planet.js icosphere, terrain material, sea
glsl.js shared GLSL: sky, ground palette, chart, reveal
sky.js gradient, stars, aurora, KEL and its ring
survey.js the discovery canvas and the baked relief
props.js five instanced meshes, ~22000 objects, 5 draw calls
cairns.js site briefs, placement search, the markers
player.js walking on a sphere; camera
ui.js readouts, chart, compass, journal
audio.js synthesised everything
postfx.js bloom, SMAA, tone map, grade
main.js boot, loop, glue
window.PX exposes the live scene in the console. PX.teleport(lat, lon) and
PX.goTo(n) are the useful ones — most of what is worth looking at is on the
night side, four hundred metres away.
Two were traps in three.js. The third was mine, and it is the most instructive.
UnrealBloomPasscannot read from a multisampled composer target. It readsreadBuffer.textureand then blends its result additively back intoreadBuffer. On an MSAA target three resolves the buffer to serve that read and then callsinvalidateFramebufferon the multisample colour attachment — so the scene is gone before the bloom lands on it, and the frame comes back black with only the bloom in it. SMAA does the antialiasing here instead.LightShadownever callsupdateProjectionMatrix(). Assigningleft/right/top/bottomon a directional light's shadow camera does nothing on its own; the frustum silently stays at its default ±5 and only the ground directly beneath the player ever receives a shadow.- The prop scatter put all ~10,000 objects in the northern hemisphere. Candidates come off a Fibonacci spiral, which runs pole to pole — so visiting it in index order filled every per-type cap out of the first band of latitude it crossed, then rejected the entire rest of the moon. The landing site was bare and the nearest tree was 104 metres away. Shuffling the visit order fixes it, and the caps now thin the population uniformly instead of clipping it geographically. A screenshot showed the symptom; only measuring the distance to the nearest instance of each type found the cause.
src/config.js is the whole surface. seed makes a different moon. landBias is
the land/sea ratio. revealRadius is how much of the world you are allowed to
make real at once — the single most load-bearing number in the game.
Built with three.js. Everything else is in this repo.



