Skip to main content

Architecture: N agents in the same file

Why this document is generated by a script

tools/eval/ARCH.md is not hand-written. It is generated by node tools/gen-arch.mjs, and the reason is in the script's header (tools/gen-arch.mjs:5-8):

The hand-written ARCH.md said "game.js (3234 lines)" when the file had 5361. Every arquivo:linha pointer in the conflict table was off — and that table is precisely what keeps two agents (or two contributors) from editing the same region. A hand-written line-number index goes stale on the first commit; the only fix is to generate it.

And the separation that makes this work (tools/gen-arch.mjs:11-13, quoted verbatim — the source comments are in Portuguese):

frente -> SÍMBOLO   = conhecimento humano, estável, vive nas FRENTES do script
símbolo -> LINHA = volátil, é o que este script resolve toda vez

Translation: "front → SYMBOL = human knowledge, stable, lives in the script's FRENTES; symbol → LINE = volatile, it is what this script resolves every time."

The old ARCH.md pinned front → line, mixing the two shelf lives. It is a small idea with a big consequence: the work partition is declared in terms that do not change (method names), and the resolution to volatile coordinates (line numbers) is recomputed on every run.

The arch:check is RED right now — and that is the best demonstration on this page

npm run arch and npm run arch:check exist today in the root package.json, and the check is not passing. Gate output below, quoted verbatim — the tools print in Portuguese:

$ npm run arch:check
✗ ARCH1 ARCH.md está DESATUALIZADO em relação ao código.
game.js tem 6428 linhas; o índice do ARCH.md não bate.
Rode: npm run arch

The message misleads on purpose: it talks about lines because that is the summary it knows how to print, but what --check compares is the entire generated block, byte by byte — and that block also carries the game's version number. Correct symbol index plus stale version gives the same red. One command fixes it.

A caveat that still holds: in CI the step has continue-on-error: true, so the check runs but does not block — which is exactly how it managed to stay red without anyone noticing. Removing that line is what turns it into a real gate.

The indexed files

Size of the files gen-arch.mjs indexes — a generated block, regenerated by npm run docs and checked by npm run docs:check:

FileLines
public/js/game.js6,894
public/js/main.js2,680
public/js/characters.js1,068
public/js/glbchars.js837
public/js/vmattach.js628
public/js/weapons.js344
public/js/springs.js260

Total in public/js/: 31,956 lines in 44 files. The symbol-to-line index lives in tools/eval/ARCH.md.

