<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ivan Volzhin]]></title><description><![CDATA[Ivan Volzhin]]></description><link>https://ivanvolzhin.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 12:34:44 GMT</lastBuildDate><atom:link href="https://ivanvolzhin.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to make a 3D game in the browser with three.js and Jolt: architecture notes from an agent-built racer]]></title><description><![CDATA[How to make a 3D game in the browser: three.js rendering, Jolt car physics and the debug hooks an AI agent used to build and test Sky Shift.]]></description><link>https://ivanvolzhin.hashnode.dev/how-to-make-a-3d-game-in-the-browser-with-three-js-and-jolt-architecture-notes-from-an-agent-built-racer</link><guid isPermaLink="true">https://ivanvolzhin.hashnode.dev/how-to-make-a-3d-game-in-the-browser-with-three-js-and-jolt-architecture-notes-from-an-agent-built-racer</guid><category><![CDATA[ThreeJS]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[WebGL]]></category><category><![CDATA[ai agents]]></category><dc:creator><![CDATA[Ivan Volzhin]]></dc:creator><pubDate>Fri, 23 Oct 2026 06:57:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6adaf5bd4dd07559fb7a296f/d95d6d93-b717-4710-a267-ba8c850c2a82.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This post walks through how to make a 3D game in the browser with three.js, using one shipped project as the worked example. Our team runs Playgama, a platform for web games, and Sky Shift was our test of how much of a shipping title AI agents could carry. It is a stunt racer on tracks suspended over open water; each gate replaces the vehicle you are driving with another model. Claude Code produced the entire codebase, 33,113 lines of JavaScript. For a three.js developer, though, the interesting part is less the AI and more the structure that let us verify what the agent wrote.</p>
<p>You can play Sky Shift in your browser right now: <a href="https://playgama.ai/play/i66ryvsxgiybqp?utm_source=hashnode&amp;utm_medium=syndication&amp;utm_campaign=sky_shift">Sky Shift on Playgama</a>.</p>
<p><a class="embed-card" href="https://www.youtube.com/watch?v=KGID1fQxtxM">https://www.youtube.com/watch?v=KGID1fQxtxM</a></p>

<h2>What does the stack look like?</h2>
<p>Rendering is three.js over WebGL. three.js is a library rather than an engine with an editor: it draws the scene, and game state, input, UI and level logic are all code you (or your agent) write around it. Physics is Jolt compiled to JavaScript. We picked it because we wanted genuine collisions and a proper vehicle model, and Jolt ships suspension, tyre friction, differentials and a gearbox. Both libraries are bundled into the build instead of being requested from a CDN when the page loads, which the Playgama game checklist asks for in any case.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adaf5bd4dd07559fb7a296f/19ca4ed7-8899-4ac6-914f-961234769960.jpg" alt="three.js renders the scene" style="display:block;margin:0 auto" />

<h2>How do you get a drivable car model into three.js?</h2>
<p>A drivable car needs its wheels as separate nodes, each pivoting on its axle, so code can rotate and steer them. Our models came out of Tripo's image-to-3D: a ChatGPT concept image goes in, a textured mesh comes out. For the web, three settings mattered: triangle topology at about 5,000 tris, GLB as the export format, and textures between 1K and 2K instead of the 4K the dialog was set to.</p>
<p>Segmentation is what produces separate wheels, and it has a price.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adaf5bd4dd07559fb7a296f/9ba84358-df6b-4771-a2d4-4e963c377a82.jpg" alt="Tripo's segmentation view" style="display:block;margin:0 auto" />

