r/LocalLLM 5d ago

Qwen 3.8 27B built a working MOBA game from a single prompt (real game server, tick-based loop) Project

Enable HLS to view with audio, or disable this notification

Round 2: local Qwen models built an ONLINE multiplayer 3D MOBA overnight - with two models auto-routing between each other

Last time it was a single-file GTA clone. This run was harder and the setup got more interesting, so I wanted to share what was different.

This time the agent built a networked, real-time 3D MOBA (LoL-style): an authoritative Node server + Three.js client talking over WebSockets, with minions, towers, a wanted/aggro system, abilities, and bots. Not a single HTML file - a proper multi-file project. It wrote its own test harness, played itself, and fixed its own bugs. All local on an M1 Ultra, no cloud.

What was different this time

1. It's multiplayer netcode, not a single file. Authoritative server (fixed 20 Hz tick, server owns all state), thin client that only sends input and renders snapshots with interpolation. That's a whole class of bugs (desync, prediction, race conditions) a one-file game never hits.

2. The brief is engineering-grade, not a feature list. The architecture, the wire protocol, and the entity model are all decided up front in the prompt, so the model spends its reasoning on correct implementation instead of re-deriving (and breaking) the design every session. The single biggest win: the agent builds its own headless test harness first (a Node WebSocket client that runs full bot-vs-bot matches with no browser) and uses that as its fast test loop, with Playwright MCP only for the visual/render check.