Block generated by node tools/gen-docs.mjs. Source: wc -l public/js/*.js

The largest methods in game.js — where the conflict lives

This table is not reproduced here, and the reason is this page's own thesis: it is linha → método, the volatile side of the separation, and duplicating it in a prose page creates a second copy that ages on its own. It lives generated, in one place only:

npm run arch                        # regenerates tools/eval/ARCH.md
node tools/gen-arch.mjs --json # the raw index, for another tool

What does not age, and is therefore written here: _updateBot() is by far the largest method in the file and is flagged by the index itself as an extraction candidate; constructor(), update() and _dom() are red zone, append-only, because any front may need them. Big method = unreviewable PR and conflicting merge — extracting _updateBot is high-value, medium-risk work, and it requires coordinating first, because the region is contested.

Disjoint line ranges

This is the mechanism that lets several agents (or contributors) edit the same file — the largest in the repository, thousands of lines — at the same time, without merge conflict.

How it works

  1. Each front declares SYMBOLS, never lines. In tools/gen-arch.mjs:32-73, the FRENTES constant lists, per front, three things: exclusive arquivos, simbolos (methods) and consts (top-level constants). For example, the ARMAS/VIEWMODEL front owns _buildViewModels, _vmFrame, _tryShoot, _shotRecoil… and the constants WEAPONS, VM_FOV_DEFAULT, VM_OFF, REC_DEG.
  2. The script indexes the file and resolves symbol → range. indexar() (tools/gen-arch.mjs:80-111) scans the file line by line with three patterns: class method (exactly 2 spaces of indentation), arrow method assigned at runtime (this._vmFrame = (force) => {) and top-level declaration. Each symbol ends where the next one begins.
  3. Contiguous ranges are merged (gap ≤ 12 lines) to keep the table readable (tools/gen-arch.mjs:172-178).
  4. Overlap between fronts is detected, because a conflict table that contradicts itself is worse than none (tools/gen-arch.mjs:190-200).

The detail in step 2 deserves highlighting: v1 of the script only saw class methods, so _vmFrame — about 100 lines born inside another method, as an arrow that closes over local variables — was invisible in the index (tools/gen-arch.mjs:95-97). An index that cannot see the most contested method in the file is worse than no index at all, because it gives false confidence.

The conflict table

From tools/eval/ARCH.md (generated block — the ranges below are from the previous generation; run node tools/gen-arch.mjs for today's):

FrontExclusive files
ARMAS / VIEWMODELvmattach.js springs.js weapons.js fparms.js handik.js
BOTS / JOGABILIDADE— (ranges in game.js only)
MAPAS / MUNDOmaps.js mapprops.js map_brasilia.js map_havan.js map_piscina.js map_piscinao_ramos.js map_ferrovelho.js
GRÁFICOS / FXbloom.js textures.js vao.js stylize.js gpuparticles.js
UI / HUD / MENUmain.js public/style.css src/pages/index.astro
ÁUDIOaudio.js
PERSONAGENScharacters.js glbchars.js
SITE / BACKENDsrc/
Two map files have NO declared owner

map_quebrada.js (1.319 lines, the newest map) and map_decals.js appear in no front at all in tools/gen-arch.mjs — the list above is a faithful copy of FRENTES, and they are not there. Whoever edits those two collides with nobody according to the table, which is precisely the guarantee the table is supposed to give and does not. Adding them is one line in tools/gen-arch.mjs followed by npm run arch.

(map.js was once listed here and no longer exists: it was the "Praça (clássico)", deleted along with the praca_old map.)

The red zones

Three methods are append-only, because any front may need them (tools/gen-arch.mjs:75-77):

  • update() — the loop
  • _dom() — the HUD wiring
  • constructor() — one of the largest methods in the file (today's size is in ARCH.md)

Editing the middle of these is the fastest way for two contributors to trample each other. Append at the end; do not reorganize.

The operating rules

  • Declare your front before editing. If it is a human PR, say so in the description.
  • In game.js, edit by chunk — never overwrite the whole file. A tool that rewrites the file erases the work of whoever is on the other range.
  • Two fronts with disjoint ranges run in parallel. The generated ARCH.md records that this was measured: "3 agents edited disjoint ranges simultaneously with zero content conflict" (tools/gen-arch.mjs:163).
  • Touched a symbol? Move the name in the front's declaration, not the number. The script warns when a declared symbol disappears from the code.
Why this matters to you, human

The same partition that prevents collisions between agents is what makes a PR of yours reviewable. A PR that touches _updateBot + style.css + map_havan.js is three PRs hidden in one, and will collide with three different fronts. One PR per front lands fast.

The three zones of the repository

public/     game      vanilla ES modules, zero build, vendored Three.js
src/ site Astro + Vercel adapter, SSR API routes
tools/ harness .mjs/.py scripts — the ruler, the gate and the probes

Versions, counts and what each tool does are in Stack and toolsgenerated, not hand-written.

The coupling between them is deliberately thin and worth understanding:

  • The site loads the game via import map, in src/pages/index.astro:97-123. It is the only place where Astro knows the game's modules exist.
  • The harness loads the game straight from disk, with no browser: tools/eval/harness.mjs stubs DOM/canvas/fetch and imports public/js/game.js as a module. That is why the gate measures production code, not a reimplementation.
  • tools/eval/serve.mjs:15 bridges to the test case: it serves public/ and maps / to the index.astro source, with no Astro in the path.

Practical consequence

The game cannot gain a runtime dependency or a build step. This is not conservatism: it is what lets harness.mjs boot the Game class in pure node in seconds, which is what makes the gate exist. A bundler in the middle would break the ruler (quality gate) along with the portability.

Content data system

Today maps, weapons and characters are code: each map_*.js is geometry declared by hand, and the largest of them rival the system modules in size. The "content as data" direction in docs/ROADMAP.md wants to migrate this to JSON with a single loader, so that a content contribution becomes "open a JSON and create content" instead of "a risky hand-coded code PR".

If you want the highest-leverage work in the entire project, this is it. See Current state.

What is generated, and what is not

Two things in this repository are generated by script, and for the same reason:

GeneratedScriptGate
tools/eval/ARCH.md — symbol→line index and conflict tabletools/gen-arch.mjsnpm run arch:check
The numeric blocks of README.md and of this documentationtools/gen-docs.mjsnpm run docs:check (in check:fast)

The rule that separates what goes in and what stays out:

  • Derivable from the code? It becomes a generated block, between markers, with --check in the gate. Counts of lines, of characters, of weapons, of maps, of scripts, of invariants, version, the package.json script list, dependency version.
  • Not derivable? Then it is a decision or an explanation — and it must not contain a number that ages. Write it without the number, or cite the command that produces it. The gate's scoreboard, for example, depends on which inputs exist on the machine: it lives pasted from a real run in KNOWN-BUGS.md, not repeated across five pages.

And the reason --check is in the gate, not merely available: what does not become a ruler is optimized away. A generator nobody is forced to run goes stale in a week, and then the documentation is back to lying with the appearance of rigor — which is worse than lying without it.

Where you put the new gate in the chain matters

check:fast is a chain of &&: the first error cuts off the rest. arch:check has been red for days, so every gate placed after it is born dead — it runs zero times and nobody notices, because the output stops earlier. That is exactly what happened to the first version of docs:check, and it is the same failure mode as BUG-02 (the gate measuring the viewmodel from yesterday because the && cut off before the JSON was regenerated).

That is why docs:check comes before arch:check in package.json, with the reason written in the //check:fast key. When ARCH.md is regenerated and arch:check goes green again, the order stops mattering; until then, it matters.

Pasting a new block is writing the marker and running npm run docs:

{/* BEGIN:GERADO:BLOCK_NAME — não edite à mão, rode `npm run docs` */}
{/* END:GERADO:BLOCK_NAME */}

