Skip to main content

Stack and tools

This page answers the question "what is this made with?" — and it answers with the declared version, not the remembered one. The table below is generated by node tools/gen-docs.mjs from package.json, docs/package.json and the vendored Three.js itself.

LayerToolVersion
3D engine (WebGL)Three.js, vendoredr160
Gamevanilla ES modules, zero build44 files
SiteAstro with SSR^7.1.1
HostingVercel adapter^11.0.6
Databasemanaged Postgres (RLS; private schema)^2.110.7
Browser checksPlaywright^1.62.1
GLB pipelinegltf-transform^4.4.1
Mesh compressionmeshoptimizer^1.2.0
Imagessharp · resvg^0.35.3 · ^2.6.2
This documentationDocusaurus3.6.3
CI runtimeNode22

Three.js comes from public/vendor/three.module.js. Of the scripts in tools/, 109 import Playwright, 37 import gltf-transform, and 4 import meshoptimizer.

Block generated by node tools/gen-docs.mjs. Source: dependencies/devDependencies do package.json · REVISION de public/vendor/three.module.js

The two zones, and why the boundary is hard

The repository has two applications with opposite rules, and almost every misunderstanding from newcomers is born from treating them as one.

public/ — the GAME: Three.js, WebGL, zero build

The game is vanilla JavaScript with ES modules served raw. There is no bundler, no transpiler, no build step. The browser downloads public/js/game.js exactly as it is in the repository.

This is a design decision, not laziness, and it pays off in three places:

  1. The game runs by dragging the folder onto any static host. It does not depend on Astro, does not depend on Vercel, does not depend on npm at runtime. It is what makes it viable to ship on a portal (CrazyGames, itch) without rewriting anything.
  2. The harness can boot the Game class in pure node. tools/eval/harness.mjs imports the production code with DOM and canvas stubbed, and measures the real game in seconds. A bundler in the middle would break that — and without that there is no gate.
  3. node --check on each file is a complete syntax test (npm run syntax), because the file node parses is byte for byte what the browser executes.

The price, which is also real: cache. Without a build there is no hash in the filename, so invalidation is manual — the import map's ?v=. The rule and what it has already cost are in Getting started, in one place only.

Three.js is vendored at public/vendor/three.module.js (plus vendor/addons/). No CDN and no runtime dependency: the import map points to the local file. Do not add a CDN or a runtime package without opening an issue.

WebGL is the target, and a weak machine is a requirement. There is a quality: 'low' path with no post-processing and a querystring kill-switch for every risky change (?bloom=0, ?ao=0, ?fxaa=0, ?water=0). Every graphics change that requires extra rendering has to declare its measured cost.

src/ — the SITE: Astro with SSR on Vercel

