TOTAL CLAUDE — Architecture & Contracts

Read this before touching code. It is the contract between subsystems so that independently-developed modules integrate without a rewrite.

Target: a Three.js battle simulator that stands comparison with Total War: Rome II. The Siege of Rome, 271 AD — a Juthungi/Alemannic host assaulting the city from the Campus Martius against a late-third-century Roman field army.


1. Project shape

src/
  core/      Engine, Time, Input, EventBus, RTSCamera, events   (owned by integrator)
  util/      math, rand                                          (owned by integrator)
  render/    SkySystem, LightingSystem, PostFX                    → RENDER agent
  terrain/   TerrainSystem, vegetation, ground materials          → TERRAIN agent
  city/      Rome: walls, landmarks, insulae, streets             → CITY agent
  units/     roster, soldier meshes, LOD, atlases                 → UNITS agent
  anim/      rig, clip authoring, VAT baking, GPU skinning        → UNITS agent
  sim/       types, formations, BattleSystem, scenario            (shared, see below)
             Combat, Morale, Projectiles, Ragdoll                 → COMBAT agent
  ai/        general AI, tactical AI, pathfinding                 → AI agent
  ui/        HUD, unit cards, minimap, banners, orders            → UI agent
  vfx/       particles, dust, blood, decals, weather              → VFX agent
  audio/     mixer, positional sound, music                       → AUDIO agent
tools/       shoot.mjs (screenshot harness), fetch-assets.mjs
public/assets/  hdri, textures, models + manifest.json

File ownership is exclusive. Do not edit files outside your assigned directory. If you need a change in src/core/, src/util/, src/sim/types.ts, src/sim/formations.ts, src/sim/BattleSystem.ts, src/sim/scenario.ts, src/main.ts or index.html, report it in your final message and the integrator applies it. Two agents editing one file loses work.


2. The subsystem interface

Everything plugs into the engine as a Subsystem (src/core/Engine.ts):

ts
interface Subsystem {
  readonly name: string;          // unique; how others resolve you
  readonly order?: number;        // lower updates earlier
  init?(ctx: EngineContext): void | Promise<void>;
  fixedUpdate?(dt: number, ctx: EngineContext): void;  // 30 Hz, deterministic
  update?(dt: number, ctx: EngineContext): void;       // per frame, visual only
  preRender?(ctx: EngineContext): void;                // camera is final here
  resize?(w: number, h: number, ctx: EngineContext): void;
  dispose?(): void;
}

EngineContext exposes scene, camera, rig, renderer, time, input, events, quality, viewW, viewH, get(name), tryGet(name).

Order budget — keep to these bands so update order stays predictable:

BandSystems
−100…−60sky, lighting
−50…−10terrain, city (static world)
0…50battle sim, combat, morale, projectiles, AI
100…150vfx, ragdoll
200…300unit rendering, animation upload
400…500audio
600…800UI / HUD
900+post-processing (owns the final present)

Determinism rule. Anything in fixedUpdate must be deterministic: no Math.random(), no Date.now(), no reads of frame time. Use Rng from src/util/rand.ts (rng.fork('my-system')). Visual-only jitter in update/preRender may use hash01(index, salt).

npm run lint checks the call-level half of this and runs ahead of npm run build. It is narrow on purpose and prints its own blind spots — iteration order, unstable sorts, floating-point association — every time it passes. tools/qa-determinism.mjs is what actually proves a replay is bit-identical.


3. Cross-subsystem contracts

TerrainSystem (name: 'terrain')

Other systems depend on these and they must not change signature:

ts
heightAt(x: number, z: number): number             // metres above datum, bilinear
normalAt(x: number, z: number, out: Vector3): Vector3
slopeAt(x: number, z: number): number              // 0 flat .. 1 vertical
get heightField(): { data: Float32Array; res: number; spacing: number; halfExtent: number }
export const HALF_EXTENT = 1400                    // battlefield half-size, metres

Terrain must install ctx.rig.heightAt in init so the camera rides the ground.

SkySystem (name: 'sky')

