Imported from zavtraleto/glorp-battle (
AGENTS.md). Install upstream withnpx skills add zavtraleto/glorp-battle. Copyright stays with the author.
Glorp Battle
Mobile-first, portrait, real-time grid battle prototype modeled on Mega Man Battle Network: MMBN6 for combat rules, MMBN3 for content. Vite + TypeScript (strict) + Three.js. The whole interface is a 3D physical terminal (NET-01) with the battle on its CRT. Target: Chrome (Android first), 60–120 FPS.
- Spec:
docs/GDD.md(Russian). It is the contract: read the relevant section before changing gameplay. - Interface spec:
docs/TERMINAL.md(Russian) — the physical terminal NET-01 (stages T1–T3). It wins over the GDD for controls and presentation. - Battle look:
docs/BATTLE_VISUAL.md(Russian). Before changing how anything looks, read its §10.1–10.2 and TERMINAL.md §9.1–9.2: the code map (which file owns which part of the look) and the pitfalls we already hit. - Current plan:
docs/superpowers/specs/2026-09-25-battle-feel-plan.md— tasks 1–4 after the 2026-09-25 playtest (tempo, hand, telegraphs, encounter lab + Champy); each task records its status and decisions there. - Live build: https://zavtraleto.github.io/glorp-battle/ (public repo
zavtraleto/glorp-battle).
Language
- Talk to the user in Russian.
docs/GDD.mdandREADME.mdare Russian. - Code, comments, identifiers, commit messages: English.
- Everything the player sees is English and comes from
src/i18n/en.tsviat('key')(chipName,chipDesc,enemyNamefor data). Never put player-facing literals in terminal code. The CRT uses a 5×7 pixel font (terminal/crt/pixelFont.ts): a new character needs a glyph (a test checks every string).
Commands
npm run dev # Vite dev server on :5173 (also exposed on the LAN for phone testing)
npm test # Vitest, node environment
npm run typecheck # tsc --noEmit
npm run build # typecheck + production build into dist/
Before every commit: npm test and npm run build must pass. Pushing to main runs .github/workflows/deploy-pages.yml (test → build → GitHub Pages); a red test blocks the deploy.
Design rules
- Source of truth for anything the GDD leaves open: MMBN6 as the combat base, MMBN3 for content and systemic depth (chips, virus stats, folder content). If BN3 lacks something, use BN6 and say so. MMBN1 is no longer a reference. Labels in docs:
[MMBN3],[MMBN6],[оценка],[решение]. - Deliberate deviations — do not undo them:
- There is no Buster (GDD §4): only chips deal damage. Spent chips reshuffle into the draw pile when it runs dry (GDD §5).
- The run is linear and the folder never changes during it: no path choice, rewards, legacy or saves between runs (GDD §10).
- One trackball micro-swipe = exactly one panel. After each accepted step, the current finger position becomes the next gesture anchor; a stationary finger never repeats movement. No hold-to-repeat on gestures (keyboard keeps it).
- A
DBGbutton (bottom-right) toggles debug tools in every build; pause is an icon in the top-left corner of the CRT. - Working names replace Capcom names: Mettik, Canodron, Hopzap, Bladdy, … (see GDD §0.1).
- When behavior changes, update the GDD in the same change. Mark new decisions
[решение YYYY-MM-DD], estimates[оценка], and keep §17 (tuning table) in sync withsrc/config/tuning.ts.
Architecture
src/sim/ pure simulation: no DOM, no Three.js — World, Player, field, enemies, attacks, chips
src/app/ Session + Run: title (Play / Tutorial) → linear run of 6 multi-wave battles (path → battle → …) → complete; death / abandon → game over; pause; tutorial director
src/render/ battle view (BATTLE_VISUAL.md): grid + cell states, procedural sprites, FX, palette pass; reads sim state, never mutates it
src/terminal/ 3D physical terminal: CRT (battle render target + HUD/menu canvas), controls, chip rail
src/core/ fixed-step loop, seeded RNG, input (commands, swipe, keyboard, browser-gesture guards)
src/data/ encounters, chips, folders (starter, debug all), tutorial, enemy looks and levels — content is data, not code
src/config/tuning.ts every gameplay number (seconds), live-editable in the debug panel
src/debug/ Tweakpane panel (lazy-loaded; folder tree in tuningLayout.ts), stats overlay, dead-time meter, URL params
Invariants:
-
Fixed 60 Hz step. Durations live in
tuningas seconds; convert withsecondsToTicks(). Never hardcode numbers in logic. -
Two clocks in
World:tickadvances only inACTION(and end-of-battle animations);uiTickalways advances and drives state timers (stateElapsed). Enemy/attack timers usetick, so they freeze duringBATTLE_INTROandPAUSED. Render withalpha = 0whileworld.simFrozen. -
Input is abstract. The terminal controls and the keyboard push
Commands intoInputState;World.step()consumes them once per tick. OnlyACTIONreacts to them, including chip selection (rail taps and keys 1–5 sendselectChip); menus callSessionactions throughTerminalHandlers.menu. -
Sim → view via events. The sim pushes
SimEvents;main.tsdrains them each tick into FX, the terminal (CRT flash, damage numbers) and the debug log. Add an event instead of letting render peek at transient sim state. -
Determinism. All sim randomness uses
world.rngFolder/world.rngAi(seeded, forked streams). NoMath.random()insrc/sim. Session derives a seed per run; the run derives its folder, each step's encounter pick and each step's battle from it. -
Enemies extend
Enemy(src/sim/enemies/enemyBase.ts), act only throughEnemyContext, register infactory.ts, get a seed indata/enemies.ts, and are placed indata/encounters.ts. Scale damage and timings withthis.dmg()/this.ticks()so levels work. Lane attacks implementLaneMoverso FX can interpolate them. -
New tunables go into the right group in
tuning.ts(one flat group per chip, per enemy, …; keys without a group prefix:mettik.HP,cannon.DAMAGE); the debug panel picks them up automatically (add a slider range inRANGESindebug/tuningLayout.tsif the auto range is wrong). A new group must be placed inTUNE_LAYOUT— a test checks it. -
Battle palette. Battle materials emit a signal, not a colour: G = phosphor, R = red, B = accent (
render/palette.ts); the palette pass maps each pixel to its palette colour dimmed by the signal's brightness (no dithering since 2026-09-19). The CRT picture is drawn 1:1 with the glass's render pixels;CRT_RES_W/Honly set its aspect. No text in battle: HUD shows only HP segments, damage numbers and menus. -
Panels live in the sim.
world.fieldowns panel state and ownership; movement, warps and waves askfield.canStand/field.panel, and anything leaving a cell callsfield.onLeave. Objects (world.objects) sit inOccupancyand stop shots. -
New chips are data: a
shape, acolor, optionalonHit/field/guardindata/chips.ts; a new shape or field action goes intosim/chips/patterns.ts/World.applyFieldAction. Add strings toi18n/en.ts, an icon toterminal/chips/chipIcons.ts, and a test intests/chipUse.test.ts. -
The terminal only reads sim/session state; it changes them only through
InputStateandSessionactions. Its mode is derived fromsession.screen+world.state, never stored. -
Terminal feedback is immediate: a control reacts on
pointerdownin the same frame; animations tied to game outcomes (chip eject, hits) are driven bySimEvents and never delay the action. -
Terminal look is PS1 low-poly + big pixels: procedural geometry and small canvas textures with nearest filtering, low-res render target upscaled without smoothing, CRT shader only on the screen glass. Stay within the performance budget in TERMINAL.md §10.
Comments cite GDD sections (// GDD §8.2). Match the surrounding style: short doc comments, no narration.
Testing
- Tests drive
World/Sessiondirectly withstep(dt, { commands, held }).Sessiontakes{ seed, cheats, folder }and has no storage. - Use
skipIntro: trueto start inACTION,cheats: { god, aiEnabled: false }to isolate systems,world.giveChip(...)to queue chips. - Reset tuning in
beforeEach:mergeTuning(tuning, JSON.parse(JSON.stringify(DEFAULT_TUNING))). - A world with no living enemies becomes
BATTLE_WONon the next tick — keep at least one enemy when testing other systems. - Compute tick expectations from tuning (
T(tuning.x.Y)), never from literal frame counts.
Verifying in the browser
- Dev-only handle:
window.__glorp(world,session,input,loop,sceneRenderer/battleView,terminal,tuning,cheats,startBattle). - URL params:
?debug=1&seed=123&battle=3&encounter=e1&wave=2&tutorial=2&folder=all&god=1×cale=0.5(tutorial=Nopens the tutorial at lesson N;battle=/encounter=skip the title and use the starter folder, or theallfolder withfolder=all;encounter=s1…s5are the multi-wave run stages,wave=Nstarts from wave N;allholds one of every chip); terminal:?hitzones=1&rscale=400&crtres=240x320&bench=1. - Drive the terminal with synthetic
PointerEvents on#terminal-canvas; zone rects are in__glorp.terminal.layout.zones(CSS px). - Do not run
?bench=1or CPU-throttled measurements unless the user asks. - The in-app Browser pane does not run
requestAnimationFramewhile hidden, so the game looks frozen there. Use the Chrome DevTools MCP with phone emulation (390x844x3,mobile,touch) for anything time-based. - Pause the sim for screenshots with
__glorp.loop.clock.paused = trueandstepOnce(n).
Environment gotchas (Windows)
- Vite may miss a second write to the same file within a moment (stale CSS/modules). Apply all edits to a file in one write; if the page looks stale,
touchthe file or restart the dev server. ghis not on the bashPATH: use"/c/Program Files/GitHub CLI/gh.exe".- The global git email is a work address; this repo sets a local
user.email. Do not change git config. .gitattributesenforces LF; CRLF warnings on commit are expected.
Git
- Commit or push only when asked. Subject line in imperative English (
M7: ...,ci: ...,docs: ...), body lists the user-visible changes. - End commit messages with the
Co-Authored-Bytrailer given by the harness.