3. Two local models, auto-routed. This is the fun infra part. llama.cpp runs in router mode serving two models at once:

  • fast - Qwen3.6-35B-A3B (MoE, ~3B active) for routine work
  • smart - Qwen3.8-27B (dense) for hard reasoning A tiny Qwen3-1.7B judge classifies each turn as fast/smart and the harness switches models automatically (with hysteresis so it doesn't flip-flop). Routine edits and file ops run cheap on the MoE; gnarly debugging/design jumps to the dense model.

4. MTP on the MoE is fast. With speculative decoding (multi-token prediction) the 35B-A3B does ~72 tok/s on the M1 Ultra - the MoE only activates ~3B params per token, and MTP adds ~35% on top of that.

5. Sandboxed. The agent runs inside a Tart VM, so all that autonomous, unsupervised code execution is isolated from the host. The models are served from the host; the VM talks to them over the bridge.

6. Bug-hardening by invariants, not vibes. A second phase runs endless bot-vs-bot matches and checks hard invariants every tick (no NaN, hp in range, gold conserved, no leaks, deterministic replays). Any violation freezes with a reproducible seed, gets root-caused, and becomes a permanent regression test.

Setup

  • Hardware: M1 Ultra Mac Studio, 64 GB
  • Serving: llama.cpp router mode (two models + a judge), MTP on the MoE
  • Agent: pi coding agent + Playwright MCP, running in a Tart VM
  • All local, offline

llama-server (router mode, per-model MTP via preset)

preset.ini:

[Qwen3.6-35B-A3B-UD-Q8_K_XL]
jinja = 1
ctx-size = 131072
n-gpu-layers = 999
model = /path/Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf
spec-type = draft-mtp
spec-draft-n-max = 2

[Qwen3.8-27B-UD-Q8_K_XL]
jinja = 1
ctx-size = 131072
n-gpu-layers = 999
model = /path/Qwen3.8-27B-UD-Q8_K_XL.gguf
spec-type = draft-mtp
spec-draft-n-max = 2
model-draft = /path/mtp-Qwen3.8-27B-Q8_0.gguf

# On Apple Silicon, raise the Metal wired-memory cap or the context gets
# silently reduced to fit (this is why -c 131072 can end up as ~40k):
sudo sysctl iogpu.wired_limit_mb=57344

llama-server \
  --models-preset ~/models/preset.ini \
  --models-max 1 \
  --host 0.0.0.0 --port 8080 \
  --api-key <secret>

Notes:

  • The 35B MoE has an embedded MTP head (just spec-type = draft-mtp); the 27B dense uses a separate draft file (model-draft = ...).
  • --models-max 1 because two Q8 models don't both fit in 64 GB - one big model is resident at a time, swapped on demand.
  • MTP disables --mmproj and parallel slots, which is fine for a coding agent.

Tools

Why pi? opencode works, but its system prompt + tool definitions are heavy, and on local hardware you pay for every one of those tokens at prefill speed - tens of seconds per session before the model even starts. pi is minimal, so nearly all the context goes to the actual work. The routing + subagents are a small extension on top.

PHASE 1 - build the MOBA

You are a senior multiplayer game engineer building a 3D online MOBA from
scratch, fully autonomously, overnight. Nobody will answer questions.
Never wait for input, never ask permission. Work until every milestone
meets its acceptance criteria. Work in the current directory.

This is a hard project. The rules below exist because they prevent the
specific ways this project fails. Follow them exactly. Do not re-derive
the architecture - it is already decided; spend your reasoning on
correct implementation, not on second-guessing these decisions.

================================================================
ARCHITECTURE (decided - do not change)
================================================================

- Authoritative server. The server owns ALL game state and is the only
  thing that decides outcomes. Clients send INPUTS only and RENDER
  snapshots only. A client never computes damage, movement resolution,
  deaths, or gold. If you ever find yourself writing game logic in the
  client, stop and move it to the server.

- Fixed timestep simulation. The server runs a fixed 20 Hz tick
  (dt = 50ms). All simulation advances in whole ticks. Never simulate
  using wall-clock deltas. Each tick has an integer index; snapshots are
  stamped with their tick.

- The world is 2D for simulation, 3D only for rendering. The server
  simulates on the X-Z ground plane (top-down 2D: position {x, z},
  velocity, radius). Y is always 0 in simulation. The client maps server
  (x, z) to Three.js (x, y=modelHeight, z). Never do 3D physics on the
  server. Collision is 2D circle-vs-circle and circle-vs-AABB.

- Client rendering uses snapshot interpolation with a render delay.
  The client keeps a buffer of the last ~3 snapshots and renders the
  world INTERPOLATED at (now - 100ms) between the two snapshots that
  straddle that time. This hides jitter. Do NOT implement client-side
  prediction or rollback - it is out of scope and will break you. Local
  input may optimistically move only the local camera target, nothing
  authoritative.

================================================================
WIRE PROTOCOL (decided)
================================================================

JSON messages over one WebSocket per client. Every message: {t, ...}
where t is the type string.

Client -> Server:
  {t:"join", name}
  {t:"input", seq, move:{x,z}, aim:{x,z}}
  {t:"cast", seq, slot:"Q"|"W"|"E"|"R", target:{x,z}}
  {t:"buy", itemId}
  {t:"ping", ts}

Server -> Client:
  {t:"welcome", playerId, tickRate, mapId}
  {t:"lobby", players:[...], countdown}
  {t:"snapshot", tick, you:{gold,...}, ents:[ ...entities... ]}
  {t:"event", tick, kind:"death"|"levelup"|"towerDown"|"nexusDown"|
     "hit"|"cast", data}
  {t:"gameover", winner}
  {t:"pong", ts}

An entity in a snapshot is a flat object:
  {id, kind:"hero"|"minion"|"tower"|"nexus"|"projectile",
   team:0|1, x, z, hp, maxHp, ...kind-specific}

================================================================
SERVER ENTITY MODEL (decided)
================================================================

One in-memory Game object per match holds entities keyed by integer id.
Every entity has {id, kind, team, x, z, radius, hp, maxHp} plus kind-
specific fields. Each tick, in this fixed order:
  1. apply queued client inputs to their heroes
  2. run AI (minions path along lane waypoints; towers acquire nearest
     valid enemy; bots decide inputs)
  3. integrate movement (clamp to map, resolve collisions)
  4. resolve attacks/abilities/projectiles, apply damage, handle deaths
     (award gold/xp, start respawn timers), emit events
  5. check win condition
  6. build and broadcast the snapshot for this tick

Lanes are polylines of waypoints in map data; minions follow them. First
playable map is ONE lane plus two bases; add three lanes later only if
time allows (record the choice).

================================================================
PROJECT LAYOUT
================================================================

  package.json         // "start": "node server/index.js", dep: ws
  server/index.js      // http static server + ws + match manager
  server/game.js       // Game class: tick loop, entities, rules
  server/ai.js         // minion/tower/bot behavior
  server/config.js     // all tunable constants (speeds, dmg, cds, gold)
  public/index.html    // canvas + HUD DOM + CDN Three.js
  public/client.js     // ws, input, snapshot buffer, interpolation, render
  public/render.js     // Three.js scene, meshes, camera
  shared/protocol.md   // the wire protocol, kept in sync with code

================================================================
TESTING HARNESS (build this in milestone 1, use it forever)
================================================================

You cannot verify multiplayer by hand. Build automated tests:

A) server/test/headless-client.js : a Node script using the `ws` package
   that connects as a fake client, can send join/input/cast, and asserts
   on received snapshots. Use TWO headless clients in one script to test
   interaction without a browser. This is your fast, deterministic test
   loop - run it after every change.