(BLOCK_NAME is one of the keys of the BLOCOS object at the top of gen-docs.mjs. A declared block that nobody consumes becomes a loud warning in the output — an orphan block is dead code that pretends to be documentation.)

In plain Markdown (README.md) the marker is an HTML comment (<!-- BEGIN:GERADO:… -->). In the pages of this doc it is an MDX comment ({/* … */}): Docusaurus 3 compiles .md as MDX, and an HTML comment there is a parse error that takes down the build. The generator accepts both syntaxes and preserves whichever it finds.

What the generator does NOT solve: arquivo:linha pointers in prose

A game.js:5361 written in the middle of a paragraph is the cheap version of the same defect — it points to the wrong place at the first commit that touches the file. It cannot be generated (the pointer is part of the sentence), but the gross case can be detected: a pointer that points past the end of the file.

No arquivo:linha pointer in the docs points outside the file it cites. ✓

This checks only the file's bound: a pointer that still fits but changed subject passes here. That is why the house doctrine is to declare the SYMBOL and leave the line to the generator — see tools/gen-arch.mjs.

Block generated by node tools/gen-docs.mjs. Source: sweep of arquivo:linha across README/STATUS/HANDOFF/KNOWN-BUGS/docs/docs/SKILL

That is why the doctrine is to declare the symbol and leave the line to the generator. When the arquivo:linha really is necessary, cite alongside it the name of what lives there — that way whoever reads it a month from now finds it via grep even with the pointer shifted.