<p>The segmented red car landed as 55 meshes, each carrying its own texture, and 42 of those textures were 128 px or smaller. In three.js, every mesh with its own material becomes its own draw call, so this one car cost 55 of them. The relevant checklist line reads "Budget draw calls, not polygons."</p>
<p>Attempt one baked all the little textures into a single map. Draw calls dropped and the car went blurry: a bake can only rearrange the pixels you feed it.</p>
<p>What shipped combines two exports of one model. The unsplit export contains identical triangles and a single 2048 × 2048 texture. A headless Blender script then:</p>
<ol>
<li>reads the segmented GLB purely to label each triangle as wheel or body, gathering every wheel's loose fragments;</li>
<li>cuts the body and four wheels from the unsplit GLB, so all five meshes share that one sharp texture;</li>
<li>relocates each wheel's origin to its axle, so rotation happens in place;</li>
<li>scales the vehicle to real-world dimensions.</li>
</ol>
<pre><code class="language-plaintext">blender -b --python clean_car.py -- red-car-split.glb red.glb renders/ 4.4 --full red-car-full.glb
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6adaf5bd4dd07559fb7a296f/946edb93-98e8-4528-9e4b-6e2d484d1ce6.png" alt="The script turns 55 parts into a body plus four wheels" style="display:block;margin:0 auto" />

<p>Outcome: 5 meshes and 1 texture where there had been 55 and 55. The yellow car had no unsplit export, since we segmented it first, so the script packed its 53 textures into one 4096 × 4096 atlas. That gave the same draw-call saving with a softer surface. Save the unsplit GLB before you segment.</p>
<p>Keeping the pipeline as a single command pays off when an agent is involved: every new car follows identical steps, and the agent can rerun them on request. The checklist phrase is "Script the asset pipeline into one command instead of optimizing by hand."</p>
<p>Static props (barriers, billboards, flags, ramps) and sound effects came from Kenney's CC0 Racing Kit and sound packs and needed nothing beyond loading.</p>
<h2>Wiring the wheels in code</h2>
<p>Because each wheel arrives as its own node with a correct pivot, the runtime side stays small. The code spins all four with the car's motion, turns the front pair with steering input, and lets each wheel ride its own suspension. None of that would be possible with the wheels fused into the body mesh, which is why the asset step above spends so much effort keeping them separate. It also explains the turned-wheels problem we hit early: the first concept image showed the front wheels angled, and an image-to-3D generator would have frozen that angle into the mesh. One more ChatGPT prompt asking for straight wheels fixed it before Tripo ever saw the picture.</p>
<h2>Why is the track stored as data instead of geometry?</h2>
<p>Nobody modelled the track in an editor. It lives in config as a list, one entry per section, and every entry has a length, a turn, a climb, a width and a type: walled canyon, jump, tube, or banked wall-ride. At load, the game generates geometry from that list.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adaf5bd4dd07559fb7a296f/f5d85e60-154f-4046-8975-f3c77b8f9592.jpg" alt="The track config, one line per section" style="display:block;margin:0 auto" />

<p>Two things follow. Shuffle the entries and you have a new track. And the agent edits levels by editing lines, so "lengthen the canyon and add a jump before the tube" is a small diff rather than a modelling session. Harbour Run, Skybridge and Razor Coast, the three levels, are three such lists.</p>
<p>To steer the layout, we gave the agent fourteen reference screenshots of stunt maps. It asked four design questions before touching code, and our answers became a point-to-point sprint with a giant platform, a canyon, a tube and steep climbs, each zone with its own colour.</p>
<h2>How does an AI agent test a 3D game it cannot see?</h2>
<p>This is the part we would copy into any three.js project an agent maintains. The game exposes its runtime state on a global, <code>window.__game</code>. That lets the agent boot the build in a headless browser, drive it, read positions and frame rate, and grab screenshots, rather than infer behaviour from source files.</p>
<p>Source code tells the agent what ought to happen; the hooks report what did. The agent drew its own conclusion and recorded it in the project notes:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adaf5bd4dd07559fb7a296f/b5d55d87-406f-4026-8a1a-bf365efba83c.jpg" alt="The agent's rule, written into the project notes" style="display:block;margin:0 auto" />

