Devlog
Building Swing City
A procedural city that started as a Blender script became a web-swinging, wall-crawling, car-knocking browser game with gamepad, touch, and WebXR support — then grew a real multiplayer mode over a Cloudflare Worker. This is the story of how it got built, and the bugs that had to be found before it felt right.
One July 2026 session ran 13 rounds of real-hardware VR playtesting back to back — ship a batch, play it on the actual headset, get bug reports, ship the next batch. Two of those bugs took a lot more than one round each.
01 · Origins
A city that starts as a seed
Swing City didn't start as a game. It started as city_generator.py, a Blender/Python script that lays out a low-poly, Blade Runner–style grid from a single seed — streets, traffic-light-obeying cars, rain, neon towers — the same layout every time you feed it the same number.
Porting that into a browser game meant carrying the same layout math, window-facade shader logic, signal cycle, and stop-and-go traffic simulation over into a single self-contained index.html — one file, Three.js pulled from a CDN, no build step. Feed it the same seed in Blender and in the browser and you get the same city both places.
02 · Feel
Making it feel like a game
The first version had one verb: swing. Fire a web at a building, get yanked toward it. It also had a bug baked into the yank itself — the rope started 10% shorter than the actual distance to the anchor, so attaching snapped you toward it in a single frame instead of swinging out from where you stood. Fixing the math (the rope now starts at your exact current distance, only ever clamped shorter for street clearance) was round one. Round two found a second cause of the same symptom: a ground-clearance clamp that could still shorten the rope below the true attach-moment distance for some anchors — which explained why the teleport didn't happen on every swing. The fix was to remove the clamp entirely and let the existing collision system handle ground safety instead of a rope-length hack.
A public-facing rewrite added the things that make a physics sandbox feel like an actual game: a 240-unit sky-fall spawn instead of starting already standing on a rooftop, typed pickups (score orbs, a noir desaturation effect, jump boosts, slow-time, and a rare zombie trigger — each a distinct shape and color so you can tell them apart before committing to a swing), zombies that rise out of the street with stacking contact-damage, and a full synthesized audio layer: thwip on web-fire, a collision thud, pickup chimes, an explosion boom, and a procedurally generated noir chiptune loop built from the same seeded RNG as the city, so the soundtrack is reproducible per-seed without repeating the exact same melody every load.
That same pass root-caused an always-explode bug instead of just patching around it: the out-of-bounds margin sat 14 units past the city edge, and the ground plane extended roughly 90 more units past even that — so you could walk far outside the playable world while still standing on lit “city ground,” with nothing telling you you'd already left it. The fix tightened the margin, shrank the ground to match, and added a visible glowing boundary wall at the exact trigger radius, so the edge became something you see coming instead of an invisible tripwire.
Movement itself got redesigned once holding spacebar turned into a stutter-hop instead of a jump: the old code re-applied jump velocity every single frame Space was held while grounded — up an inch, gravity pulls back down, re-fire immediately. The fix was to make web-fire edge-triggered (a fresh press does something, holding does nothing extra) and drop standalone jumping in favor of treating every press as a pull — toward a building if one is in range, toward nothing in particular if it isn't, but always a deliberate motion rather than a repeating one.
03 · Postmortems
Bugs found the hard way
Some of these only showed up once real hands were on the controller — a category of bug this project kept running into, and kept naming honestly in its own commit messages rather than quietly re-patching.
›The right stick that got re-specified 10 timesVR input
On this specific headset, the right controller's axes[2]/[3] came in swapped relative to nominal X/Y, and both sign-flipped from the WebXR spec. That alone would've been a one-line fix — except each attempted fix got confirmed wrong on real hardware, then re-patched, then re-confirmed wrong again, across 10 separate rounds of playtesting. By round 8 the compensating flips had piled up enough that the variable names no longer meant what they said. The breakthrough was throwing out the spec entirely: Alex confirmed first-person look was correct on hardware, and that one anchor — smooth output is physically X, therefore the variable driving it IS physical X — let the rest get derived mechanically instead of reasoned from names. Round 9 shipped the fix; Alex called it dead-on and said “LOCK THAT.” The lesson is now a code comment: after enough patches, stop reasoning from names or spec — find one confirmed-correct input→output pair and derive everything else from it.
›The VR avatar that was invisible the entire timethree.js
Three separate fix attempts all flipped a visible flag — wrong diagnosis every time, since the flag was true the whole session. The real cause was a three.js axis-convention split most people never hit: Object3D.lookAt() orients cameras and lights toward -Z, but every other Object3D — including a plain Group, which is what the camera rig actually was — orients +Z. So cameraRig.lookAt(player) faced the VR view 180° away from the avatar every single frame — it sat directly behind the camera, fully visible, just never in view. Measuring the rig-forward · to-player dot product confirmed it: -0.84 before the fix, 1.000 after. The fix swapped to a pure yaw-only rotation instead of any lookAt-driven approach — and had to carefully preserve the existing 180° offset already baked into the stick calibration above it, or fixing this bug would have silently re-broken the one already locked in.
›The car that never actually tumbledphysics
Knocking a car sent it flying — score ticked up, the sound played — but the car itself never really tumbled. Cars spawn at y=0, so on the very first frame after a knock, integration only climbs a fraction of a unit — nowhere near clearing the 0.25 ground-settle threshold on its own. Without a check on which direction the car was actually moving, every knock silently snapped straight back to grounded-and-motionless on frame one. The physics call fired correctly; the physics itself never got to play out. The fix only settles a car while it's falling (vel.y <= 0), not on the way up.
›The framerate that only got worse the longer you playedreal-world only
WebXR support required switching the render loop to renderer.setAnimationLoop(loop) — but the old self-recursing requestAnimationFrame(loop) call was left in place underneath it, doubling and compounding scheduled frames every tick. The framerate didn't start bad — it got steadily worse the longer a session ran, which is exactly the shape of a leak like this. This one is invisible to headless or automated verification, because rAF never fires in that kind of environment at all — it only ever showed up by actually playing.
›A web line invisible on some GPUsrendering
The web-swing line was drawn with plain THREE.Line, which relies on gl.LINE_WIDTH. Most GPU drivers clamp that to 1px regardless of what you ask for, so depending on the hardware the line either drew thin and faint or effectively vanished. Swapping to Line2/LineGeometry/LineMaterial draws real screen-space geometry instead of relying on a driver-dependent primitive, so line width actually means something everywhere.
›Building facades that ignored the scene's own lightshaders
“Brighten the world a little — still night, but not 90% black,” was the ask. Turning up ambient light and exposure barely moved the buildings at all. The dark wall material between the windows was a raw shader computing its own color and ignoring ambient and exposure entirely — no amount of turning the global lighting levers was ever going to touch it. The fix was to change the shader's actual base-color constant, not keep tuning levers that could never reach it.
04 · Input
Everywhere you play
Gamepad and touchscreen input map onto the same primitives keyboard and mouse already drive, so support just works everywhere keyboard does — no separate input paths to keep in sync.
WebXR needed a different trick, because a headset owns the camera's transform the moment it starts presenting — you can't just keep moving the camera directly the way desktop play does. The fix is the standard WebXR “dolly” pattern: the camera is a child of a movable rig group, not moved on its own. Outside XR, the rig sits at identity — a child's local transform under an identity parent is its world transform, so desktop play stays byte-for-byte unchanged. Only once a headset is presenting does the third-person follow-camera code retarget from the camera to the rig instead — the headset then supplies the player's actual head look on top of the same follow-the-player positioning desktop already used.
05 · Multiplayer
Going multiplayer
Multiplayer is a pure relay: a Cloudflare Worker backed by a Durable Object, running one “Room” per game with no server-side physics at all. Every client sends its own state roughly 15 times a second — position, look yaw, web-anchor state, alive flag — and the Durable Object fans it straight out to everyone else. Position is whatever the client says it is, the same trust model the rest of the game has always used; there was never a server before this, so there was never server-side authority to lose. It runs on the WebSocket Hibernation API, so a room full of idle-but-open sockets doesn't pin the Durable Object in memory between messages.
The rules are joust rules: land on top of another player and they explode. Getting that to read correctly for everyone in the room took a couple of passes — the first version only called the victim's own explosion effect, so bystanders just saw an avatar vanish the next time an alive: false update arrived, with no explosion to explain it. And remote avatars first rendered in one shared placeholder color, because the server tagged each connection with an id but not the color it had assigned — fixed by broadcasting the color on every state update, same as the id.
The first real deploy hit a genuine Cloudflare-side network incident in the ENAM/WNAM region — confirmed against Cloudflare's own status page, not a misconfiguration on this end — that resolved on its own; the deploy was verified end-to-end against the live Worker once it cleared. Local development doesn't need any of that: wrangler dev --local runs the same Durable Object via Miniflare with zero Cloudflare login required, which is also how the whole relay — connect, remote-player sync, joust detection, the server-relayed broadcast, and the local game-over-on-being-jousted path — got verified before any of it shipped for real.
06 · Appendix
Sample document: the delegation manifest
After a family playtest of the multiplayer build, the follow-up list — sixteen items, everything from a two-line color fix to a full power-up framework — got routed by who should actually build each one: the local model fleet, Gemini, or a given Claude tier. This is the real planning document that came out of that pass, reproduced as-generated. It's an internal task-routing artifact, not a feature list — kept here as a concrete example of what that delegation process actually looks like on a real project.
01›WebXR first-person mode, default in VRSonnet · 90%
Touches the cameraRig / isPresenting branch built this session — genuinely easy to get subtly wrong. That exact code already shipped one real regression (the duplicate requestAnimationFrame framerate bug). Needs new first-person math, a mode flag, and a toggle binding, still defaulting to third-person outside VR.
Fleet-alone confidence: 35%. Once the camera math exists, a narrower “wire up this toggle key” slice could go to fleet or Gemini.
Still can't test in-headset from here — needs your eyes once it ships.
02›VR death screen — can't see the restart menuFleet · 70%
Simplest fix skips 3D UI entirely: auto-restart after a short delay when gameOver && renderer.xr.isPresenting. Small, precise diff.
If you'd rather have a real head-locked menu — a text-sprite plane parented to the camera — that's bigger, and shares infrastructure with the name-tag and leaderboard work below, so it'd sequence after those.
03›Wall-climb: face the wall, reach the roof without releasingSonnet · 75%
Two parts. Orienting the pawn to face into the wall while climbing is mechanical once you know the wall-normal is already computed per-axis in the collision code — that half alone is roughly 60% fleet-able with a precise prompt. Smoothly transitioning onto the rooftop at the top of a climb needs reasoning about how the wall-crawl state interacts with ground/roof collision resolution — that half needs real context. Bundling as one task.
04a›Rain sometimes audible but not visibleFleet · 55%
Possibly the particle live-count rounds to near-zero at low intensity while the audio gain stays audible. Isolatable and testable independent of the sync problem below.
04b›Rain / world state doesn't sync in multiplayerOpus · 15%
Root cause: every client independently consumes the same seeded RNG stream for weather timers, but at different real-world moments depending on local frame timing — so weather desyncs between clients almost immediately after load.
Fixing it properly means the server periodically broadcasts authoritative weather state, or weather gets derived deterministically from wall-clock time instead of a per-client-consumed PRNG stream. Genuine distributed-state design, not a tuning tweak — staying on Claude.
05›Border walls bounce hard instead of grace-period deathSonnet · 65%
Well-bounded physics change in code I know precisely. I'll write the first version — reflecting velocity off the out-of-bounds check — then a fleet or Gemini pass can tune bounce strength and feel iteratively once the mechanism exists. If I hand over a precise spec with the exact code location up front, fleet could plausibly take the whole thing solo.
06›City 4× bigger, more variety, named buildings, KatakanaGemini · 65–70%
Scale + performance check
Sonnet checks that 4× the buildings doesn't tank draw calls; fleet can write the constant tuning once bounds are known. ~70%.
More building “type” variety
New archetypes, not just recolors — more creative than mechanical, staying on Sonnet.
Names + floating labels
Gemini Flash generates the name banks, but I'll personally review the profanity blocklist rather than delegate it. Names — English and Katakana both — will be invented pseudo-words from syllable recombination, not real dictionary words: real words (especially Japanese, which I can't fully vet for unintended meaning) carry a rude-collision risk a nonsense generator doesn't. ~65% on the generator; the blocklist review stays with me regardless of tier.
07›3-letter initials, profanity-filtered, floating over your pawnSonnet · 75%
The filter list is a judgment call with real kid-safety stakes — I'll curate that myself, not delegate it. The mechanical half (text input UI, floating name-sprite rendering) goes to fleet once I've built a shared text-sprite helper, which the leaderboard and building-name work below will also reuse. 75% on that mechanical half.
08›Spinning, pulsing rooftop coin on every buildingFleet · 75%
Pure mechanical Three.js animation — rotation.y += dt*speed, scale following a sine pulse. The effect-trigger name broadcast (“PLAYERNAME triggered X MODE”) is really part of the orb-effects framework below and sequences after it.
09›Session leaderboard — overlay, 3D billboard, new-leader bannerSonnet · 65%
Score isn't currently broadcast in multiplayer state at all — this touches the core relay message shape, so the first wiring pass stays on Sonnet. Once that's in, and the text-sprite/billboard helper exists (shared with items 6 and 7), the billboard rendering itself is fleet-able at roughly 65%.
10›Streets read as pure black — want dark grey definitionFleet · 90%+
Unlike the building-facade fix, which needed a raw-shader root-cause dig, the road and sidewalk use plain MeshStandardMaterial — they already respond normally to lighting. This is a two-line color bump. Trivial enough I might just fix it directly regardless of who else touches this list.
11›Car honks, plus missing collision sounds by typeFleet · 70%
The honk mechanic already exists — quiet and probabilistic by design — you may just not have caught one yet, or it's tuned too quiet; I'll do a quick live-audio check myself first. The missing sounds (player-vs-building has none at all; player-vs-player soft contact has none) follow an existing bump/knock template exactly. Good “copy this pattern for two more cases” fleet task.
12›Add trucks and jeeps to trafficFleet · 65%
New procedural box-based meshes following the existing car-mesh pattern are fleet-friendly. I want a second look at whether bigger vehicles need their own collision radius, so knockback and blocking still feel consistent against the smaller cars.
13›Right trigger also fires the web, Spider-Man-styleFleet · 90%
A one-line addition to the existing gamepad web-held check. Basically free — I might just do this one directly.
14›Orb power-ups — every effect gets a you-only AND an everyone versionOpus · framework
Finalized 2026-07-03. The single biggest, most interconnected item on the list — now bigger, since almost every effect ships as two separate pickups (a solid orb for you-only, the same orb with a rotating halo ring for everyone). The framework — active-effect state, 30-second timers, stacking, torus duration-extenders, multiplayer broadcast — plus the physics-heavy effects (energy blast, pong mode, bomber, ghost, web-anywhere) need to not regress car, player, and building collision while they're at it. Staying on Claude.
Once that scaffolding exists, the simple effects become genuinely fleet-delegable as isolated “add one more effect to the framework” tasks — each roughly 60–70% with a precise per-effect prompt, since the you/everyone plumbing is identical every time (self-apply vs. broadcast-and-everyone- applies-to-self, reusing the joust/knock relay pattern already built).
Locked design notes from this round
Fly is full Superman flight — gravity is disabled entirely while active, not just floaty. Distinct from the new low-gravity idea below, which keeps gravity but weakens it.
Pong mode and energy blast both fire forward from the jousting stick, not omnidirectional — pong mode launches ball projectiles dead ahead, energy blast is a forward beam. Same launch-origin/direction convention as the existing web-fire aim logic, so this reuses code that already exists.
| Effect | You-only | Everyone |
|---|---|---|
| Giant | 10× scale, your collision | server-wide giant free-for-all |
| Tiny | you shrink | everyone shrinks |
| Fly (Superman) | no gravity, just you | whole city floating, no gravity |
| Low-gravity (new) | huge floaty jumps, gravity still applies | whole city moonwalks |
| Speed | you move at 2× | everyone at 2×, chaos |
| Super jump | your jumps 5× | everyone's jumps 5× |
| Invincibility | you can't die | nobody can die — goofy stalemate |
| Fire | you explode anyone you touch | mutual — everyone explodes everyone on contact |
| Energy blast | your stick fires a forward beam | everyone's stick fires a beam |
| Pong mode | balls launch forward from your stick | everyone launches balls |
| Bomber | you drop bombs | everyone drops bombs |
| Ghost (new) | you pass through everything | whole city no-collision free-for-all |
| Web-anywhere (new) | your web always finds a phantom anchor | nobody ever whiffs a web, city-wide swing party |
| Magnet (new) | pulls nearby orbs toward you | pulls all players toward the trigger point — dogpile |
| Confetti (new) | cosmetic trail, no gameplay effect | everyone trails confetti |
| Zombie summon (new) | a personal chaser hunts your target | triggers the full zombie wave early, city-wide |
Three exceptions that don't take the you/everyone split:
- Time-freeze — self-only only; an “everyone” version is just normal time, contradicting the point (the existing noir/slow-time powerup already covers that case).
- Disco / city recolor — everyone-only; it's an environment effect, so “only you see it” isn't meaningful.
- Shrink-tag — a genuinely different mechanic, not a variant of tiny-mode: touch another player to shrink them specifically, an offensive targeted tool rather than a self/everyone buff.
All new ideas and the you/everyone split confirmed as-is, 2026-07-03 — no longer a proposal.
15›Jousting stick prop, fix head-stomp, add stick-poke killSonnet · both mechanisms
Per your answer, both should work: landing on someone's head, or poking them with the stick from any angle.
Visible stick prop
Pure cosmetic geometry — fleet, ~80%.
Head-stomp reliability
The fix from earlier this session is confirmed live, so this isn't a shipping bug. Best working theory: the target's rendered position lags the true position by a network tick or two, and a 0.9-unit height band is tight enough that lag alone can miss it most of the time. Needs a real diagnosis before I just blindly widen numbers — staying on Sonnet.
Stick-poke as a new kill trigger
Needs actual interaction design: how does a poke coexist with the existing soft-push and hard-knock tiers so it doesn't just always win over ordinary contact? Not fleet-appropriate for the design call; implementation after that could move to fleet.
16›Start-screen copy — clarify points come from more than swingingFleet · 95%
A copy edit. Free — might just do this one directly too.
Recommended first test
Five cheap, high-confidence tickets to try the fleet on first
- 1Streets too black → two-line color bump (item 10)
- 2Right trigger also fires web (item 13)
- 3Start-screen copy update (item 16)
- 4Missing collision sounds, copying the existing pattern (item 11)
- 5Rooftop coin spin/pulse animation, visual only (item 8)
All five are mechanical, well-specified, and low blast-radius — easy to eyeball pass or fail against. If those land clean, the next tier up — border-wall bounce, truck and jeep geometry, individual orb effects once the framework exists — is where the real token savings start compounding.