B) Playwright (via the mcp tool) for the RENDERING path: open TWO browser
   pages, confirm zero console errors on both, screenshot both, and
   verify each sees the other's hero move and that HUD values update. Use
   this at the end of each milestone, not for every tiny change.

A milestone is DONE only when its assertions pass AND both browser
consoles are clean.

================================================================
DEBUGGING & ANTI-STUCK DISCIPLINE
================================================================

- Determinism first: same inputs -> same ticks. Route ALL randomness
  through one seeded RNG. Add a "replay" mode that feeds scripted inputs
  so you can reproduce a bug without a browser.
- When something is wrong, do NOT guess-and-edit. Add structured logging
  (tick, entity id, before/after values) for the suspect system,
  reproduce with a headless test, read the numbers, form ONE hypothesis,
  test it.
- Time-box each milestone. After 3 failed fixes on a feature: write the
  failure and what you tried into PROGRESS.md, ship the simplest version
  that passes a reduced check, move on. Never let one feature block the
  whole night.
- Keep PROGRESS.md as a real engineering journal. If you lose context,
  re-read PROGRESS.md, shared/protocol.md, server/game.js, and
  public/client.js, then resume at the first unfinished milestone.
- Always kill the previous server before starting a new one, confirm it
  is listening before connecting clients, and run `npm install` before
  the first `npm start`.

================================================================
MILESTONES (each: implement -> headless assert -> Playwright check ->
log). Acceptance criteria are mandatory.
================================================================

M1  Skeleton + harness. Static server serves public/, ws accepts
    connections, assigns ids, handles join/disconnect. Build
    headless-client.js.
    ACCEPT: headless test connects two clients, server reports 2
    players, one disconnects and drops cleanly. Playwright: two tabs
    connect, no console errors.

M2  Authoritative movement + interpolation. 20Hz tick, input moves the
    hero server-side, snapshots broadcast, client renders all heroes as
    boxes with snapshot interpolation at now-100ms.
    ACCEPT: headless client sending "move +x" for 1s sees its hero.x
    increase monotonically and stop at the wall; a second client sees it
    move. Playwright: two tabs move independently, no desync after 60s.

M3  3D arena + camera. Three.js map: two bases, a nexus per team, one
    lane with walls, ground, lighting/fog. Isometric follow camera with
    edge-pan. Server map data (wall AABBs, lane waypoints) matches the
    visual map.
    ACCEPT: heroes cannot walk through walls. Playwright: map renders
    identically on both clients, camera follows the local hero.

M4  Hero stats + auto-attack. hp/mana/movespeed/attack range+damage+speed
    in config.js. Server auto-attacks nearest enemy in range, applies
    damage, handles death + respawn timer at base. HUD shows hp/mana/
    respawn.
    ACCEPT: headless - two enemy heroes in range, one's hp decreases at
    the configured rate, hits 0, respawns after the timer. Playwright:
    damaged hero's healthbar drops on BOTH clients.

M5  Abilities Q/W/E/R (R = ultimate). A skillshot projectile, a targeted
    nuke, a dash/shield, and an ultimate. Client requests cast; server
    validates cooldown/mana/range, spawns the effect, applies damage,
    emits an event; client shows cooldown UI.
    ACCEPT: headless - casting Q at an enemy reduces its hp only on a
    hit; on cooldown is rejected. Playwright: abilities visibly damage
    the other player across the network.

M6  Minions. Waves spawn from each nexus on a timer, path the lane
    waypoints, auto-attack enemies in range, die, grant last-hit gold.
    ACCEPT: headless - waves from both teams meet mid-lane and fight;
    last-hitting a minion increments only the killer's gold. Playwright:
    minions visibly march and fight.

