AI
How ChatGPT Astra Built My 3D Portfolio Game, Step by Step
September 202612 min read

It is a 3D game version of this portfolio at matthewswong.com/play. You arrive by boat at dawn and walk four islands — the Harbor, Workshop Island, Product Cove and the Lighthouse — where every stall, building and lamp is a real project, product or certification from the site. The goal is to light the lighthouse before dark, which takes about seven minutes.
The playable loop — route, world, controller, HUD, cards, gates and tests — landed in one session on 12 September 2026 from a 1,015-line PRD. A second pass on 18 September rebuilt the world's look, fixed the movement bugs, made it work on phones and added bloom. Writing the brief and reviewing the output took me longer than either session.
Yes. Phones get a battery-saver tier by default with a floating touch stick, a look surface and jump and interact buttons, and the dialogs are capped to the viewport so they scroll on a landscape phone. The whole loop is tested on an emulated Pixel 7 with real touch events, but a pass on physical devices is still on the list.
A brief it could verify: a product requirements document with goals in order, non-goals, budgets written as CI gates, and the data rules the game must respect. After that it needed me to look at screenshots and say what felt wrong, and it still needs the three human-only steps — the selfie for the real avatar, a Blender art pass, and a test on real phones.
By turning every rule into something that runs. A prebuild gate fails the build if a project has no placement in the world; an import guard keeps three.js inside the game's folders; a smoke test and a memory soak run on every pull request; and three Playwright scripts replay movement, touch input and the full game on desktop and phone. The agent also screenshots the real game after each change and reads the image back.

Key Takeaway
The Archipelago is a 3D portfolio game built by ChatGPT Astra from a 1,015-line product brief: six gated phases, a headless screenshot loop so the agent could look at its own output, and three human-only steps. This post walks through the ideation, the framework, the build, and the movement and colour bugs only tests could see.
I kept opening my own portfolio page and seeing the same thing: a grid of cards, twelve projects, and no reason to click any of them. The work below the fold — the ERP that runs a factory floor, two products with real users, six certificates — might as well have been in a drawer. In September 2026 I decided the portfolio should be a place you walk through rather than a page you scroll, and I decided not to build it by hand.
This is the record of how that page became The Archipelago, a walkable 3D game at /play, and how ChatGPT Astra built it: from a brief I wrote, through six gated phases, to the second pass that fixed what only a test could see. I have the commits, the test output and the screenshots, so every number here has a source.

The concept came before any code, and it came out of one rule: a game needs one goal in the first thirty seconds. Ours is light the lighthouse before dark. You arrive by boat at dawn, playing as me, and the daylight is the progress bar: dawn, morning, afternoon, dusk, one step per island finished. Each island is a section of the site, so the metaphor also decides the engineering: an island is a chunk that streams in when you walk toward it.
The one design rule I insisted on is the parity rule: exhibits are derived from the same modules the pages render from — lib/projects.ts, lib/certifications.ts and lib/products.ts — and never retyped. A prebuild gate fails the build if a project exists without a placement in the world, or a placement without a project. That single rule is why the game cannot drift from the site.
I did not prompt the agent with 'build me a 3D portfolio game'. I had it research first, then wrote a product requirements document with it: 1,015 lines, sixteen sections plus appendices. Problem, goals ranked in order, non-goals, references it had actually checked (library versions from the npm registry, browser support from caniuse, avatar vendors whose domains resolved), the concept, the world, the player experience, art direction, architecture, performance, reliability, accessibility, SEO, privacy, metrics, the delivery plan, risks, and the open questions for me. The PRD is the prompt; everything after it is execution.