ts
readonly sunDirection: Vector3   // unit, ground → sun
readonly sunColour: Color        // linear
readonly ambientColour: Color    // linear
timeOfDay: number                // 0..24
setTimeOfDay(hours: number): void

Also expose, for anyone who needs IBL or fog matching:

ts
environmentTexture: THREE.Texture | null   // the PMREM-processed HDRI

CitySystem (name: 'city') — and there is exactly one of it

A map owns a city, or owns none. MapDefinition.city is a CityPlan | null, and main.ts registers CitySystem only when the map hands it one:

ts
const plan = getMap(config.map).city;
if (plan) engine.add(new CitySystem(plan));

This replaces the old hidesCity: boolean. That flag's failure mode was invisible and it was paid for once already: Rome's wall was built onto the plain of Pydna and merely made invisible, so it blocked movement across a map where it was nowhere on screen. A flag you must remember to set repeats that on the next map; the absence of data cannot.

There is one CitySystem and it builds whichever city its plan describes. Do not add a sibling subsystem for a second city. The accessors the siege system reads — getWallStairs(), getGateDoor(), getWallSegments(), getGarrisonBays(), getObstacles(), blocksMovement(), setGateOpen(), bayAt(), masonryTopAt() — contain no city-specific knowledge; they are derived from a WallBuildOutput and two lists of footprints. A second implementation would fork the occupancy raster, the obstacle boxes and the stair solids, all three of which have already shipped a bug and been fixed separately.

The contract is src/city/cityPlan.ts, and it is the only module a new city imports from. It re-exports the wall types as export type, so a city's own wall.ts gets the whole interface without pulling Rome's 135 KB of Aurelian geometry into the module graph.

ts
interface CityPlan {
  id, name, siegeGateId
  garrison: Faction       // whose city it is; the storming side is derived, not named
  battlefieldZ            // no city geometry below this z, at ANY detail level
  towerWidth, towerChamberHeight
  merlonLength, crenelLength   // must match the wall's own crenellation() exactly
  gateOpenWidth
  build(heightAt): CityBuild   // { wall, chunks, footprints, lanes, landmarks, checks }
}

scenario.ts reads name, siegeGateId and garrison through a narrow structural view, so src/sim/ still does not import src/city/. deployBattle (was deploySiegeOfRome) puts garrison on the parapet and whichever belligerent is not garrison in the field.

Three constraints on any city, all load-bearing in files the city workstream does not own:

  • The wall runs broadly along x and the city is at +Z. bayAt indexes bays arithmetically in x because it runs once per projectile per tick; scenario.ts deploys at z −190 and z +130; Siege.ts reads GarrisonBay.nx/nz as the outward normal. CitySystem asserts a uniform bay pitch at build time.
  • A tower on the wall-walk must publish the hole a man walks through it. GarrisonBay.passOuter/passInner are the clear lane through the tower at the bay's west end, as offsets along the outward normal, and passLoY/passHiY are the two walk levels it joins. Siege.linkPath routes a TowerPass crossing down the centre of that band; a city that publishes a zero-width band gets the old route along the cityward lip, which is legal and is what a construction step wants. Derive the lane once and cut the stone with the same call — Rome kept the two apart, the doorway was a pair of constants sized for a 3.5 m curtain, and after the widening to 6.0 m the path ran 1.36 m past the far jamb at every tower on the circuit. Both cities do this in one helper apiece (towerLane, punicTowerPass) and tools/probe-towerpass.mjs measures the result off the built meshes rather than off either source.
  • Nothing at z < battlefieldZ, checked per vertex per LOD by assertNoStrayGeometry.
  • 220 draw calls whole-frame — met at the assault camera, with 1 to spare. The city's own upper bound is printed at every boot as [city:<id>] N draws … by family. A second city has the same ceiling as the first, not a share of it, and any commit that adds city geometry must quote the ledger line. How the number is spent, and how little of it is the colour pass, is in §4 — read that before adding geometry, because a mesh that casts is charged five times, not once.