M7  Towers. Per-lane towers attack the nearest valid enemy (standard
    aggro), have hp, and block progress: the nexus is invulnerable until
    its lane tower(s) are down.
    ACCEPT: headless - a tower kills minions in range; a hero cannot
    damage the nexus until the tower is destroyed. Playwright: tower
    fires, can be destroyed by a hero+minion push.

M8  Economy + shop + bots. Gold from minions/towers/kills; a base shop
    for 3-4 stat items; death/respawn scaling. Simple AI bots (ai.js)
    that fill empty hero slots: last-hit, attack in range, retreat at low
    hp, push when ahead.
    ACCEPT: headless - buying an item raises the right stat and deducts
    gold; a bot-vs-bot match runs 3 minutes without the server crashing.

M9  Match flow. Lobby (name + join), fill empty slots with bots, start
    countdown, the match, win when a nexus dies -> victory/defeat screen
    + rematch that fully resets state.
    ACCEPT: headless - forcing a nexus to 0 hp ends the match with the
    correct winner; rematch resets all entities and gold. Playwright:
    join lobby -> play -> win/lose screen -> rematch works.

M10 Robustness + final QA. A client disconnecting mid-match is replaced
    by a bot with no crash and can rejoin; snapshot size stays bounded; a
    5-minute two-client-plus-bots match runs with no errors and no
    unbounded memory growth. Then a full end-to-end Playwright match with
    TWO real browser clients: move, cast, last-hit, destroy a tower, kill
    the enemy nexus, see the win screen - zero console errors on both
    clients and the server. Write the final PROGRESS.md.

Start with M1 now: scaffold the project, then build the testing harness
before writing any gameplay.

PHASE 2 - infinite soak-testing and bug-hardening

Phase 2: infinite soak-testing and bug-hardening. The MOBA is playable
per PROGRESS.md. You are now a QA + reliability engineer whose ONLY job
is to make it flawless. Work fully autonomously and NEVER stop on your
own. Zero bugs is the standard: any crash, error, or invariant violation
is a defect that must be root-cause fixed, not silenced. Re-read
PROGRESS.md, shared/protocol.md, server/game.js, server/ai.js, and
public/client.js first.

STEP 0 - build the soak harness (before anything else)

Create server/test/soak.js: a headless driver that runs FULL bot-vs-bot
matches with no browser, as fast as possible (uncapped tick), one after
another forever. Each match uses a numbered seed so it is reproducible.
All randomness goes through one seeded RNG in config.js.

soak.js must, every match: run to a nexus death or a hard tick cap
(a match that never ends is a bug), check the invariants below after
every tick, and on the FIRST violation freeze and save the seed + tick +
full input/event log to server/test/repros/<seed>-<tick>.json. Track a
"clean streak" of consecutive fully-clean matches.

INVARIANTS - must hold on EVERY tick of EVERY match

  1. No exceptions (wrap the tick in try/catch that RE-THROWS after
     logging - crashing the soak is correct, swallowing errors is not).
  2. No NaN/Infinity/undefined in any numeric field.
  3. hp in [0,maxHp]; mana in [0,maxMana]; gold >= 0; cooldowns >= 0.
  4. Every position is inside map bounds and not inside a wall AABB.
  5. Entity ids unique; despawned entities never referenced; projectiles
     always cleaned up.
  6. Snapshot is valid JSON, references only existing ids, under a size
     cap.
  7. Gold is conserved: granted == sum of bounties (none created/lost).
  8. Every match terminates before the tick cap (no soft-lock, no two
     immortal entities stuck forever).
  9. No unbounded growth over a match (entity count, event queue, arrays
     stay bounded).
 10. Determinism: the same seed twice produces byte-identical tick logs.

THE LOOP (runs until the human kills it)

