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.mdsaid "game.js (3234 lines)" when the file had 5361. Everyarquivo:linhapointer 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.
arch:check is RED right now — and that is the best demonstration on this pagenpm 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:
| File | Lines |
|---|---|
public/js/game.js | 6,894 |
public/js/main.js | 2,680 |
public/js/characters.js | 1,068 |
public/js/glbchars.js | 837 |
public/js/vmattach.js | 628 |
public/js/weapons.js | 344 |
public/js/springs.js | 260 |
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
- Each front declares SYMBOLS, never lines. In
tools/gen-arch.mjs:32-73, theFRENTESconstant lists, per front, three things: exclusivearquivos,simbolos(methods) andconsts(top-level constants). For example, the ARMAS/VIEWMODEL front owns_buildViewModels,_vmFrame,_tryShoot,_shotRecoil… and the constantsWEAPONS,VM_FOV_DEFAULT,VM_OFF,REC_DEG. - 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. - Contiguous ranges are merged (gap ≤ 12 lines) to keep the table readable
(
tools/gen-arch.mjs:172-178). - 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):
| Front | Exclusive files |
|---|---|
| ARMAS / VIEWMODEL | vmattach.js springs.js weapons.js fparms.js handik.js |
| BOTS / JOGABILIDADE | — (ranges in game.js only) |
| MAPAS / MUNDO | maps.js mapprops.js map_brasilia.js map_havan.js map_piscina.js map_piscinao_ramos.js map_ferrovelho.js |
| GRÁFICOS / FX | bloom.js textures.js vao.js stylize.js gpuparticles.js |
| UI / HUD / MENU | main.js public/style.css src/pages/index.astro |
| ÁUDIO | audio.js |
| PERSONAGENS | characters.js glbchars.js |
| SITE / BACKEND | src/ |
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 wiringconstructor()— one of the largest methods in the file (today's size is inARCH.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.mdrecords 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.
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 tools — generated, 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.mjsstubs DOM/canvas/fetchand importspublic/js/game.jsas a module. That is why the gate measures production code, not a reimplementation. tools/eval/serve.mjs:15bridges to the test case: it servespublic/and maps/to theindex.astrosource, 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:
| Generated | Script | Gate |
|---|---|---|
tools/eval/ARCH.md — symbol→line index and conflict table | tools/gen-arch.mjs | npm run arch:check |
The numeric blocks of README.md and of this documentation | tools/gen-docs.mjs | npm 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
--checkin the gate. Counts of lines, of characters, of weapons, of maps, of scripts, of invariants, version, thepackage.jsonscript 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.
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 ofarquivo:linhaacross 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.