How to contribute
The number below is not rhetoric, and it is not hand-written: it comes from git shortlog -sn --no-merges minus the authors that are AI agents (which sign as Claude /
Claude (gauntlet …)).
11 human author identities sign commits in this branch: ruben-cytonic, Emerson Garrido, Ruben, rubenmarcus, Ruben Marcus, William Oliveira, Juan Versolato Lopes, daeeseD, matheusgb, Maná Soares, daltonfontes. Automated identities are excluded. A Git author name is not necessarily one unique person.
Block generated by
node tools/gen-docs.mjs. Source:git shortlog -sn --no-merges (descontando autores que são agentes)
There is no team, there is no community, there is no reviewer queue — there are these people and an automated gate.
main has a fourth contributor that this working branch does not contain — 13 commits
of a desktop client, merged in July. Who, how much, and why this matters for any
licensing decision is in CONTRIBUTING.md (license section and surfaces).
This is relevant to you in two opposite ways. The bad one: if your PR gets stuck, it can
take a while. The good one: almost the entire ruler (quality gate) is machine. npm run check gives
you the same verdict the maintainer would, before you open the PR, without waiting for
anyone. The barrier is low on purpose — it is one of the principles that do not change in
docs/ROADMAP.md.
But the ruler is not.
One-sentence summary: bring the number. A PR that changes visible behavior and brings neither a new invariant nor the reason it does not need one will come back with a question.
Setup
git clone https://github.com/rubenmarcus/csbrasil.git && cd csbrasil
npm install
npm run dev # http://localhost:4321 — the root route IS the game
Optional:
npm run fetch-audio # audio pack (without it: synthesized sounds)
Requirements: Node 22 (it is what the CI uses, .github/workflows/ci.yml:14) and Python 3 for
part of the harness (ref-measure.py, char_probe.py, mat_shade.py — they use numpy and PIL).
public/ does NOT run the gameThere is no public/index.html: the game's HTML is src/pages/index.astro, at the root route.
Use npm run dev. Details and proof in
Getting started.
Running the gate
npm run eval:vm # MANDATORY FIRST — see the warning below
node tools/eval/invariants.mjs # the whole gate
node tools/eval/invariants.mjs --json # machine-readable output
npm run check # syntax + vm + gate + recoil + bots
eval:vm runs BEFORE invariants.mjs. Always.The viewmodel invariants (VM1–VM19) read tools/eval/vm_mint_audit.json, which is what
eval:vm writes. Running the invariants with that JSON stale measures yesterday's
viewmodel and invents reds: on 04/08/2026 the JSON was at V0=80° against game.js
at V0=42°, and VM5 flagged 26/26 weapons out; after npm run eval:vm, 3/26.
VM1 dropped from 26/26 to 2/26 and VM9 went green.
The order of npm run check has already been fixed (package.json) — the care is for when
you call node tools/eval/invariants.mjs by hand. Details: BUG-02 in
KNOWN-BUGS.md.
Real cost: on a 2-CPU machine, about 10 minutes. It boots the real game five times (once per map), runs 60 s of bot simulation per map and audits every weapon GLB. Run it before opening the PR, not after receiving the review.
Individual harnesses, for when you want to iterate fast on a single front:
node tools/eval/vm-mint-audit.mjs # viewmodel framing (the whole arsenal)
node tools/eval/vm-solve.mjs # does a feasible point exist for the VM invariants?
node tools/eval/vm-solve.mjs --atual # only the margins of the current config (instant)
node tools/eval/botsim.mjs 60 all # bot navigation, all maps, fixed seeds
node tools/eval/char-probe.mjs # characters (C1..C6)
node tools/eval/map-check.mjs all # map geometry (MAP1-MAP3, CTF1)
node tools/eval/mat-check.mjs # material/light/fog/texture
node tools/eval/pickup-check.mjs # is every pickup reachable?
node tools/eval/ui-check.mjs # UI1 contrast · UI2 clutter · UI3 dead area · UI4 rhythm
Before saying you fixed it: mutate
MUT=ui1_ctf_scrim_fraco node tools/eval/ui-check.mjs # expects UI1 to go RED
Undo your own fix and check that the gate goes red. If it stays green, what you measured is not what you fixed. It is the most expensive lesson in this repository and it has a whole page: Mutation test.
If your PR is a bug fix
Use the bug-hunt skill (.claude/skills/bug-hunt/SKILL.md). It is the step-by-step of this
doctrine applied to defects — with the real case that bought each rule, the template for the
KNOWN-BUGS.md entry and the one for the final report, including how to declare what you did not
verify. It works for agents and for people.
What a PR needs
1. A new invariant — or the reason it doesn't need one
This is the rule that defines the project. Every PR that changes observable behavior brings one of two things:
- A new invariant in
tools/eval/invariants.mjs, with a ceiling that has provenance (reference file + measured pixel + script that reproduces it), or - One sentence in the PR description saying why it does not need one. Valid reasons: "it is already covered by invariant X" (say which), "it is a refactor with no observable change — the gate gives the same score before and after" (paste both), "it is pure content (text, asset) with no game rule attached".
Invalid reason: "I tested it manually and it looked good".
Why: intent that does not become an invariant gets optimized away. One round took the gate from 16/21 to 19/21 without loosening a single ceiling, and was rejected, because it silently destroyed an aesthetic decision that no invariant encoded. Full case in The gate.
2. The gate cannot get worse
Paste the output of node tools/eval/invariants.mjs before and after. If any critical one went
red, the PR does not go in. If you fixed a red one, say which and show it.
The gate is red today (see which ones, and the living list with root causes is
in KNOWN-BUGS.md). That is not a license to make it worse: the commitment is "your change
adds no red".
3. Numbers, with arquivo:linha
The claim "I improved the lighting" is not reviewable. "The praca_poderes floor was 8 points
of L* above the walls, cause at map_brasilia.js:NNN, fixed to X" is. This
requirement is not style — it is what allows the next round to check your work.
4. One front per PR
Consult the conflict table (Architecture). A
PR that touches weapons + UI + map is three hidden PRs, and will collide with three fronts. In
game.js, edit by section; never overwrite the whole file.
5. Repository hygiene
node --checkon every file inpublic/js/that you edited (the CI does this first,.github/workflows/ci.yml:19-20).- Comments in Portuguese explaining the why, not the what. It is the repo's culture and it is what survives the next handoff.
- Never delete a provenance comment in a cleanup PR. That long paragraph explaining where the number 0,513 came from is what keeps the next round from repeating three lost days.
- Touched
public/js/*.js? Bump the?v=on both sides —public/js/version.jsand the import map ofsrc/pages/index.astro. It has already cost days of "fixes that never arrived". - Touched
public/js/*.js,maps.js,characters.js, or a dependency? Runnpm run docsand commit it along.docs:checkis incheck:fastand will fail — it takes less than a second and is what keeps the doc from lying again. - No copyrighted assets. No committed
service_rolekey. - No runtime dependency in the game. Three.js is vendored; the game has to run by dragging the folder onto a static host.
6. Editorial line
From CONTRIBUTING.md:7-16: the game has no political side (both teams have the same
mechanics), does not incite hatred, does not use real people — only original archetypes, no gore.
Contributions that violate this are rejected. It is not bureaucracy: it is what protects the project
from takedowns and from becoming something else.
How to add a weapon
The pipeline is data-driven from the GLB. Weapon GLBs live in public/models/weapons/
(the count is in the generated block of Getting started).
- Put the GLB at
public/models/weapons/<id>.glb. Geometry only — the material comes from the pipeline (MAT1requiresmetallicFactor 1 / roughnessFactor 1with a metallicRoughness map, which is the standard for all the current ones). - Declare the weapon in
public/js/weapons.js. The fields the gate reads:len— length in meters.ARM4fails above 1,25 m outside bolt-action snipers. It is the field that normalizes scale; it is not decoration.gripZ— fraction of the length, counted from the muzzle, where the grip sits (ak/m4 use 0,62 — it lands on the trigger guard). It is what anchors the hand.vm— mesh scale multiplier in the viewmodel. It exists because them92hit 14,50% against VM18b's measured ceiling of 13,09%.scope: truerequiresspreadScopedeclared — that isARM1, and it exists because of the "sniper without zoom".
- Run the auditor:
node tools/eval/vm-mint-audit.mjs. It opens the GLB with its own parser, projects the viewmodel in both aspects and writestools/eval/vm_mint_audit.json. That JSON is versioned — without it, VM1–VM6/VM9/VM10 become SKIPPED, which is a gate green by absence of data (.github/workflows/ci.yml:24-27). - Run the gate. You will face VM1, VM3, VM5, VM9, VM12, VM16, VM18, VM18b,
VM19 — nine framing invariants, all with a range measured on a reference frame. If it
does not close, use
node tools/eval/vm-solve.mjsinstead of tuning by eye: it reads the ceilings frominvariants.mjsitself and says whether a feasible point exists, or which pair of invariants intersects empty and by how much. - Commit the updated
vm_mint_audit.jsonalong with the rest.
How to add a character
45 GLBs in public/models/characters/, 44 measured by char-probe.mjs.
- GLB with a rig, in bind pose, feet on the ground.
CHR3requires|bbox base| ≤ 0,01 min the bind pose and in every clip — the sign separates two defects:y < 0is feet inside the ground,y > 0is a character floating in the air. - Declare it in
public/js/characters.js/public/js/glbchars.js. - Run
node tools/eval/char-probe.mjs. What it will demand:CHR1— anthropometric proportion and "balloon" index. Today it is red for the entire cast, so you are not the one who broke it; but do not make it worse.CHR2— body height within half a head hitbox (dispersion ≤ 0,15 m). Measured without accessories: hat/hair/pole inflate the bbox and make the GLB path (the CHR2 evidence itself points atglbchars.js:319-322) shrink the body.CHR4— no palm born buried inside the body.CHR5/CHR5B— finish (normal + roughness + AO). CHR5B went green on 04/08: it was the "three finish levels on the same screen" that the owner described, with a good part of the cast lacking any surface map, and today there are zero characters without one. A new character without normal + roughness reopens the red — bring the maps.CHR6— no pair with the same silhouette (IoU ≤ 0,98).
How to add a map
Today maps are code, not data: every map_*.js is geometry declared by hand, and the
biggest ones rival the system modules in size. Migrating this to JSON is the Phase 2
content-as-data of
docs/ROADMAP.md, and it is the
highest-leverage contribution in the project.
The registry, generated from the MAPS of public/js/maps.js:
| Id | Menu name | Opens in | File in public/js/ | Lines |
|---|---|---|---|---|
praca_poderes | Praça dos Três Poderes | rounds | map_brasilia.js | 1,830 |
piscina_treta | Piscina da Treta | rounds | map_piscina.js | 810 |
loja_h | Loja H (Estacionamento) | capture | map_havan.js | 1,964 |
ferro_velho | Ferro Velho do Zé | capture | map_ferrovelho.js | 1,888 |
quebrada | Quebrada (Rua do Baile) | capture | map_quebrada.js | 1,599 |
posto_treta | Posto da Treta | capture | map_posto.js | 489 |
upa_24h | UPA 24h da Treta | capture | map_upa.js | 288 |
obras_prefeitura | Obras da Prefeitura | capture | map_obras.js | 240 |
atacadao_treta | Atacadão da Treta | capture | map_atacadao.js | 255 |
parque_treta | Parque da Treta | capture | map_parque.js | 402 |
velho_oeste | Velho Oeste da Treta | capture | map_velho_oeste.js | 433 |
penitenciaria | Penitenciária da Treta | capture | map_penitenciaria.js | 247 |
12 registered maps - 2 open in rounds and 10 in capture. ctfMode sets the initial mode; it does not lock it. There are 14 map_*.js files on disk, so a file alone does not make a map playable.
Block generated by
node tools/gen-docs.mjs. Source:objeto MAPS de public/js/maps.js
Two warnings that cost time if you do not know them:
praca_old("Praça (clássico)") NO longer exists. It left the registry andpublic/js/map.jswas deleted along with it (the owner's literal request: "let's delete classic praça"). If you findpraca_oldin a ruler output, that output predates the removal — it is the case of the table pasted in Current state.map_piscinao_ramos.jsexists on disk and is NOT in the registry (it is the "Piscinão" version, out of the menu). A map file inpublic/js/does not imply a playable map; what decides is theMAPSobject.
To add a map in today's format:
- Create
public/js/map_<name>.jsexporting abuild<Name>()function. Usemap_piscina.jsas a reference — it is the smallest of the registered ones (the table above has each one's size). - Register it in
public/js/maps.js:8-36— display name,build, andctfMode: trueif the geometry was drawn around flags.ctfModeopens the map in capture; it does not lock it.ctfOnlyno longer exists:MOD1fails any map that forces the mode. The player chooses. - Run
node tools/eval/map-check.mjs <mapId>. What it measures, all by raycast against the real world:MAP1— no spawn and no walkable floor with the body inside solid geometry. Ceiling = a 0,30 m step (above that it is not "stepping over", it is "being inside").MAP2— each team spawns entirely on the same floor; respawn not visible from outside (measured with the game's own_losClear, the same function that decides whether the bot shoots you).MAP3— stairs within NBR 9077 / Blondel (riser 16–18 cm, tread 25–32 cm, 2h+p 63–65 cm, width ≥ 1,20 m) and the navigation graph + the flood-fill climb up them.CTF1— flags not collinear, ≥ 2 rays from the nearest spawn, none buried.
- Run
node tools/eval/pickup-check.mjs(it feedsVM14): every pickup must be reachable on foot, by flood-fill of real connectivity on a 0,25 m grid seeded at the spawns of both teams. It has already happened that weapons fell into the pool ofpiscina_tretawith the gate reporting a gap of 0,0000 — GREEN. - Run
node tools/eval/botsim.mjs 60 <mapId>: the bots must navigate your map without getting stuck (BOT3stuck ≤ 4%), without walking sideways (BOT1) and without spinning in place (BOT2). A disconnected waypoint is the most common defect of a new map, and it has broken PRs before (it is the defect the "content as data" direction exists to kill).
Good first tasks
Ordered by (impact ÷ effort). All are real, verified in this tree, and none requires understanding the whole game.
Very good for the first PR
The entry tasks live in docs/issues/,
one per file, each with context, what to do, acceptance criteria and which files
to touch. The README.md there indexes by available time (30 min / 1 h / 2-3 h) and by area
(SEO, UI, backend, CI). None of them requires touching public/js/*.js, on purpose:
it is the code where the gameplay agents work in parallel and where the conflict table
of tools/eval/ARCH.md rules.
They exist as files, not as issues. There is a ready-made script —
docs/issues/abrir-issues.sh, with gh authenticated:
bash docs/issues/abrir-issues.sh --dry-run # prints title + labels, opens nothing
bash docs/issues/abrir-issues.sh --labels # creates the 8 labels in use
bash docs/issues/abrir-issues.sh # opens the 15
It is idempotent (it looks for an issue with the same title before creating one) and has never been
run: the repository belongs to the owner and opening an issue is an irreversible action under his name.
In other words, if you look for the tasks in the Issues tab, you will not find them — read the .md files.
It told you to fix the README.md (done), add arch/arch:check to the
package.json (they exist today), regenerate the ARCH.md and make the tp-mount-probe skip
when public/models/anims/ was missing — a folder that is versioned today (438 files
in git ls-files public/models/anims). A doc that tells you to do what has already been done burns
someone's first contribution; that is why the list became a pointer to docs/issues/,
which is maintained.
The only item from the old list that still stands — and is now fixed: the
message of invariants PX1–PX4 pointed to tools/eval/motion.mjs, which never
existed in git (a phantom pointer). The skips now honestly declare "no dedicated
harness (PX debt)": what runs in CI browsers today is portao-browser (real game
boot + graffiti + selection-screen silhouette), and a dedicated viewmodel
harness remains open work.
Real work, still accessible
-
VM12 and VM1 on the specific weapons. VM12 fails on 5 of 52 measurements (worst
famas@3:2 at 0,660 against the 0,62 ceiling); VM1 on 2 of 26 (famas,uzi). They are per-weapon fixes, with a measured range andvm-solve.mjsavailable to prove feasibility. Front: ARMAS/VIEWMODEL. -
BOT8 — bot with line of sight and not shooting. It is the cheapest debt on the list, and the root cause is already found:
game.js:5361evaluatesconst hasTurn = … this._duelToken(b)every frame, before any "can shoot" gate — and_duelTokendoes not consult, it reserves the token. A bot that is reloading or has no firing line steals one of the 2 tokens and holds it; the others cross the field of view without firing. The fix is to move the call inside theif. Measured in the last recorded run: 4 episodes, maximum silence 4,23 s — and note that it got worse since the baseline's 2,7 / 3,03 s, which makes it also a good A/B. Front: BOTS/JOGABILIDADE. Details: BUG-03. -
Characters: proportion (CHR1) and surface maps. Beware of stale doc here: the CHR5B went GREEN on 04/08 (the 27 of 44 characters without a surface map went to 0 of 44), so that specific item has already been done — do not redo it. What is still red is CHR1/CHR3/CHR4, and the underlying cause is rig, not runtime (BUG-10). Read the KNOWN-BUGS before picking it up. Front: PERSONAGENS.
-
setTimeoutnot cleared indispose()— a leak between matches, pointed out inRELATORIO-ANALISE.md:134. The line numbers in that report are stale (thegame.jshas moved ~1.000 lines since then); find the current ones withgrep -n setTimeout public/js/game.jsand check which ones survivedispose(). A good hygiene PR with a measurable effect on the heap. Front: red zoneconstructor/update— coordinate first.
High value, needs a conversation first
-
Extract
_updateBot()(772 lines). Marked as an extraction candidate by the generated index itself. It needs prior agreement on the partition, because the region is contested. -
Maps as JSON (Phase 2). Geometry, colliders, occluders, spawns, pickups and waypoints as data, with a single loader and waypoints validated by test. It is what turns "risky code PR" into "open a JSON". Open an issue first.
-
A nightly CI job with a browser to unblock PX1–PX4. Four pixel invariants have been skipped since forever.
Process
- Big feature? Open an issue first (see
IDEAS.md). - Fork + branch
v2/<subject>—v2/multiplayer,v2/audio,v2/ui-hud. The prefix is the release cycle (top ofCHANGELOG.md), and the convention was born from a concrete problem: on 04/08 the working branch was still calledfeat/evio-feel— the name of a July feature — with 143 commits of different subjects piled up. A name that does not say what the branch is becomes a dumping ground. (Source:CONTRIBUTING.md.) - Run
npm run check. Paste the output into the PR. - Small PR, one front, description with numbers and
arquivo:linha. - By contributing you license under whatever license the
LICENSEstates at the moment of your PR. What it is today and which files must change together in a swap: the license section ofCONTRIBUTING.md. If this is decisive for you, read it before writing the first line.
Reporting a bug: what happened, what you expected, steps to reproduce, browser/OS and a
screenshot of the console (F12). And if the bug is behavioral, it will become an invariant — that is
how it never comes back (tools/eval/invariants.mjs:20-21).