Two things in the brief did more work than the rest. Non-goals kept the first release to four islands with three more visible on the horizon, so scope had nowhere to creep. And every budget was written as a CI gate rather than a paragraph: an import guard that only lets three.js be imported under the game's own folders, so the rest of the site cannot grow a 3D dependency by accident; the placement parity check; a Playwright smoke test and a memory soak test that run on every pull request; and a notice gate that tells the visitor the download size, computed from the build manifest, before a single byte of engine ships.
Write the brief so a machine can verify it. A sentence like 'the game must stay light' does nothing; 'three may only be imported under app/components/game and lib/game, checked at prebuild' becomes a script the agent runs after every change.
The PRD's delivery plan split the work into six phases, each ending in something checkable rather than a feeling. The estimates assumed the agent would do the code phases and that three steps were human-only: the selfie for the avatar, the art pass, and looking at the output.
| Phase | Outcome | Gate |
|---|---|---|
| 0 — Foundation | Chrome-free route, dynamic import, capability probe, notice gate, Map fallback, import guard, CI smoke test | Every existing page's built HTML unchanged; smoke test green |
| 1 — Vertical slice | Harbor, stand-in avatar, capsule controller on a BVH, camera, HUD, one real exhibit card, deep links, saved state | The whole loop playable |
| 2 — Avatar | Avaturn selfie to GLB, Mixamo clips, Blender clean-up, at most 3 MB | Four of five people who know me name me from a still |
| 3 — Workshop + Cove | Island art, 14 exhibits, placement parity, screenshot textures, merges, day advance | Budgets green; first visual review |
| 4 — Lighthouse | Six lamps, the climb, dusk, the beam, finale card, share | Full playthrough under eight minutes, no dead ends |
| 5 — Launch | Touch controls, accessibility, Indonesian copy, OG stills, SEO, device matrix, analytics, docs | Every performance and reliability row green |
Phases 0 and 1, plus the code side of 3 and 4, landed in one session on 12 September 2026 with a procedural world and a mannequin standing in for the avatar. Two deviations from the brief are worth naming, because both were the agent pushing back with evidence: it kept the header and footer off the game route with a pathname switch instead of the route-group split the PRD asked for, because 32 scripts and documents hardcode the pages folder; and it pinned React to 19.2.7 because React Three Fiber 9.7 declares a range that excludes 19.3.
What the gates could not measure was the thing I saw first on 18 September: the game was correct and ugly. Islands were poker chips, palms were five cones on a stick, every building was a box with a slab roof, and I was a black capsule. So the second pass got a brief of its own, and the agent rebuilt the world without downloading a single model: island ground as one organic geometry that the renderer and the collider both read, so the shore you see is the shore you bump into; palms with curved, ringed trunks and drooping fronds; grass, bushes and rocks scattered by a seeded generator that avoids every footprint and path; textures drawn on a canvas at runtime — planks, thatch, corrugated sheet; water that knows where every shore is, with shallows and a foam line; and seven buildings rebuilt as a thatched warung, hip-roofed stalls, a corrugated ERP shed, a glass gym, a carwash bay, lamp posts and a banded lighthouse.