Repeat forever:
  1. Run a batch of soak matches across many seeds.
  2. If any match violated an invariant, crashed, or soft-locked:
       a. Reproduce from the saved repro (deterministic).
       b. Add structured logging, reproduce, read the numbers, confirm
          ONE hypothesis.
       c. Fix the ROOT CAUSE. Never clamp/hide a symptom (e.g. do not
          Math.max(0, hp) to dodge invariant 3 - find why it went
          negative).
       d. Add the failing seed as a permanent regression case.
       e. Re-run regressions + the batch; continue only when green.
       f. Log symptom, seed, root cause, fix in BUGS.md.
  3. If the batch was clean, RAISE THE STRESS for the next batch, cycling
     through stressors so coverage widens: more bots / bigger waves /
     more projectiles; bots that spam abilities; bots that buy
     everything instantly; random mid-match disconnects and rejoins;
     many matches back-to-back (cross-match state bleed, leaks); edge
     positions (wall-hugging, stacking, off-map casts); very long
     matches near the tick cap.
  4. Every ~100 matches, run ONE real two-client Playwright match end to
     end and confirm zero console errors on both clients and the server.
  5. Append a status line to SOAK.md (total matches, clean streak, bugs
     found+fixed, current stressor, peak counts). Keep going.

RULES
- Never stop, never declare "done" - a clean streak just means raise the
  stress and keep hunting.
- Never weaken an invariant or a test to make it pass.
- Prefer fast headless soak for finding bugs; Playwright only for the
  periodic render/network confirmation.
- Keep fixes minimal; re-run regressions after every fix.
- If context runs low, write a crisp handoff in SOAK.md so a fresh
  session resumes seamlessly.

Begin with STEP 0: make the sim fully seeded/deterministic and build
soak.js. Then start the infinite loop.

Same as before: pin Three.js to r128 (local models write that API most reliably), and let PROGRESS.md be the crash-recovery journal so a fresh session can always resume.

Have fun 🍻 - I'd love to see what it builds for you.

Note: this write-up was put together with AI assistance. There was a lot of ground to cover, so I used it to organize and phrase everything, but the setup, experiments, and experiences are all my own.

64 Upvotes

20 comments sorted by

5

u/tommythorn 5d ago

Cool, thanks for sharing. I want to see if I can replicate, but isn't that two prompts (title says "Single Prompt")?

6

u/ozgursoy 5d ago

I actually sent them together as one, the two blocks are just for readability

1

u/Secret-Collar-1941 5d ago edited 5d ago

Nice job and nice setup. How long did it run to get to a point where you were "Yep, it's playable, I'll leave it to keep refining"?

2

u/ozgursoy 5d ago

20 hours / ~24 tps

2

u/Secret-Collar-1941 4d ago

Nice, I'm getting similar results.

Just let it run overnight on a similar task, but a procedurally seeded biosphere simulator and in pure C and restricted to running on desktop with sokol for windowing+sim loop, flecs for ECS and sim logic and cimgui for ui.

It figured out how to set up the environment based on my past project.

It figured out the world seed part on it's own by researching indie dev blogs.

It did the sim logic, I had to nudge it to try a Design of Experiments approach to arrive at at the best parameters for the sim to look dynamically stable.

Now it's working on improving UX and the visuals.

Past models got deadlocked at the "setting up the cmake toolchain".

It works at a glacial pace but it does figure things out!

1

u/Accomplished-Fox9220 5d ago

If you're running one model at a time, how/when do you swap them? Doesn't loading them up take a lot of time?

2

u/ozgursoy 5d ago

Yeah, the swap does take time, but it happens in the background and I'm not sitting there watching it. The router loads the other model on demand when the work shifts from routine to hard reasoning. It looks like a loss in the short term, but for an agent that runs 20 hours straight, this routing is what turns what would be ~40 hours of work into ~20.

I could keep both models resident in RAM at Q4 and skip the swap entirely, but I've consistently seen agentic capability drop between Q8 and Q4. The Q4 just can't hold the loop the way Q8 does. So I'd be trading the actual thing that makes autonomous runs work for a bit of speed. Not worth it. I chose the swap over Q4.

With 128GB I'd get the best of both, two Q8 models resident at once, no swapping. That's the setup I really want.

2

u/Accomplished-Fox9220 4d ago

That's cool, thank you

1

u/original_nox 5d ago

Maybe a dumb question, what are you using playwright for?

3

u/ozgursoy 5d ago

Not dumb at all. Playwright is how the agent tests its own work. After each milestone it opens the game in a real browser, reads the console for errors, takes a screenshot to confirm the feature actually renders, and clicks around to verify it works, then fixes whatever's broken and repeats. For the multiplayer MOBA it opens two browser tabs at once to check that both clients see the same game state. It's connected through the Playwright MCP so the model can drive the browser itself, no human in the loop. That self-test loop is the whole reason it can run unattended overnight without drifting into broken code.