Directory layout: src/city/*.ts is shared machinery; src/city/<cityname>/ is one city's own geometry. Ownership is per-city-directory, so two cities can be built in parallel.

Water is a map's to declare, and the heightfield says where it is

MapDefinition.terrain.water is a WaterProfile | null and TerrainSystem builds WaterSurface only when a map hands it one. This replaced hasRiver: boolean, for the reason city: CityPlan | null replaced hidesCity: under the flag there was exactly one water surface in the engine — a ribbon of geometry built along the Tiber's own meander train — so Carthage, which is a peninsula, had to answer "no". Its gulf, its lagoon and its harbours shipped as terrain under the datum painted by the splat, and the owner's report on the finished map was "I see the ocean but no lagoon, it's just the beach." A flat desaturated plate with no specular, no animation and no depth cue reads as wet sand, and a 17:00 sun 20 degrees up is the case that reads worst.

Two properties are load-bearing and a new map should rely on both:

  • The wetted extent comes out of the heightfield, not out of an authored polygon. Water is wherever the bed is under waterLevel, tested per pixel against the same height texture and the same edge-drift TerrainMaterial uses, so a coast cannot disagree with its own bathymetry and a sand bar comes out as a bar. It also means a map cannot flood a salt flat: the Sebkhet Ariana is built at +0.54 to +0.64 m and stays dry by construction.
  • One draw call for all of a map's water. A 16 m grid with the dry cells left out, a coarse ring outside the battlefield so the sea runs to the horizon, and any authored basin welded into the same buffers with its surface height and depth per vertex. Measured at ten cameras on Carthage: +1 draw at every one, byrsa 281 → 282.

A basin whose bed is built geometry — a harbour cut into level ground — is the one thing the bathymetric test cannot see, because the heightfield there is at quay level. Those are declared as basins on the profile with their own y and depth; import the quay builder's own BASIN_WATER_Y/BASIN_DEPTH rather than copying the numbers.

A basin's surface is absolute and it is not a function of the bed under it. WaterBasin.y was dy, an offset from heightAt(centre), and that put Carthage's two basins at −1.46 and −0.04 while the gulf they both join through 21 m channels sat at 0 — because the ground sample at the cothon's centre is +0.34 and the merchant basin's is +1.76, and neither number has anything to do with the sea. Connected water is at one height by definition. The quay's freeboard is then an output of the ground, measured by the city's own build checks, not an input the water is derived from.

Nothing in the simulation knows what water is, and rendering a surface did not change that. A man walks into the sea unless something stops him, and only two things can:

  1. A slope the pathfinder refuses. SLOPE_IMPASSABLE = 0.62 measured over its 7 m cell, so a 9 m fall in 14 m. Carthage's open coast plunges 9.5 m in 12 for exactly this reason and for no bathymetric one.
  2. An obstacle box from the city plan. This is the only option for a harbour basin, whose quays are level with the town and whose water is two metres down: there is no scarp to build. Whoever builds the quays must publish the basins through getObstacles() with topY at quay level, or units will march across the naval harbour.

Both are still the only two. A rendered surface is not a collider and must never be mistaken for one.

But the pathfinder had a third opinion and it was the Tiber's. Pathfinding.ts carried WATER_LEVEL = 1.5 and MARSH_LEVEL = 3.0 as module constants described as heights above datum. They are not: the Tiber's surface is 5.0, so they are depths — 3.5 m of water drowns a man, 2.0–3.5 m is waded at 2.6× cost — and on the Campus Martius every one of the 8,205 cells the "marsh" band charges is river bed. Written as absolute heights they followed the pathfinder onto every map and called 122,847 cells of dry Carthage water: 110.6 ha, 14.1 % of the battlefield, the isthmus approach at the lagoon margin, the Sebkhet Ariana and the strand. Read as depths below terrain.waterLevel they generalise exactly and the Campus Martius does not move a cell.

isWater reads the same datum, and a map that declares no water answers false everywhere — the absence of a WaterProfile is the absence of water, the same rule city: CityPlan | null follows. Pydna's floor is +8.07 m so nothing there is affected today; the rule is there so the next map to cut a dry gully below its own sea datum does not find a river in it.

Rendering the water found two map bugs that painting them had hidden, and both are fixed. Two connected water bodies exist on Carthage — the gulf at 60.0 ha and the lake channel behind the Taenia at 3.08 ha, x −1094..−954, z 482..842, mean depth 5.63 m. 22 building footprints stood under the datum inside the second, 6,689 m² of the fabric's 357,376, plus the wall's south-anchor tower on ground at −0.75 m. The cause was that the fabric had a coastline test in zshoreZAt, the gulf — and none in x, which is where the lake is. It tests the bed now, for the same reason the wetted extent comes out of the heightfield: a city and a coast planned against two different curves is one bug seen from two sides.

What is left there and is the map workstream's, not the city's: the heightfield does not excavate the harbours. Measured against the built basins, 51 % of the cothon's water area and 84 % of the merchant basin's stand under terrain that is above their surface, so those parts render as dry ground with a basin buried beneath them. The cothon's quay also clears its own water by only 0.34 m against §6.2's 1.8, because the ground at its centre is 0.34 m where §3.3 puts the harbour district at 2–6. Building the quay up to the design figure is not the fix: men stand at terrain height, so a quay raised 1.5 m is a quay they walk under.

BattleSystem (name: 'battle')

The single source of truth for army state. Read freely; write only via its methods.

ts
pool: SoldierPool          // structure-of-arrays, see src/sim/types.ts
units: UnitGroupState[]
hash: SpatialHash          // rebuilt at the top of every fixedUpdate
rng: Rng
strength: Record<Faction, number>

spawnUnit(typeId, x, z, facing, formationId?): number
unitById(id): UnitGroupState | undefined
typeOf(u): UnitTypeDef
groundAt(x, z): number
damage(i, amount, fromX, fromZ, attackerUnitId): boolean   // the ONLY way a man dies
rout(u): void
activeUnits(faction?): UnitGroupState[]
renderPos(i, alpha, out): void       // interpolated; renderers must use this
renderFacing(i, alpha): number
setFormation(u, formationId): void

SoldierPool is parallel typed arrays indexed by soldier. Key fields: x/y/z, px/py/pz (previous tick), vx/vy/vz, facing, prevFacing, lean, unitId, faction, slot, rank, file, hp, maxHp, state, stateTime, target, attackCooldown, fatigue, ammo, animClip, animTime, animPrevClip, animPrevTime, animBlend, animRate, scale, variant, grime, deathDirX/Z, deathVariant. count is the high-water mark — iterate 0..count.

variant[i] is a stable 0..1 hash per man. Use it for every appearance choice (skin tone, beard, shield emblem, kit variant, cloak yes/no) so a man's look never changes frame to frame.

Animation clip table

enum Clip in src/sim/types.ts defines 24 clips (Clip.Count). BattleSystem already selects animClip/animTime per soldier every tick. The animation system must provide, for exactly those enum values:

ts
clipInfo(clip: Clip): ClipInfo   // { duration, loop, hitFrame?, rootSpeed? }

animTime is normalised 0..1 within the clip, not seconds.

Events

All cross-system signals are declared in src/core/events.ts (GameEvents). Add new ones there — report the addition, don't edit the file yourself. Notable ones you should emit or consume: meleeHit, volleyFired, projectileImpact, linesClashed, cavalryCharge, soldierDied, unitRouted, unitDestroyed, unitMoraleChanged, orderIssued, selectionChanged, cameraShake, playSound, musicCue, battleEnded.


4. Performance budget

Non-negotiable: 60 fps at 1920×1080 on an Apple M4 Max with 6,000+ animated men on screen. Measured by tools/shoot.mjs, which prints fps, draw calls and triangles.

Frame time is the binding constraint; the rest are proxies for it. The triangle figure was originally 14 M, chosen as a guess. Measured across all 15 shots at 1080p with 8,964 men, the two heaviest — the establishing shot at 14.75 M and the cavalry wing at 15.65 M — run at 174 and 70 fps respectively. So 14 M was mis-calibrated rather than the geometry being wrong, and it is now 16 M. If a change pushes triangles up while frame time stays inside 16.7 ms, that is fine. If frame time regresses, no triangle count excuses it.

renderer.info.render.triangles counts every pass, not unique geometry. Measured at the cavalry camera, unique visible geometry was 10.6 M while the counter reported 35.5 M — roughly 3.3x, because every shadow cascade re-draws every caster. So the budget above is a budget on the reported figure, which is the honest way to read it: it is a proxy for total vertex work, not a model complexity count.

There is no depth prepass, and this paragraph used to say there was. PostFX.ts:13-16 states the decision and the reason: a second geometry pass over the animated men costs more than reconstructing view normals from the scene target's own depth attachment, which is exact except across silhouettes. Nothing in src/ does a prepass — the only colorWrite: false material in the tree is CitySystem's shadow proxy. The multiplier is therefore the cascades alone, which is also why it is not a constant: a caster is drawn 1 + cascades times and a non-caster (terrain, grass) exactly once, so the ratio moves with how much of the frame casts. Measured at ab-rome-wall, ultra, 1920x1080, t+171 s by masking each cascade's shadow.autoUpdate in turn: the four cascades carry 4.54 M of the frame's 9.21 M reported triangles, and the colour pass alone is 4.67 M.

The rout shot exceeds the triangle proxy on purpose, and that is not a bug to fix. It reports 18.3 M against the 16 M line while carrying only 6.97 M of unique geometry (soldiers 2.47 M, city 2.31 M, grass 1.30 M, terrain 0.41 M) and running at 13.65 ms — 73 fps, inside the binding constraint with room to spare. The multiplier is not a constant: it ranges from ~2.3x to ~3.3x with how much of the scene the cascades happen to span, so a fixed absolute line on the reported figure cannot mean the same thing in two frames. tools/perfdiff.mjs therefore treats an absolute triangle overage as a reported warning rather than a failure, while still failing a triangle regression against a baseline — a 25% jump means LOD selection, a culling test or a cascade bound broke, even when frame time absorbs it. Proxies are trustworthy as derivatives, not as absolutes.

This shot exists because the other fifteen could not see the frame: wide is wide but fires at t+2 before anyone has routed, and aftermath is late but sits at zoom 0.34 on the corpse pile. Neither puts thousands of scattered men on screen at once. The frame also gets heavier as men die — 7,879 men at t+130 render 15.03 M, 7,010 at t+171 render 18.30 M — because a rout spreads a unit over ~120 m and pushes men who were a tight LOD2 clump across the LOD1 boundary. Headcount is the wrong thing to reason from.

Where the draw calls actually go, and the only lever that matters

Measured at a974a28 with tools/probe-budget.mjs, which instruments WebGLShadowMap.render and WebGLRenderer.render separately. Engine sets info.autoReset = false and resets once per frame, so the counter accumulates over the whole frame and differencing the two entry points splits it exactly.

A frame is a small colour pass, a large shadow pass and a fixed post chain. At the Rome assault camera, 1920x1080, ultra, t+72 s: 98 colour + 98 shadow + 23 post = 219. The colour pass is 96-101 at every tier; the entire tier scaling is the shadow pass, because the cascade count is the only thing a tier changes about it.

The shadow pass is very nearly cascade-invariant. Cascade 0 covers 39 x 39 m and cascade 3 covers 745 x 745 m, and they draw the same objects — every caster in this scene is a merged mesh whose bounding sphere straddles all four. So the practical rule is:

A shadow-casting mesh costs one draw call in the colour pass and one more in every cascade. On ultra that is five. Splitting a chunk into one mesh per material saves nothing in the shadow pass, because every one of them resolves to the same opaque depth material — see buildShadowProxy in CitySystem.ts, which merges them back into one.

Draw calls per camera per tier, both scenarios, at t+72 s. Nothing here needed the machine to be quiet: the count is deterministic and load-independent, which is why it and not frame time is the right thing to gate on.

cameraultrahighmediumlow
assault (siege)219214186156
city (siege)192192175149
wall (siege)191191172144
wall (field)163163149126
city (field)154154144125
terrain134134124106
clash12012511591
melee11612611289
wide / raking11211210284-86
romanline1031039375

The assault camera is the binding one and it is at the line, not under it: panning during an interactive session touches 226. The next lever, if more headroom is wanted, is the cascade count — one cascade off ultra is worth about 39 draws — and it is a quality decision, not a bug fix.

"The assault camera" names two different framings, and that is most of why these figures never reconcile. tools/probe-budget.mjs means the scenario's own boot framing, captured by not calling setCamera at all, and it defaults to --at=0. tools/shoot.mjs means the named entry ab-rome-wall, resolved against the live curtain at t+170 s. They are different places at different moments and they do not produce the same number. Any figure in this section is unreadable without both, so quote the camera and the sim time or do not quote it.

Measured at d128adf, 1920x1080, ultra, dpr 1, both framings in one boot per map, draw split taken from the real frame by wrapping WebGLShadowMap.render and WebGLRenderer.render:

camerasim timemendraws= shadow + colour + post
Rome, boot framingt+03,07419176 + 92 + 23
Rome, boot framingt+170 s2,39520080 + 97 + 23
Rome, ab-rome-wallt+170 s2,39521595 + 97 + 23
Carthage, boot framingt+03,44017267 + 82 + 23
Carthage, boot framingt+170 s2,98818271 + 88 + 23
Carthage, ab-carth-wallt+170 s2,98822487 + 114 + 23

So the 204 / 186 that circulate as "the assault camera" are the boot framing figures and they still reproduce there (200 and 182 at t+170). The ab-*-wall cameras run 15 to 42 draws heavier, and Carthage's is over the cap. 851c479 independently reports ab3-rome-wall at 223 and ab3-rome-parapet at 224 and confirms both read the same before that pass, so the breach is where the ceiling stands rather than something a recent change did.

"One cascade off ultra is worth about 39 draws" does not reproduce. Measured at ab-rome-wall by masking each cascade's shadow.autoUpdate in turn, the shadow pass is 95 draws in four almost equal parts — 23 / 23 / 24 / 25 — so a cascade is worth about 24, and the whole shadow pass is 95 of 215 draws and 4.54 M of 9.25 M triangles. In time it is 2.68 ms p50 / 3.97 ms best-of-block out of a 17.66 ms frame, and it is not spread evenly: cascade 0 alone is 1.27-2.35 ms of it, because its 37 m footprint fills the 2048² map while cascade 3's 714 m footprint barely marks it.

Much of that is submitted to cascades it cannot possibly reach. A soldier LOD tier is a radial shell and a cascade is a view-depth slice — 1.5-26 / 26-63 / 63-152 / 152-460 m against tier bands 0-44.8 / 44.8-128 / 128-640 m — but every tier mesh sets frustumCulled = false (its instance buffer is filled per camera-frustum, so a bounding sphere would mean nothing) and WebGLShadowMap.js:515 therefore draws it into all four. Zeroing geometry.instanceCount from onBeforeShadow for the cascades a tier cannot reach — the instanced form of the trick buildShadowProxy already uses — measures 215 -> 207 draws and 9.25 M -> 7.89 M triangles at Rome, 225 -> 215 and 14.46 M -> 10.05 M at Carthage, with the frame byte-identical in both scenes. See tools/scratch/gpucost-cascskip.mjs.

The 242 below is stale, but the conclusion is not. At the boot framing Carthage now renders 187 (851c479), and docs/tech/RENDERING.md records 186 = 88 + 75 + 23 at 6698e19 — so the 157-call fabric family this paragraph goes on to describe is gone. Carthage is nonetheless still the over-budget map at the camera that is actually graded: ab-carth-wall measures 224 at ultra against Rome's 215, and it is the only figure in this section that tools/perfdiff.mjs --budget fails on.

Carthage is now the over-budget map, and for the opposite reason. With city: CARTHAGE_PLAN wired in, its assault camera renders 242 at ultra: 134 colour + 85 shadow + 23 post. The shadow pass is cheaper than Rome's (85 against 98) and the triple wall really does cost less than Rome's single curtain — 25 visible meshes against 31 — so the material-stream sharing works exactly as advertised. The colour pass is the problem:

fabric 157   streets 28   wall 25   monuments 17   byrsa 6   harbour 5   trees 2

fabric is about forty small chunks at 5/3/1 meshes. Their LOD ladder works — most sit at level 1 or 2 at any battle camera — but forty chunks at three meshes is 120 calls before anything else draws. Rome solved the same problem in insulae.ts by merging six districts into one chunk; Carthage has gone the other way. The lever is chunk count, not detail, and it belongs to whoever owns src/city/carthage/. Note the trade this makes explicit: small chunks give real LOD and real frustum culling, large chunks give few calls, and the surface correction in CitySystem.surfaceCorrection is what decides whether a large chunk can use its ladder at all.

ResourceBudget
Draw calls, whole frame≤ 220
Triangles, whole frame≤ 16 M as reported by renderer.info
Soldier draw calls≤ 12 (instanced, one per faction × LOD)
Unique materials≤ 40
Textures resident≤ 220 MB
fixedUpdate for 6k men≤ 4 ms
Shadow passes≤ 4 cascades

Techniques that are expected, not optional: GPU instancing for every repeated object, vertex-animation textures for soldier skinning (never SkinnedMesh per man), shared materials and texture atlases, 3 mesh LODs plus a billboard impostor tier, frustum and distance culling, and DynamicDrawUsage on instance buffers.


5. Assets

public/assets/manifest.json indexes everything downloaded, with author and licence:

jsonc
{
  "hdris":    [{ "id", "name", "path", "author", "license", "timeOfDay", "weather" }],
  "textures": [{ "id", "name", "author", "license",
                 "maps": { "albedo", "normal", "roughness", "ao", "displacement" },
                 "resolutionPx", "tiling" }],
  "models":   [{ "id", "name", "path", "author", "license", ... }]
}

Everything shipped is CC0 or CC-BY with attribution recorded in ASSETS.md. Never use assets ripped from Total War or any commercial game. Load via fetch('/assets/manifest.json'); treat a missing file as non-fatal and fall back to a procedural substitute — the game must still run with an empty asset folder.


6. Visual direction

Rome II's look, in the specifics that matter:

  • Palette. Sun-bleached, dusty, slightly desaturated. Warm sunlight against cool shadow. Roman red (#a8202a) and gold read as the only saturated notes on the field.
  • Lighting. Strong directional sun with long shadows, a visible warm/cool split between lit and shadowed surfaces, and aerial perspective that fades distant hills to sky colour. Never flat ambient.
  • Scale. A man is 1.75 m. Formations are dense — men nearly shoulder to shoulder, 0.86 m lateral spacing. Rome's walls are 6–8 m of masonry; buildings tower.
  • Ground. Never a single tiled texture. Blend dry grass, trampled dirt, gravel, mud and stone by slope, height and trampling, with a detail normal that survives a close camera.
  • Crowds. Variety is everything: height, kit, skin tone, shield emblem, cloak, beard, animation phase. Identical repeated men is the single biggest tell of a hobby project.
  • Atmosphere. Dust kicked up by movement, haze with distance, god rays, birds, smoke. A still frame of an empty field should still look like a place.
  • Post. Filmic tone map, bloom on highlights only, subtle vignette, fine grain, sharpening after AA. No crushed blacks, no orange-teal, no lens flare spam.

7. Verification — you are graded on rendered frames

bash
npx tsc --noEmit                              # must be clean
npm run lint                                  # determinism + tool-argument static checks
node tools/shoot.mjs --list                   # available shots, sets and families
node tools/shoot.mjs --shots=wide,romanline   # render specific frames
node tools/shoot.mjs --out=screenshots/mypass  # to a scratch directory

Screenshots land as PNGs plus a report.json with fps/draws/tris per shot. Read your own screenshots with the Read tool and iterate. Text that compiles is not the deliverable; a frame that looks like Rome II is.

The harness exposes window.__game = { engine, battle, ready, advance(s), setCamera(x,z,zoom,yaw), simTime() }. Add a shot to tools/shoot.mjs's SHOTS map only via the integrator.


8. Code standards

  • TypeScript strict. No any without a comment explaining why.
  • Comment the why, not the what. Explain a magic constant's origin; don't narrate i++. Historical or physical justifications are welcome and useful.
  • No dead code, no commented-out blocks, no console.log left in hot paths.
  • Dispose GPU resources in dispose().
  • Match the surrounding style: named exports, readonly where it holds, no default exports, no classes-as-namespaces.