Instancing is why the cost stayed sane: every static thing on an island — hundreds of grass tufts, every frond, every rock — is one instanced draw whose matrices are written once on mount, instead of the per-frame re-decomposition the helper library does by default. The memory soak test after the pass cycled the islands three times: heap 21.2 MB at baseline, 21.9 MB after the cycles, 19.1 MB after leaving and returning, with geometry and texture counts back at baseline. That test exists because the brief demanded it; it caught nothing this time, which is the point.
Every change in the second pass went through the same loop. The agent read the file it was about to change and everything that imported it, planned, implemented, ran the typechecker, and then did the step that made the difference: it rendered the real game headlessly and looked at the picture. Chromium in headless mode can run WebGL2 through SwiftShader with no GPU, and the game already exposed a small debug bridge in development for its soak test — teleport, go to an island, read the renderer's counters. Twenty lines of Playwright turned that into eyes.
// scratch/shoot.mjs — the agent's eyes. Headless Chromium renders WebGL2
// through SwiftShader, so no GPU is needed; the debug bridge the game exposes
// in development (window.__archipelago) moves the player without walking.
const browser = await chromium.launch({
headless: true,
args: ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto("http://localhost:3000/en/play/", { waitUntil: "networkidle" });
await page.getByRole("button", { name: /set sail/i }).click();
await page.locator("canvas").first().waitFor({ state: "visible" });
for (const island of ["workshop", "cove", "lighthouse"]) {
await page.evaluate((id) => window.__archipelago.goto(id), island);
// The follow camera is critically damped: give it two seconds to settle,
// or every still is a smear of the previous position.
await page.waitForTimeout(2000);
await page.screenshot({ path: "shots/" + island + ".png" });
}
// stats() returns draw calls, triangles, geometry and texture counts —
// the numbers the soak test watches, read back after every change.
console.log(await page.evaluate(() => window.__archipelago.stats()));Under a software renderer the game runs at about three frames a second, so the script waits for the damped camera to settle before each still, and the agent read every image back before deciding the next change. That is how it noticed a pale band at the horizon (the fog colour did not match the sky), grass tufts that read as spikes, and foam blobbing across the shallows — none of which any gate would have reported.

Movement felt wrong and I could not say why, so instead of describing it I asked for a reproduction. The agent wrote a Playwright script that holds keys for six seconds and samples the player's position and the camera's yaw through the bridge. The baseline run put numbers on three feelings at once.
Backing up spun the camera 7.4 radians in six seconds and the player never came back, because the camera auto-aligned behind the avatar while reversing: press back, the avatar turns to face the camera, the camera swings round, forward flips, and you walk a circle. A straight walk to Workshop Island stalled at the harbor's edge for 55 seconds because acceleration was clamped per axis, so the heading drifted eighteen degrees in the first half second and the path curved off the causeway into water. And the island's lip and the causeway edge acted as walls, because the collision capsule started at the feet.
// Wrong: each axis is clamped on its own, so a diagonal target ramps the
// shorter axis to full speed first. For half a second the velocity points
// along the diagonal, the avatar turns to face it, the camera aligns behind
// the avatar, and "forward" has quietly rotated. Over 50 m the path curves
// off a 4 m causeway onto the beach, where the water blocks both axes.
velocity.x += clamp(wanted.x - velocity.x, -maxChange, maxChange);
velocity.z += clamp(wanted.z - velocity.z, -maxChange, maxChange);
// Right: clamp the change *vector*, so the velocity ramps along the wanted
// direction from the first step and the heading never drifts.
let dx = wanted.x - velocity.x;
let dz = wanted.z - velocity.z;
const change = Math.hypot(dx, dz);
if (change > maxChange) {
dx *= maxChange / change;
dz *= maxChange / change;
}
velocity.x += dx;
velocity.z += dz;The fixes were small once the numbers existed: align the camera only while the input pushes forward, clamp the change vector, start the collision capsule 0.4 metres above the feet so anything lower is a step the ground snap lifts you over, and widen the causeways from four metres to five. The same afternoon the phone run found two more that no desktop test would have: the logbook panel sat exactly where the left thumb lands and swallowed every touch, so the stick never appeared; and the pause menu was taller than a landscape phone and could not scroll, so Resume was off-screen with no way to reach it. Three scripts now stand guard:

The last graphics step was bloom on the high tier so lamps, lanterns and the sun glow. It exposed a real bug rather than adding polish: with the post-processing composer in the chain, every still came back pale and washed out. The bloom threshold was the first suspect and the wrong one. The sky and water shaders had never included the renderer's output transform, so without the composer they displayed too dark and saturated — and the palette had been tuned against that — while through the composer they were converted correctly and looked pale. Two tiers were showing two different games. The fix was to route both shaders through the same tone mapping and colour-space conversion as every other material and re-express the palette once; the battery-saver tier and the desktop tier now render the same picture.
Three things stayed human, exactly as the brief said they would. The agent cannot take the selfie the real avatar needs, so the traveller with the backpack is a stand-in. It cannot do the Blender art pass that would replace the procedural islands with authored ones. And it cannot stand in a room with a real phone; every mobile result here is an emulated Pixel 7. Beyond those, one lesson from an earlier project held: gates verify budgets and cannot see dull, so the visual review is a scheduled step with a date, not something I hoped would happen.

The game is live at matthewswong.com/play, in English and Indonesian, on desktop, touch and gamepad. Every exhibit in it is a real project, product or certificate from this site, and the mailbox at the harbor is the contact page.
The rule I carry out of this: write the brief so a machine can verify it, make the machine look at its own output, and keep the three human-only steps human. An agent given a paragraph builds a paragraph. Given a PRD with gates, a screenshot loop and a place to push back, it built a world I would not have finished alone — and told me, with numbers, where it was wrong.
Sources and further reading