# elfs-orcs-tower-defense — notes for agents This game is built with JumPHP. **Read `../../AGENTS.md` first** (rules, commands, API), then `BRIEF.md` (what the game must become, milestone by milestone), then **`SUIVI.md`** (where the work stands, the user's decisions, what comes next: in French; keep it up to date after each step, the user works from several computers). ## Checklist ```bash bin/jumphp test elfs-orcs-tower-defense # lint + phpstan + phpunit (unit + game tests) + headless smoke test bin/jumphp shot elfs-orcs-tower-defense 60 # side screen (the first one) -> games/elfs-orcs-tower-defense/var/screenshots/shot.png JUMPHP_INPUT='[[5,"confirm"]]' bin/jumphp shot elfs-orcs-tower-defense 240 var/screenshots/play.png JUMPHP_SCENE='App\Scene\PlayScene' JUMPHP_SCENE_PARAMS='{"demo":true}' bin/jumphp shot elfs-orcs-tower-defense 900 var/screenshots/demo.png # other languages: tests/LanguagesTest.php photographs fr, de, ru, ja -> var/screenshots/tests/lang--*.png bin/jumphp build elfs-orcs-tower-defense && bin/jumphp webshot elfs-orcs-tower-defense 6 '[[1,"Enter"]]' ``` ## This game A retro tower defense in high fantasy (no licensed names: see BRIEF.md §2). **Milestones 1-3** are done: the **5 sides** (elves, dwarves, horsefolk, little folk: against the Horde; orcs: against the Alliance of the free peoples), 10 waves per attacking side, 4 towers per side with 3 levels and 2 specializations each, a **campaign of 6 maps** (each opens when the one before is won, per side; stars saved per side and map), **hard mode** (opens once a map is won: enemies' health x1.5, gold x0.85), a **hero** per side (sent anywhere on the road, fights, skill fires by itself) and a **side power** with a cooldown (cast where you point), 14 languages. **Milestone 4** is done except publishing (only when the user asks): the bot wins all 30 map x side combinations (tests/CampaignTest.php), hard mode stays winnable, `site/` (the game's home page) and `assets/thumbnail.png` / `assets/icon.png` exist, the remote build (`bin/jumphp build:remote`) was checked in Chrome. **The battle is drawn in 3D, painted like a storybook** (the user wants neither pixel art nor a crude low-poly look: soft painted ground, lush round trees, warm light, cool shadows): a WebGL layer under the 2D scene (`$engine->gl()`, layer 'below', 'native' resolution, key colour magenta). Pure classes in `src/View3d/` (excluded from the container, unit-tested by `tests/ViewTest.php`): `Mesh` (boxes, cylinders, balls with colour gradients and lumps, triangles, quads, and for sculpting `lathe()` (a smooth surface through rings: torsos, heads, helmets), `limb()` (along a profile), `plate()` (a flat shape with a thickness: blades, axe heads, feathers), `sheet()` (an open surface: capes) -> one buffer; 10 floats per vertex, the last one the material), `Units` (every creature, see "Creatures" below), `Skeleton` (bones turning about their joints -> u_bones matrices), `Towers` (20 towers by level / specialization, front towards +z), `Props` (shots, walls, traps, disc / ring / ball / soft shadow), `MapModel` (the land of a map: terrain with hills outside the board, the forest around it, trees, bushes, grass tufts, flowers, rocks, floating specks of light, platforms, the castle; one mesh per map and side), `GroundMap` (a 512 x 256 RGBA texture per map: road, water, sun / shadows, sky light), `Rig` (the camera: fits every platform under the top bar, `screen()` / `world()` project both ways), `FlatView` (the 2D fallback's projection). `src/Ui/World3d.php` holds the GL side: ONE shader for everything, reading the vertex material (`Mesh::SOLID`, leaves / grass in (0, 1] = how much they sway in the wind, `GROUND` painted from the GroundMap and `assets/textures/noise.png` (made by `tools/make-noise.php`: patches, grain, clouds), `MOTE` + seed: specks that drift, `SOFT`: shadows fading at their edge); warm wrapped sun from the west (`MapModel::SUN`), cool sky, drifting cloud shadows, haze far away, a light vignette; a model cache, one draw call per object. Static shadows (trees, rocks, castle) are baked into the GroundMap; towers and creatures get soft blob shadows (`World3d::shadow()`, drawn first). `PlayScene` reads and places its 2D overlays (ring menu, bars, numbers, HUD) through `$this->cam`; `render3d()` draws the world, `render()` keeps the flat 2D drawing when WebGL is missing or the param `view: flat` is given (the long logic tests use it: Mesa's software GL is slow). To look at the models: `tests/ModelShotsTest.php` builds every tower of each side at level 1, 3 and specialized (`var/screenshots/tests/towers-.png`; crop them with GD to look closer). The 2D is HD (game.yaml `hd: auto`: drawn at the window's resolution, coordinates unchanged) and the 3D layer is composed by the 2D's alpha (`setLayer('below', 'view', null)`): scenes clear the 2D with `$world->clearColour()` (transparent), then any colour, translucent or not, and smooth text lie over the 3D as they are. **The playground does not run this repository's engine** but its shared one (the official release of `VERSION`): the engine changes of this project (HD, alpha composing, TtfFont's style / thicken / outline) are not there until JumPHP releases them. `App\Ui\Hd` detects what the engine can do; without it the game keeps the retro way: the pixel font (`FontProvider::PIXEL_FONT`), the 3D through the key colour `World3d::KEY` (`Paint::$keyed`: only black may be translucent over it, `World3d::veil()`). `tests/LegacyEngineTest.php` plays that way (`Hd::$legacy`); check the real thing with `bin/jumphp build:remote ` then `JUMPHP_WEB=remote bin/jumphp webshot ` (build/runtime: the shared engine as released). Never call an engine method this project added without going through `Hd`. Things to remember: `crc32()` is signed and products overflow in the browser's 32-bit PHP (mask to 16 bits: `Mesh::hash()`); the 16-bit depth buffer needs a far near plane (`Rig::NEAR`); the browser builds the map mesh too (PHP has 128 MB there: keep a map under 100k vertices, ViewTest checks it); no uniform may be declared in both shaders (their precisions differ in WebGL: link error); offsets that grow with time (clouds, water) are wrapped in PHP (`fmod`) before they reach the shader; a gable roof's halves turn by `+side * pitch` about x (each falls away from the ridge); `bin/jumphp test --filter X` runs only phpunit (with OpenGL on Xvfb when the game uses it). Engine fix made for this game (engine/src/Gl/OpenGl.php `textureFromRgba`): desktop GL kept SDL's `GL_UNPACK_ROW_LENGTH`, so a texture made after SDL had updated one of its own was read with wrong rows (the second battle of a session had a scrambled ground); it is reset to 0 around the upload now. There is no title screen (the user found a PLAY / OPTIONS menu too retro): the game starts on the side screen, whose gear (top right corner of the header, or back / Escape) pushes `OptionsScene` (QUIT there on the desktop). The pause, victory and defeat menus go back to the side screen. Phones get no on-screen pad (`game.yaml touch: {pad: none}`, an option of the engine's `touch.js` added for this game: the playground's shared engine 0.4.0 ignores it and still shows its d-pad until JumPHP has it). In the browser a double click no longer sends the page fullscreen (`game.yaml double_click_fullscreen: false`, also an engine option added here: a tower's card then build, clicked fast, made Chrome go fullscreen; the playground's engine 0.4.0 still does it). The settings (`OptionsScene`, over the side screen or the pause menu) are a painted panel, not a menu list: a volume bar with minus / plus, two arrows for the language, a switch for the full screen (desktop), wooden BACK / QUIT buttons; keys: up / down, left / right, confirm, back (`tests/OptionsTest.php`). The interface's look (`App\Ui\Paint`, storybook like the 3D): rounded panels of ink blue edged with gold, wooden buttons and header bars, round medallions, an evening-sky backdrop, and icons drawn with shapes (heart, coin, flag, skull, stars, lock, pause / play, up, target, sell): sharp at any size in HD. The side and map screens show pictures of the 3D game (`assets/sprites/cards/side-.png`, `map-.png`, drawn smoothed by `Sprites::drawPicture()`), cut by `tools/site/make.php` from the test frames (ModelShotsTest writes where the towers stand: `towers-.json`). **Portraits** (the ring menu's towers, the hero's button, the next wave's preview, the creatures under the side cards without WebGL): pictures of the 3D models, `assets/sprites/portraits/unit-.png` / `tower-.png` (128 px, transparent), drawn smoothed by `Sprites::drawPortrait()` (false when missing: the old pixel drawing is used). They are made by `tests/PortraitShotsTest.php`: `PortraitScene` (a photo studio, not a screen of the game) draws one model alone through `World3d::studio()` (no land, plain background), over black then white; the difference gives the alpha. Re-run it after changing a model (`bin/jumphp test --filter PortraitShotsTest`): it rewrites the PNGs. The 2D fallback battle (no WebGL) still draws the pixel sprites of `Sprites`. **Creatures** (`App\View3d\Units`; the user found the old round dolls "gross" and gave a 3D strategy game of the 2000s as the bar: sturdy readable shapes, not a copy of its characters). Sculpted with `Mesh::lathe()` / `limb()` / `plate()`: each people its build (`RACES`: orcs hunched with big fists, trolls huge, goblins with a big head and long ears, dwarves stocky, elves slender, the little folk round), heads from rings (`HEADS`: jaw, brow, cranium) with eyes, brows, nose, mouth, ears, tusks, hair (long, crest, curls), beards, helmets / hoods / caps / hats / crowns, cuirasses and pauldrons that shine (metal), weapons in volume and oversized (`growAbout()`), shields, capes. Mounts (horse, wolf) with their rider as a second model (`parts()`), flyers with flapping wings, the ram with turning wheels. **Skeletons**: each vertex belongs to a bone (`Mesh::bone()`: the material is `Mesh::BONE` + bone, `METAL_BONE` + bone when it shines); a person has 16 bones (`HIPS` ... `FOOT_L`), a mount 12, a flyer 7, the ram 6. `Units::pose($sprite, $anim, $t, $part)` gives the bones' matrices (cached: 32 steps a cycle, 8 a second for idle / stun); the shader moves each vertex by `u_bones[bone]` (a `mat4[16]` uniform: works on the playground's released engine too) and adds a sun glint on metal. Animations: `walk` (t: cycles; the scenes give distance / `stride()` so the feet keep in step with the ground), `fight` (the blow lands in the first quarter, with `PlayScene::lunge()`; archers draw their bow), `idle` (breathing, looking about), `stun`, `fly`; riders sit astride (`personPose($seated)`), feet stay flat (`FOOT_*` bones). `World3d::creature()` draws one (and its rider); `PlayScene::gait()` picks the animation. `tests/ViewTest.php` checks every creature builds and poses and keeps its feet on the ground; `tests/CreatureShotsTest.php` photographs them (`var/screenshots/tests/creatures-idle|walk|fight.png`, `creatures-battle-*.png`): look at them after a change, then re-run `PortraitShotsTest` (the interface's portraits). A human is 7-11k vertices, 30-50 ms to build (lazily, once). **Particles** (3D only): `App\View3d\Particles` (pure, seeded, at most `MAX` = 240) holds sparks and dust where shots land, puffs and coins of kills, dust and golden motes of builds / upgrades, dust of sales, green motes of heals, the power's shower, red flares of leaks, embers over burning enemies and trails behind glowing shots. `PlayScene` emits them from the battle's events (`particlesOf()`) and each frame (`ambientParticles()`), steps them at the battle's speed and draws each as a `World3d::ball()` (fading in / out: `Particles::look()`). **Blows**: fighting soldiers, the hero and held enemies step towards their foe and back about twice a second (`PlayScene::lunge()`). Tests: `tests/ParticlesTest.php`. **Vignettes** (the side cards, 3D only): each card shows its side's floating island (`App\View3d\Diorama`: grass, an underside of earth and rock narrowing to a point, roots; the four towers at level 3; the foes walking round them) in a 3D layer OVER the 2D (`World3d::bootOver()`: `setLayer('above', 'native')`, in the released engine too). `World3d::vignette()` squeezes a small camera's projection into the card's frame and `u_clip` drops what falls outside (the division by w is done in the fragment shader: exact where a triangle crosses the frame's edge). Each vignette draws its own opaque sky first (`Mesh::SKY`: a gradient, clouds from the noise texture): no 2D shows through, and no dark fringe round the silhouettes (the supersampled layer would average them with transparent texels). The soft shadows' blending still leaves the layer's alpha at a² + (1 - a) there: the 2D under the frame is dark and `Diorama::SHADOW` is lighter for it. The layer is over the 2D, so the scene fade does not darken it: `FactionSelectScene` reads the SceneManager's fade (private: reflection) and gives it as `u_fade`; under another scene (the pause menu pushed when the window loses the focus) it draws no vignettes. The frame's height is the room the texts leave on the most crowded card (`pictureHeight()`: longer languages get a lower frame), and `Diorama::camera()` fits the frame (the tallest tower, `Diorama::TALLEST`, at the back, and the island's tip; `tests/VignettesTest.php` checks both). In 3D the foes walk in the vignette: no portraits under the cards' texts. Without WebGL (or `view: flat`): the cards' pictures and portraits. Site pictures: `tests/SiteShotsTest.php` photographs the game (var/screenshots/tests/site-*.png), then `bin/jumphp php games/elfs-orcs-tower-defense tools/site/make.php` cuts them into site/images/, the thumbnail and the icon. Re-run both after any change of look, then `bin/jumphp build:site ` and `bin/jumphp siteshot `. Maps 2-6 take thornvale's waves (`like:` in waves.yaml) scaled and with groups added; their scenery is scattered from `scenery:` shares with a colour `theme`. Hero / power controls: round buttons bottom left of the map (tap, then tap the road / the target), H / pad LB = hero, Q / pad RB = power, arrows move the reticle, confirm sends, back cancels. Design choices made without the user's answer (asked, no reply; easy to change): soldiers of barracks / stables stand on the road point nearest to their slot and stop one ground enemy each; palisades and traps go on that road point too; hidden scouts can only be shot within a detecting tower's range (the orcs' war drums), soldiers still stop them; all 5 sides are open from the start. Tower kinds (towers.yaml `kind`): attack, barracks (`soldiers`), barrier (`barrier`), trap, aura (`aura`), income (`income`); flags `pulse`, `ambush`, `spread`, `detect`. Enemy traits (enemies.yaml): `attack`, `dodge`, `stealth`, `tunnel`, `heal`, `scale`. `tools/balance.php` (`bin/jumphp php games/elfs-orcs-tower-defense tools/balance.php`) makes the bot play every side and prints lives / gold per wave and the enemies that got through: balance with it. - **Layout** (480 x 270): top bar 30 px (lives, gold, wave, next wave preview, CALL WAVE, speed, pause), the map 480 x 240 under it (30 x 15 tiles of 16 px, `PlayScene::MAP_Y`). - **Scenes**: `FactionSelectScene` (the first screen, 5 cards; tap / arrows pick, a second tap / confirm plays; gear: settings) → `MapSelectScene` (6 maps, locks, stars, hard mode switch) → `PlayScene` (map, faction, hard) ; overlays `PauseScene` (also `game.yaml pause_scene`), `VictoryScene` (stars), `DefeatScene`, `OptionsScene` (painted panel: sound, language, fullscreen). The other menus extend `App\Ui\GameMenuScene`: the engine's MenuScene for the keys, drawn as a painted panel (gold title, texts, `extraHeight()` / `renderExtra()` for a picture such as the victory's stars, one wooden button per item; mouse / touch press the buttons). No list menus anywhere (the user wants nothing retro). - **Logic** (`src/Td/`, pure PHP, no SDL, excluded from the container, unit-tested): `Battle` (economy, lives, waves, towers, enemies, projectiles, soldiers, walls, traps, auras, income; `step()` at 60 Hz; what happened goes to `$events`), `Enemy` (path distance, slow / stun / burn / fear / shred, blocked, hidden, underground), `Soldier`, `Tower` (levels, specialization, targeting first / last / strongest / closest, its road object), `Projectile`, `Path` (waypoints), `GameData` (the YAML), `Bot` (builds and upgrades by itself). - **Data** (`assets/data/*.yaml`): towers, enemies, waves (per map and attacking side), maps (scenery rows, paths in tile corners, slots, bastion), factions (towers, colours, bot list), damage table. Balancing = these files. - **Pictures** (`src/Ui/Sprites.php`, `src/Ui/TowerArt.php` for the 16 towers of the other sides): towers drawn with GD per level / specialization, enemies as pixel strings (2 frames, templates recoloured per people), the map painted once into a 480 x 240 texture per side (the castle in its colours). A PNG in `assets/sprites/` with the documented name replaces any generated picture. **Sounds**: `src/Ui/Sfx.php` (Synth; `assets/sfx/.wav` replaces one). - **Text**: every string goes through `Lang::t()`; `config/lang.yaml` has the 13 other languages; `tests/TranslationsTest.php` fails on a missing or unused key. `App\Ui\Text` wraps / fits text to a width. - **Fonts** (`App\Ui\FontProvider`): `assets/fonts/Nunito.ttf` (Google Fonts' variable file, OFL: `Nunito-OFL.txt`; 10 px, Latin + Cyrillic; its default instance is the thinnest weight, so it is made bold and thickened, its shadow drawn as an outline) when the engine has HD, else `PressStart2P-Regular.ttf` (8 px, pixel); `assets/fonts/unifont.otf` (16 px, CJK, OFL / GPL with font exception). **Without these files the built-in bitmap font is used** (upper-case ASCII: accents dropped, CJK not shown). `MixedFont` draws CJK runs with the fallback (its line metrics are the main font's). - **Controls** (`config/input.yaml`): mouse / touch: click a stone platform → a ring of 4 choices (first click shows it, second does it); keys / pad: arrows jump between platforms, confirm opens the ring, arrows pick a choice, confirm again does it, back closes; N / pad Y calls the next wave (gold bonus), F / Tab / pad X speed x1-x3, Esc / P / Start pause. - **Saves**: `App\Save\Progress` → `var/progress.json` (best stars per side and map). - **Smoke test**: `PlayScene` with `demo: true` (the bot plays at x3 and calls the waves). - **Test params of PlayScene**: `demo`, `speed`, `gold`, `lives`, `countdown` (seconds before the first wave), `wave` (waves already done), `seed`. - **Non-obvious**: the scene steps the battle a fixed 1/60 s, `speed` steps per frame (never a variable step). Random rolls come from `Battle::roll()` (seeded, 31-bit safe for the browser).