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.jsonFile 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):
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:
| Band | Systems |
|---|---|
| −100…−60 | sky, lighting |
| −50…−10 | terrain, city (static world) |
| 0…50 | battle sim, combat, morale, projectiles, AI |
| 100…150 | vfx, ragdoll |
| 200…300 | unit rendering, animation upload |
| 400…500 | audio |
| 600…800 | UI / 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:
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, metresTerrain must install ctx.rig.heightAt in init so the camera rides the ground.
SkySystem (name: 'sky')
readonly sunDirection: Vector3 // unit, ground → sun
readonly sunColour: Color // linear
readonly ambientColour: Color // linear
timeOfDay: number // 0..24
setTimeOfDay(hours: number): voidAlso expose, for anyone who needs IBL or fog matching:
environmentTexture: THREE.Texture | null // the PMREM-processed HDRICitySystem (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:
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.
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.
bayAtindexes bays arithmetically in x because it runs once per projectile per tick;scenario.tsdeploys at z −190 and z +130;Siege.tsreadsGarrisonBay.nx/nzas the outward normal.CitySystemasserts a uniform bay pitch at build time. - A tower on the wall-walk must publish the hole a man walks through it.
GarrisonBay.passOuter/passInnerare the clear lane through the tower at the bay's west end, as offsets along the outward normal, andpassLoY/passHiYare the two walk levels it joins.Siege.linkPathroutes aTowerPasscrossing 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) andtools/probe-towerpass.mjsmeasures the result off the built meshes rather than off either source. - Nothing at z <
battlefieldZ, checked per vertex per LOD byassertNoStrayGeometry. - 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-driftTerrainMaterialuses, 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:
- A slope the pathfinder refuses.
SLOPE_IMPASSABLE = 0.62measured 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. - 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()withtopYat 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 z — shoreZAt, 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.
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): voidSoldierPool 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:
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-16states 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 insrc/does a prepass — the onlycolorWrite: falsematerial in the tree isCitySystem's shadow proxy. The multiplier is therefore the cascades alone, which is also why it is not a constant: a caster is drawn1 + cascadestimes and a non-caster (terrain, grass) exactly once, so the ratio moves with how much of the frame casts. Measured atab-rome-wall, ultra, 1920x1080, t+171 s by masking each cascade'sshadow.autoUpdatein 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
buildShadowProxyinCitySystem.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.
| camera | ultra | high | medium | low |
|---|---|---|---|---|
| assault (siege) | 219 | 214 | 186 | 156 |
| city (siege) | 192 | 192 | 175 | 149 |
| wall (siege) | 191 | 191 | 172 | 144 |
| wall (field) | 163 | 163 | 149 | 126 |
| city (field) | 154 | 154 | 144 | 125 |
| terrain | 134 | 134 | 124 | 106 |
| clash | 120 | 125 | 115 | 91 |
| melee | 116 | 126 | 112 | 89 |
| wide / raking | 112 | 112 | 102 | 84-86 |
| romanline | 103 | 103 | 93 | 75 |
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.mjsmeans the scenario's own boot framing, captured by not callingsetCameraat all, and it defaults to--at=0.tools/shoot.mjsmeans the named entryab-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 wrappingWebGLShadowMap.renderandWebGLRenderer.render:
camera sim time men draws = shadow + colour + post Rome, boot framing t+0 3,074 191 76 + 92 + 23 Rome, boot framing t+170 s 2,395 200 80 + 97 + 23 Rome, ab-rome-wallt+170 s 2,395 215 95 + 97 + 23 Carthage, boot framing t+0 3,440 172 67 + 82 + 23 Carthage, boot framing t+170 s 2,988 182 71 + 88 + 23 Carthage, ab-carth-wallt+170 s 2,988 224 87 + 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-*-wallcameras run 15 to 42 draws heavier, and Carthage's is over the cap.851c479independently reportsab3-rome-wallat 223 andab3-rome-parapetat 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-wallby masking each cascade'sshadow.autoUpdatein 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) andWebGLShadowMap.js:515therefore draws it into all four. Zeroinggeometry.instanceCountfromonBeforeShadowfor the cascades a tier cannot reach — the instanced form of the trickbuildShadowProxyalready 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. Seetools/scratch/gpucost-cascskip.mjs.
The 242 below is stale, but the conclusion is not. At the boot framing Carthage now renders 187 (
851c479), anddocs/tech/RENDERING.mdrecords 186 = 88 + 75 + 23 at6698e19— so the 157-callfabricfamily 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-wallmeasures 224 at ultra against Rome's 215, and it is the only figure in this section thattools/perfdiff.mjs --budgetfails 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 2fabric 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.
| Resource | Budget |
|---|---|
| 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:
{
"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
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 directoryScreenshots 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
anywithout 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.logleft in hot paths. - Dispose GPU resources in
dispose(). - Match the surrounding style: named exports,
readonlywhere it holds, no default exports, no classes-as-namespaces.