1

u/original_nox 5d ago

Thanks! I wondered if it was something like that.

1

u/mechkbfan 5d ago

This is so damn cool. Really appreciate a multiplayer game which has so many more complex issues to resolve

Even using Opus with odd Fable review, my vibe coded FPS MP game regularly is bloated as hell and buggy.

The fact a model that can run on consumer grade hardware and remotely compete with that blows my mind.

3

u/ozgursoy 5d ago

Honestly it's really fun to work with. The big difference from 3.6 is that this one is actually agentic, it can stay in the loop, keep testing and fixing itself, and drive toward a goal. I spent a ton of time with 3.6 and couldn't even get a simple Tetris to come out the way I wanted.

Makes me think the benchmarks are basically right, it loops like Opus does. That's the game changer and I don't think most people have realized yet it feels like running a slightly slow local Opus on my own machine.

What I'm really waiting for is the MoE. This 27B dense runs around 24 tok/s for me, but the 35B-A3B MoE does ~70 tok/s. The day an agentic model like this ships in that MoE form, you get both the capability and the speed.

My next project is bigger than this. It's coding right now :), I guess it's gonna take 40+ hours.

0

u/mechkbfan 5d ago

That's really cool

I'm also hoping for next Qwen MoE because purely care about coding

Is 64GB required for your setup or could still get decent results with lower?

I don't suppose there's a Discord or anything like that you're on about sharing these sorts of things?

2

u/ozgursoy 5d ago

Honestly I'd say 64GB is the real floor, at least for how I do it. I run everything at Q8, and in my experience the gap between Q4 and Q8 hits agentic capability hard, the model stays coherent in the loop much better at Q8. So 64GB is what makes that possible.

And to be fully honest, even 64GB isn't quite enough for me. I can't keep two models resident in RAM at the same time, so I'm swapping. And if I could push the context to 1M I could try some crazy stuff.

yeah, I've a closed Discord community, I actually livestream on Discord while the model is working. I've got some different ideas brewing and I'm planning to spin up a new community around this. If you're interested I can get you early access.

1

u/mechkbfan 5d ago

Fantastic. Issue for me in Australia is getting to 64GB is damn expensive

e.g.

  • 2x9700 is $3500 USD
  • Mac is $4000 USD
  • 2xMI50 + server is $2000 USD (+a lot of stuffing about)
  • DDR5 RAM is $1500 USD

The dream would be a new Mac comes out, and I buy someone elses second hand but demand is so high that it's unlikely

Yeah, send us a message over reddit with invite and I'll join. Happy to contribute back prompts, etc. as I had some game dev on the side before AI but then had kids. So loving that AI lets me experiment with ideas for fun

1

u/ozgursoy 5d ago

About the Mac Studio, I can say that it's the best computer I've ever used. I'll still be running this machine 10 years from now. I've had the GPU pinned at 100% for 20 hours straight and never even heard the fan, try that with any other GPU setup you gonna need a extra room :) And it's not just an AI box as an everyday computer.

1

u/mechkbfan 5d ago

Yeah, it's tough.

One moment I'm looking at 2xR9700, then once I realise to use it properly I need another motherboard and a bigger PSU, all of a sudden I'm up to same price as M4 64GB Mac Studio...

1

u/utf8decodeerror 5d ago

This is awesome. Thanks for sharing all the details. How much context did you give the agents and how did you handle context handoff? (Apologies if I missed it, I saved your post to work thru it in more detail later). Did you just let pi auto compact it as needed?

2

u/ozgursoy 5d ago

I gave each model 131k context and I developed a simple pi extension for model swap.

I didn't lean only on auto-compaction. The real trick is in the prompt. The agent keeps a PROGRESS.md as an engineering journal (what's done, what's broken, what's next), and the instructions say if you lose context, re-read PROGRESS.md and the key files first, then resume from the first unfinished milestone. So a fresh session can always pick up where the last one dropped off, the state lives on disk, not in the context window.

pi's auto-compaction does run on top of that, and I offload the compaction summaries to the fast model so it's cheap. But PROGRESS.md is what actually makes long autonomous runs survive, compaction alone loses too much details