Skip to main content

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, Ruben, Emerson Garrido, 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.

The block above counts the BRANCH, and the project is bigger than it

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).

Serving public/ does NOT run the game

There 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 --check on every file in public/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 sidespublic/js/version.js and the import map of src/pages/index.astro. It has already cost days of "fixes that never arrived".
  • Touched public/js/*.js, maps.js, characters.js, or a dependency? Run npm run docs and commit it along. docs:check is in check:fast and will fail — it takes less than a second and is what keeps the doc from lying again.
  • No copyrighted assets. No committed service_role key.
  • 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).

  1. Put the GLB at public/models/weapons/<id>.glb. Geometry only — the material comes from the pipeline (MAT1 requires metallicFactor 1 / roughnessFactor 1 with a metallicRoughness map, which is the standard for all the current ones).
  2. Declare the weapon in public/js/weapons.js. The fields the gate reads:
    • len — length in meters. ARM4 fails 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 the m92 hit 14,50% against VM18b's measured ceiling of 13,09%.
    • scope: true requires spreadScope declared — that is ARM1, and it exists because of the "sniper without zoom".
  3. 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 writes tools/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).
  4. 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.mjs instead of tuning by eye: it reads the ceilings from invariants.mjs itself and says whether a feasible point exists, or which pair of invariants intersects empty and by how much.
  5. Commit the updated vm_mint_audit.json along with the rest.

How to add a character

45 GLBs in public/models/characters/, 44 measured by char-probe.mjs.

  1. GLB with a rig, in bind pose, feet on the ground. CHR3 requires |bbox base| ≤ 0,01 m in the bind pose and in every clip — the sign separates two defects: y < 0 is feet inside the ground, y > 0 is a character floating in the air.
  2. Declare it in public/js/characters.js / public/js/glbchars.js.
  3. 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 at glbchars.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:

IdMenu nameOpens inFile in public/js/Lines
praca_poderesPraça dos Três Poderesroundsmap_brasilia.js1,830
piscina_tretaPiscina da Tretaroundsmap_piscina.js810
loja_hLoja H (Estacionamento)capturemap_havan.js1,964
ferro_velhoFerro Velho do Zécapturemap_ferrovelho.js1,888
quebradaQuebrada (Rua do Baile)capturemap_quebrada.js1,599
posto_tretaPosto da Tretacapturemap_posto.js489
upa_24hUPA 24h da Tretacapturemap_upa.js288
obras_prefeituraObras da Prefeituracapturemap_obras.js240
atacadao_tretaAtacadão da Tretacapturemap_atacadao.js255
parque_tretaParque da Tretacapturemap_parque.js402
velho_oesteVelho Oeste da Tretacapturemap_velho_oeste.js433
penitenciariaPenitenciária da Tretacapturemap_penitenciaria.js247

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 and public/js/map.js was deleted along with it (the owner's literal request: "let's delete classic praça"). If you find praca_old in a ruler output, that output predates the removal — it is the case of the table pasted in Current state.
  • map_piscinao_ramos.js exists on disk and is NOT in the registry (it is the "Piscinão" version, out of the menu). A map file in public/js/ does not imply a playable map; what decides is the MAPS object.

To add a map in today's format:

  1. Create public/js/map_<name>.js exporting a build<Name>() function. Use map_piscina.js as a reference — it is the smallest of the registered ones (the table above has each one's size).
  2. Register it in public/js/maps.js:8-36 — display name, build, and ctfMode: true if the geometry was drawn around flags. ctfMode opens the map in capture; it does not lock it. ctfOnly no longer exists: MOD1 fails any map that forces the mode. The player chooses.
  3. 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.
  4. Run node tools/eval/pickup-check.mjs (it feeds VM14): 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 of piscina_treta with the gate reporting a gap of 0,0000 — GREEN.
  5. Run node tools/eval/botsim.mjs 60 <mapId>: the bots must navigate your map without getting stuck (BOT3 stuck ≤ 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 are NOT open on GitHub yet

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.

This list once had five items, and four were done

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

  1. 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 and vm-solve.mjs available to prove feasibility. Front: ARMAS/VIEWMODEL.

  2. 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:5361 evaluates const hasTurn = … this._duelToken(b) every frame, before any "can shoot" gate — and _duelToken does 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 the if. 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.

  3. 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.

  4. setTimeout not cleared in dispose() — a leak between matches, pointed out in RELATORIO-ANALISE.md:134. The line numbers in that report are stale (the game.js has moved ~1.000 lines since then); find the current ones with grep -n setTimeout public/js/game.js and check which ones survive dispose(). A good hygiene PR with a measurable effect on the heap. Front: red zone constructor/update — coordinate first.

High value, needs a conversation first

  1. 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.

  2. 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.

  3. A nightly CI job with a browser to unblock PX1–PX4. Four pixel invariants have been skipped since forever.

Process

  1. Big feature? Open an issue first (see IDEAS.md).
  2. Fork + branch v2/<subject>v2/multiplayer, v2/audio, v2/ui-hud. The prefix is the release cycle (top of CHANGELOG.md), and the convention was born from a concrete problem: on 04/08 the working branch was still called feat/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.)
  3. Run npm run check. Paste the output into the PR.
  4. Small PR, one front, description with numbers and arquivo:linha.
  5. By contributing you license under whatever license the LICENSE states at the moment of your PR. What it is today and which files must change together in a swap: the license section of CONTRIBUTING.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).