The site is Astro with the Vercel adapter. astro.config.mjs is set to output: 'static' with adapter, and the routes that need a server opt in with export const prerender = false one by one — the case of /ranking, /u/*, /sitemap.xml and all the /api/* routes.

Here a framework is welcome. The rules that apply:

  • the Supabase service_role lives server-side only and never reaches the browser;
  • site in astro.config.mjs is with www, and every canonical comes from there;
  • vercel.json carries the security headers (CSP, HSTS, nosniff, Referrer-Policy, Permissions-Policy) and the CDN cache.

And the gotcha that costs everyone their first hour: src/pages/index.astro IS the game, served at the / route. There is no public/index.html.

Database — managed Postgres, RLS and telemetry

The ranking and the telemetry live in a managed Postgres. Schema and migrations are private (outside the repo — a security decision); the runtime only uses the envs. optional obfuscation that was delivered ready and deliberately not applied.

Security does not come from hiding the anon key — it is public by design, although the current client does not receive it. It comes from the policies, from the per-column grants and from the rate limit counted in Postgres (src/lib/ratelimit.ts + RPC rl_take), not in lambda memory.

Player identity uses a stable UID to select the account and a token to authenticate the session; the nickname is display data. Old clients and a database pending the private migration retain a temporary nickname + token fallback.

The ranking is off today (RANKING_ON in src/lib/site.ts) and was replaced by anonymous telemetry. It is a flag, not a removal — details in Current state.

None of this is required to run the game

Without the Supabase variables the site boots the same: the ranking routes respond 503 not_configured and the pages show the notice. The game in public/ uses none of them. See .env.example.

Asset generation — what is AI-generated, and by which service

Almost every 3D and 2D asset in this game is generated, not hand-modeled. The real flow, not the hypothetical one:

ServiceWhat it generatesScriptKey
mint.gg (Mint MCP)rigged characters, packs, animationMCP tools; mint-assets.json records the resultowner account via MCP
Tripo3D3D props from texttools/gen-asset.mjs --provider tripoTRIPO_API_KEY
Meshy3D props and riggingtools/gen-asset.mjs --provider meshyMESHY_API_KEY
OpenRouter2D arttools/gen-image.mjsOPENROUTER_API_KEY

mint-assets.json records 7 assets generated through Mint (3 mint-model · 4 mint-asset-pack).

API keys live in the gitignored root .env; generation is offline and the game runs without them.

Block generated by node tools/gen-docs.mjs. Source: git grep -l SDK -- tools/ | grep .mjs · mint-assets.json

Characters: mint.gg

The playable characters are rigged GLBs generated by Mint (mint.gg), through the MCP tools — start_model_generation with riggable_character in T-pose and empty hands, then animate_generated_model to come out with a skeleton.

Two non-obvious facts that save money and rounds:

  • Mint's base model does not come rigged. The skeleton only appears at the animation step. The cheap path for a new character is: generate the base → rig it with one clip → use its rigged_character_glb → reuse the shared clips.
  • Meshy rigs share the same bone names (Hips, Spine, Head, RightHand…), so a clip pack generated once matches by name on any rig in the family. That is why public/models/anims/ has shared clips and per-character clips at the same time, and why the index.json manifest exists (npm run anims) — without it the game requested clips from characters that do not have them and filled the console with 404s.

mint-assets.json is the record of what was generated: assetId, chatUrl and a note on what went wrong in the previous attempt. Without that record there is no reviewing and no regenerating — the asset becomes a binary with no provenance sitting in the repository.

3D props: Tripo3D and Meshy

tools/gen-asset.mjs generates a prop from text, downloads the GLB and writes it already optimized into public/models/props/:

node tools/gen-asset.mjs --prompt "caixa de som de baile" --id caixa_som
node tools/gen-asset.mjs --provider meshy --prompt "carro tunado" --id carro_tunado
node tools/gen-asset.mjs --resume <task_id> --id caixa_som # task already paid for

Tripo is the default; Meshy is the alternative. --face-limit (default 12000), --raw-only to skip optimization and --timeout complete the options.

Maps do not need this. The registered maps are procedural geometry in Three.js — street, shack, alley, sidewalk and roundabout are box and plane, which is what map_*.js already does. What comes from GLB are props.

2D art: OpenRouter

tools/gen-image.mjs is the 2D sibling: it generates faction posters, wallpapers and splash art from text (+ reference images), and delivers the file already framed and compressed for the box the screen will draw it in.

The cropping lives in the script on purpose. A faction board is a 245×620 box with background-size: cover; landscape art enters it showing ~26% of its width — that is how four cast posters became four portraits of ONE character. The generator does not offer that aspect ratio, so whoever publishes is who settles the bill: generate at the closest aspect and center-crop down to the box's real ratio. That way what you look at before committing is byte for byte what the player sees.

The keys

TRIPO_API_KEY, MESHY_API_KEY and OPENROUTER_API_KEY are read from a .env at the root — gitignored, mode 600. Three rules the two scripts share, each with a reason:

  1. The key never comes from argv. A command-line argument leaks in the ps of any process on the machine.
  2. The Authorization header only goes out to the API's own host. The finished GLB comes from a third-party CDN (signed link); sending the key along on the download would hand the credential to a host that is not the provider's. There is an allowlist, and redirect: 'error' prevents a 3xx from carrying the header to another domain.
  3. Nothing is printed without going through redact().
These three keys are not in .env.example

.env.example covers only Supabase and the audio bundle. The asset-generation keys exist only in the owner's .env. Anyone who clones and wants to generate assets needs to create them by hand with the names above — it is documented here and in each script's header, not in the example.

GLB optimization: gltf-transform and meshoptimizer

Every GLB that enters the repository goes through @gltf-transform (dedup, prune, textureCompress with sharp to WebP) and, on the static path, through meshoptimizer.

The reason is a real ceiling: 250 MB on CrazyGames. A raw character GLB comes in at 4-5 MB, dominated by 2K PNG texture — and the optimization is almost entirely texture, not mesh.

The pipeline scripts live in tools/: optimize-props.mjs, optimize-static.mjs, optimize-fpvm.mjs, optimize-tribos.mjs, plus the rig ones (rig-from-donor.mjs, reskin-glb.mjs, retarget-glb.mjs) and the inspection ones (inspect-glb.mjs, inspect-anim.mjs, bones.mjs).

Playwright — every harness that needs a browser

A ruler (quality gate) that depends on pixels runs in Chromium via Playwright. That is the case of tools/eval/*-capture.mjs, telas-*.mjs, select-inflate.mjs, crash-watch.mjs and fv-verify.mjs, among others.

Two things you need to know before running any of them:

  • It is expensive. Software rendering (SwiftShader) runs the game at ~0,3 FPS; an in-game capture costs minutes per map/aspect. That cost is exactly what pushed the gate to pure node — and it is why the pixel invariants (PX1PX4) are skipped, with the reason stated.
  • One session at a time. Two headless captures in parallel take down the boot and produce a "frozen countdown" that looks like a bug and is load. A single agent runs the browser.

Some harnesses need the server up: npm run eval:serve & first.

Agent skills

This repository versions skills — packaged instructions an agent loads before working. They live in .agents/skills/, and .claude/skills/ are symlinks to there.

CountHow manyMeaning
Declared in skills-lock.json39third-party skills pinned by source and hash
Versioned10content available in a clean clone
Versioned with SKILL.md10directly readable instructions

The counts differ by design: the lock records more third-party skills than the repository vendors.

The house workflow skill, gauntlet-fps, is present locally and does not belong to the third-party lock.

Block generated by node tools/gen-docs.mjs. Source: git ls-files .agents/skills · skills-lock.json

The vast majority are third-party and cover Three.js (materials, lighting, shaders, post-processing, glTF loading, geometry, animation) and game design. They are optional context: nothing in the game depends on them.

The gauntlet loop

The skill that is not third-party is gauntlet-fps, and it encodes this house's work cycle:

adversarial critic → builders in parallel → measured capture → A/B verification → regression hunter

When to use: improving, evaluating or reviewing any part of the game — graphics, map fidelity, weapon feel, menu, HUD, bots, movement — and when something is reported as ugly, weird or "doesn't look professional". When not to use: a one-line mechanical task, or a conceptual question that does not touch the game.

The whole cycle — the three rules, the problem each one solves and each one's measured case — has its own page: AI instrumentation. This section exists only to say that the skill exists and when to trigger it.

The documentation

This doc is a separate Docusaurus, in docs/, with its own package.json and its own node_modules. Nothing here is imported by the game or by the site.

cd docs && npm install && npm start   # http://localhost:3000/docs/
cd docs && npm run build # docs/build/
cd docs && npm run build:site # builds INTO public/docs/

baseUrl is /docs/ because the output can be built into public/docs/, and Astro copies all of public/ into dist/client/.

Every number on this page is generated

The tables above come from node tools/gen-docs.mjs and are checked by npm run docs:check, inside check:fast. The mechanism — what goes into a generated block, what stays hand-written, and how to paste a new block — is in Architecture.