<blockquote>
<p>Numbers from window.__game settle it; screenshots agree with almost anything.</p>
</blockquote>
<p>The hooks also give deterministic capture. Our recorder steps the simulation exactly once per frame, so the footage stays smooth no matter how slowly the machine renders:</p>
<pre><code class="language-plaintext">node tools/capture-steps.mjs --url "localhost:5190/?record=1&amp;autopilot=1" --w 2560 --h 1440 --seconds 100
</code></pre>
<h2>How do you handle every screen size?</h2>
<p>A browser game has to work from an upright phone to a wide desktop monitor. The agent runs the UI through a fixed matrix of viewport sizes and checks numerically whether elements collide. On one run it found three overlaps, patched them, then repeated the check across all fourteen sizes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adaf5bd4dd07559fb7a296f/90c6e5d8-abcf-4d3e-a403-0e04dc4555bd.jpg" alt="The menu at five of the tested sizes" style="display:block;margin:0 auto" />

<p>A later platform check caught a separate issue: the picture stretched on ultra-wide displays. The fix clamps the desktop aspect ratio to 2:1 and fills the rest with margins.</p>
<h2>The rollover bug and the Jolt setting behind it</h2>
<p>Once real cars were in, fast cornering fell apart. At full steering lock a car tipped onto two wheels, and some rolled over entirely. Here is the request we sent:</p>
<blockquote>
<p>Some cars still flip over. Think, search the internet for how car control is done on the engine we use, and make it as good as you can.</p>
</blockquote>
<p>The agent traced it to Jolt's vehicle documentation. By default, Jolt applies tyre forces at the contact point between tyre and road, which the docs note "makes the vehicle less stable." Switching to a fixed application point on the chassis changed the result at full lock and 245 km/h from a 75° roll to 4.5° of lean, all four wheels planted.</p>
<p>Two lessons for physics work alongside an agent. Measured values make it obvious whether a change helped. And sending the agent to the engine's own documentation got further than letting it tweak constants by trial.</p>
<h2>Shipping the build</h2>
<p>The final layer was the Playgama Bridge SDK, covering ads, saves, leaderboards and languages. Through Playgama MCP the agent loaded the SDK docs and the checklist, moved Bridge from 2.0.2 to 2.2.0, added eight languages, merged saves into one call, and replayed a recorded race to confirm gameplay was untouched. The QA tool then measured a 6 MB initial download and a load of about a second, with both engines bundled and each car down to five meshes.</p>
<p>A few implementation details from that layer that are useful in any browser game:</p>
<ul>
<li><strong>Ad timing as rules, not scattered calls.</strong> An interstitial may fire only when the player leaves a results screen, never during a race's first 30 seconds, and with at least three minutes between two of them. The rewarded video, which doubles the coins from a win, grants the reward only after the video finishes.</li>
<li><strong>One save call.</strong> The first version wrote each setting separately; the agent collapsed them into a single write, and the QA tool confirmed saves load in one call.</li>
<li><strong>Language from the platform.</strong> The game picks among its eight languages based on the language the platform reports.</li>
<li><strong>Custom analytics events.</strong> Bridge 2.3.0 and later can send your own events. The rule in the docs: keep event names short and stable, and put values that change, such as the level or the section, into the event data. For Sky Shift that means one event per section reached and one when a player falls off, which the agent can read back through Playgama MCP to see where players stop.</li>
</ul>
<p>The long-form version, including every prompt and the asset licences, <a href="https://playgama.com/blog/main/how-to-make-a-game-with-ai/?utm_source=hashnode&amp;utm_medium=syndication&amp;utm_campaign=sky_shift">is on our blog</a>.</p>
<p>Sky Shift is live, on a computer or a phone: <a href="https://playgama.ai/play/i66ryvsxgiybqp?utm_source=hashnode&amp;utm_medium=syndication&amp;utm_campaign=sky_shift">play it in your browser</a>.</p>
]]></content:encoded></item></channel></rss>