And a Contract (truncated here):
# GAME CONTRACT — "Medieval Survivors", a 3D medieval survival RTS
Stack: TypeScript strict Three.js 0.170 Vite. Zero external assets: every mesh is procedural low-poly built from primitives in src/meshes/common.ts. Single-player, runs in browser.
Pitch: build a village economy (wood/stone/gold/iron wheat->flour->bread food chain, hunting, berries), raise an army, survive 10 escalating night raids. Lose if the Town Hall falls.
## Conventions
- Y up. World is WORLD.size x WORLD.size units centered at origin (x,z in [-128, 128]). Every entity sits at y = ctx.terrain.getHeight(x, z).
- Grid: WORLD.gridCells=128 cells/side, cellSize=2. Cell (cx,cz) spans world x in [cx*2-128, cx*2-126]. Cell center x = cx*2 - 128 1.
- Buildings occupy size x size cells, (cx,cz) = MIN corner. Building center world x = (cx size/2)*2 - 128, same formula for z. Footprint world width = size*2.
- Humans are ~1.8 units tall. Units face movement direction: group.rotation.y = Math.atan2(dx, dz) (rotation 0 faces Z).
- Interaction ranges (dist measured in XZ plane): gather node when dist <= node.radius 1.5; build/repair when dist <= building.size 1.8 (size in cells = half footprint in world units); melee hit when dist <= attackRange target.radius.
- update(ctx, dt): dt is game-seconds, pre-scaled by speed, 0 while paused. Called every frame.
- Cross-module references use the interfaces in src/types.ts. Concrete class imports allowed ONLY where this contract names them.
- Resources are floats internally; UI displays Math.floor.
## File-by-file contract
### src/core/eventBus.ts
export class EventBus implements IEventBus — Map of event name to Set of callbacks.
### src/core/input.ts
export class InputManager implements IInput. constructor(dom: HTMLElement). Tracks held keys lowercase. Left mouse: < 6px movement = click (onClick); >= 6px = drag-select — while dragging, position/size the
#select-box div (display block, left/top/width/height px), on mouseup hide it and fire onDragSelect with NDC min/max corners. Right click fires onRightClick and suppresses contextmenu. pointer NDC kept current on mousemove. onKeyDown fires with e.key.toLowerCase() (e.g. 'escape', ' ', 'f1').
### src/core/cameraController.ts
export class CameraController implements ICameraController. constructor(camera: THREE.PerspectiveCamera, dom: HTMLElement, keys: Set<string>). Classic RTS rig orbiting a ground target point: yaw rotates with q/e, zoom distance 25..110 via wheel, pitch ~0.9 rad. Pan with wasd/arrow keys relative to current yaw, plus edge-of-screen pan (within 14px of window edge) — pan speed proportional to zoom. panTo(x,z) recenters. Clamp target to /-(WORLD.size/2 - 10). update(dt) receives REAL unscaled dt (camera works while paused). Camera position = target spherical offset; camera.lookAt(target).
### src/core/sound.ts
export class SoundManager implements ISound. Pure WebAudio synthesis (oscillators, noise buffers, gain envelopes) — distinct character per SoundName: chop=woody knock, mine=stone clink, build=hammer tap, place=thud, train=short fanfare blip, attack=swish, arrow=whoosh, death=low groan, horn=long low brass swell (wave warning), coin=bright ding, error=dull buzz, click=tick, victory=rising fanfare, defeat=falling minor phrase, upgrade=anvil ring, harvest=rustle. AudioContext created/resumed lazily on first pointerdown/keydown (addEventListener on window). 80ms per-name throttle. Master gain 0.18. toggleMute() returns new muted state.
### src/world/noise.ts
export function makeNoise2D(seed: number): (x: number, y: number) => number — seeded value noise, smooth interpolation, output [-1,1].
export function fbm(n: (x: number, y: number) => number, x: number, y: number, octaves?: number, lacunarity?: number, gain?: number): number — defaults 4/2/0.5, output approx [-1,1].
### src/world/terrain.ts
export class Terrain implements ITerrain. constructor(seed: number). A continuous private heightAt(x,z) built from fbm (scale so hills roll gently, height 0..WORLD.maxTerrainHeight); flatten toward height 3.2 within WORLD.startClearRadius*1.4 of origin (fully flat inside startClearRadius); carve one lake in a quadrant far from center by smoothly depressing heights below WORLD.waterLevel. mesh = PlaneGeometry(WORLD.size, WORLD.size, 160, 160) rotated flat, vertex y from heightAt, VERTEX COLORS: sandy near water level, grass greens with noise-driven dirt patches, gray rock on steep slopes, lighter dry grass on high ground. material MeshLambertMaterial vertexColors. receiveShadow true, castShadow false. getHeight(x,z) = heightAt (same function = mesh matches exactly). isWater(x,z) = heightAt < WORLD.waterLevel.
### src/world/water.ts
export class Water. constructor(). mesh: PlaneGeometry(WORLD.size, WORLD.size, 32, 32) at y = WORLD.waterLevel - 0.05, MeshLambertMaterial color PALETTE.water transparent opacity 0.78. update(dt): gently undulate a few vertices / bob y by -0.04 for a living-water feel.
### src/world/grid.ts
export class Grid implements IGrid. constructor(terrain: ITerrain). Precompute per-cell terrainOk: cell center not water AND |slope| ok (max height difference between center and samples -cellSize < 2.2). occupancy = Int32Array(cells*cells) of entity id or 0. isWalkable = inBounds && terrainOk && occupancy 0. canPlace(cx,cz,size) = every footprint cell inBounds && terrainOk && unoccupied. occupy/free write the footprint. blockerAt returns occupancy value.
### src/world/dayNight.ts
export class DayNightCycle implements IDayNight. constructor(scene: THREE.Scene). Owns: HemisphereLight ambient, sun DirectionalLight (castShadow, 2048 map, ortho frustum ~ -110, bias tuned ~-0.0005), dim bluish moon DirectionalLight (no shadow), scene.background THREE.Color and scene.fog (new THREE.Fog) lerped through dawn/noon/dusk/night palettes, a Points starfield only visible at night (transparent material, opacity fades), small glowing sun & moon sphere meshes orbiting the sky far out. day starts at 1, time starts 0.25 (morning). update(ctx, dt): time = dt/TIME.dayLength; on wrap past 1: time -= 1, day , emit bus dayChanged; when crossing (1 - TIME.nightFraction) emit nightFell. isNight = time > 1 - TIME.nightFraction. lightLevel: smooth 1.0 at midday -> 0.12 deep night. Sun shadow camera recentered on
ctx.cameraCtrl.target each frame (snap to whole units to avoid shimmer).
### src/world/worldGen.ts
export function generateWorld(ctx: IGameCtx, seed: number): void. Uses rng(seed) from common.ts. MAY import concrete classes ResourceNode (src/entities/resourceNode) and Animal (src/entities/animal). Spawns via ctx.addNode / ctx.addAnimal (game handles grid occupancy scene add). Placement guards: skip water cells, skip dist<WORLD.startClearRadius from origin (except berry/sheep noted), skip already-occupied cells. Content: WORLD.treeCount trees in organic clusters of 8-20 (pineRatio of them pine, pines favor higher ground); WORLD.rockCount rocks in small outcrop groups; goldMines ironDeposits scattered mid-distance (45-110 from center); berryClusters of bushesPerBerryCluster bushes at 30-55 from center; deerHerds of deerPerHerd boars roaming far; sheep near start (12-22 from center); WORLD.propCount decorative props (flowers/bushes/mushrooms/logs — NOT entities: build prop groups, set position terrain height, add directly to ctx.scene, no occupancy).
### src/meshes/props.ts
All return
THREE.Group, statically baked via bakeGroup for single draw call. exports: makeTree(variant: number), makePine(variant: number), makeRock(variant: number), makeGoldMine(), makeIronDeposit(), makeBerryBush(), makeCarcass(kind: AnimalKind), makeCropField(stage: number, size: number), makeProp(variant: number), makeStump(). Trees 4-6u tall, trunk 2-3 foliage blobs, per-variant randomness via rng(variant). Pine = stacked cones. Goldmine: rock mound gold nuggets dark entrance timber supports (radius ~2.2 visual). Iron deposit: gray rock metallic chunks. Berry bush: green blob berry dots. Carcass: lying animal form, bone accents. makeCropField: rows of wheat stalks on dirt — stage 0 bare tilled rows, 1 green sprouts, 2 waist-high green, 3 tall golden wheat; field spans size x size world units centered on origin of the group. makeProp variants: flower patch, shrub, mushrooms, fallen log.
### src/meshes/buildings.ts
export function makeBuildingMesh(kind: BuildingKind):
THREE.Group — DETAILED and distinctive, 15-40 primitives each, scaled to footprint (def size*2 world units): townhall = grand two-story timber-framed hall, stone base, banners, torch posts; house = cottage w/ thatched roof chimney; farm = low fence enclosing a dirt field tiny tool shed, and group.userData.cropAnchor = a THREE.Object3D placed at the field center (crop visuals get attached there by the Building entity; field clear span ~4.2u); mill = stone tower windmill with a 4-blade rotor — rotor NOT baked, group.userData.animate = (t: number) => rotor rotates; bakery = stone oven dome chimney awning; granary = raised storehouse on stilts sacks; lumbercamp = log piles saw bench; quarry = stone piles cart; barracks = squat stone fort banner weapon rack; archery = open range with target butts fence; stable = barn paddock fence; blacksmith = open forge: anvil, orange glowing forge (MeshLambertMaterial w/ emissive orange; userData.animate pulses emissiveIntensity); wall = 2u-wide stone wall segment ~2.6u tall w/ crenellation; tower = slim stone watchtower ~7u tall, crenellated platform; well = stone ring little roof bucket; market = colorful awning stalls crates barrels. Static parts composed then baked with bakeGroup; animated subparts (rotor, forge glow) added to the returned group AFTER baking so they stay live. Every group origin = footprint center at ground level.
export function makeConstructionSite(size: number):
THREE.Group — wooden scaffold poles planks roughly covering a size*2 footprint.
Animation convention: group.userData.animate?: (t: number) => void — game calls it each frame with ctx.time for every BUILT building.
### src/meshes/units.ts
export function makeHumanoid(kind: UnitKind | EnemyKind): HumanoidRig. Blocky humanoid ~1.8u: head, torso, 2 arms, 2 legs — arms/legs are pivot Groups anchored at shoulder/hip so rotation.x swings naturally. Distinct outfits: villager tunic straw hat; swordsman iron helm round shield on left arm; archer green hood back quiver; knight full plate plume, bulkier; raider dark furs warpaint; brute oversized shoulder spikes; shaman robe skull mask; warlord large horned helm red cape. kind 'wolf' returns a gray quadruped rig instead (same HumanoidRig interface; setTool/setCarry are no-ops for it). setWalkPhase(p): legs/arms swing ~sin(p); setIdle(): limbs ease to rest; setAttackPhase(q in 0..1): right (tool) arm winds up and swings through; setTool attaches the named tool mesh to the right hand (axe pick hammer sword bow scythe club staff; null removes); setCarry shows a sack/log bundle held in front (null removes).
export function makeAnimal(kind: AnimalKind): AnimalRig — deer slender antlers, boar stocky tusks, sheep wool blob; 4 leg pivots; setWalkPhase trots, setIdle rests.
### src/meshes/fx.ts
export function makeSelectionRing(radius: number, color?: number): THREE.Mesh — RingGeometry flat on ground (rotation.x = -PI/2), y 0.06, depthWrite false, default green 0x66ff66.
export function makeHealthBar(width?: number): IHealthBar — THREE.Sprite using a small canvas texture; set(frac) repaints green->yellow->red fill; setVisible toggles.
export function makeArrow():
THREE.Group — fletched arrow ~0.9u pointing Z.
export class Particles implements IParticles — pool of ~300 small colored boxes; burst(pos,color,count=10,speed=6) launches with random velocity gravity, shrink/fade, auto-recycle; update(dt). Constructor takes scene: THREE.Scene and adds itself.
export function makeRallyFlag():
THREE.Group — small pennant on a pole.
export function makeGhostMaterials(): { valid: THREE.Material; invalid: THREE.Material } — transparent green / red (opacity ~0.55, depthWrite false).
### src/entities/entity.ts
export abstract class Entity implements IEntity. Static id counter. constructor builds group; subclass attaches meshes. healthBar (makeHealthBar) floated above (per-entity height offset), visible only when hp < maxHp or selected. setSelected adds/removes selection ring (makeSelectionRing(radius 0.4)) and toggles bar. takeDamage: amount * ctx.effectiveArmorMult(faction) when faction player; clamps, sets dead at <= 0; spark fxBurst. get pos() returns group.position. Helpers: dist2D(p: THREE.Vector3), faceToward(p).
### src/entities/resourceNode.ts
export class ResourceNode extends Entity implements IResourceNode. constructor(ctx: IGameCtx, nodeKind: NodeKind, x: number, z: number). Mesh from props factories (variant = id). radius: tree/pine 0.8, rock 1.6, goldmine 2.2, irondeposit 1.8, berrybush 1.2, carcass 1.0. remaining = NODES[kind].amount (caller may override for carcass). harvest(amount: number): number — returns min(remaining, amount), decrements, dead when 0. update: carcass decays CARCASS_DECAY*dt. Not attackable (takeDamage no-op), selectable true (info). hp/maxHp mirror remaining/initial for the panel.
### src/entities/building.ts
export class Building extends Entity implements IBuilding. constructor(ctx: IGameCtx, kind: BuildingKind, cx: number, cz: number, opts?: { built?: boolean }). Position at footprint-center formula, y = terrain height (use height at center). Visual: built ? full mesh : constructionSite building mesh with scale.y = 0.08 0.92*buildProgress. Construction: each frame count player villagers in state 'building' whose buildTarget is this and within range -> progress = (count ? Math.sqrt(count) : 0) * dt / def.buildTime; at 1: built=true, remove scaffold, restore scale, hp=maxHp, emit buildingComplete bus message (name " completed"), sound build. Repair: same builders on a BUILT damaged building heal hp at maxHp / (buildTime*2) per builder-sqrt rate. Farm: cropAnchor children swapped to makeCropField(stage, 4.2) when stage changes; growth: if workerId set -> validate via ctx.unitById (alive, state farming/toFarm targeting this) else clear workerId; while a live farmer is adjacent in state farming: cropProgress = dt; cropStage = min(3, floor(cropProgress / (FARMING.growTime/3))). Reset progress/stage to 0 after a harvest is taken (farmer FSM calls a public takeHarvest(): boolean — true if stage 3, resets to 0). Training: if built && trainQueue.length: head.remaining -= dt, at 0 spawn new Unit at pathfinder.nearestFreeAround(rally ?? front-of-building), emit unitTrained, sound train (import Unit from ./unit). addToQueue: queue cap 5, requires state.pop UNITS[kind].pop <= popCap AND payCost, push {kind, remaining: trainTime, total: trainTime}. Tower: if def.attack && built: attackTimer -= dt, find ctx.findNearestEnemyOf('player', pos, range), spawnProjectile from pos (0, 6.2, 0), damage = ctx.effectiveDamage(def.attack.damage, 'player'), reset cooldown. Well: heal player units within radius by rate*dt (cap at maxHp). Townhall/walls: nothing special beyond above. Death: handled by game removal pass.
### src/entities/unit.ts
export class Unit extends Entity implements IUnit. constructor(ctx: IGameCtx, kind: UnitKind, x: number, z: number). def = UNITS[kind]; radius 0.55 (knight 0.8). rig = makeHumanoid(kind); setTool(def.tool). Movement: path: THREE.Vector3[] from ctx.pathfinder; advance at def.speed, pop waypoint < 0.35, y = terrain.getHeight, face heading, rig.setWalkPhase(accumulated distance * 2.2); path end -> arrival logic per state. Orders (each clears previous, computes path; unreachable -> stay idle): orderMove -> moving; orderGather(node) -> toNode (walk to pathfinder.nearestFreeAround(node.pos)); orderBuild(b) -> toBuild; orderAttack(t) -> toAttack; orderFarm(farm) -> toFarm (also farm.workerId =
this.id). FSM: toNode: in gather range -> gathering (face node, tool: wood axe, stone/gold/iron pick, food-from-bush/carcass axe; swing loop via setAttackPhase; sound chop/mine throttled): carry.amount = NODES[node.nodeKind].gatherRate * ctx.gatherRateMult() * dt via node.harvest; full at GATHER.carryCapacity ctx.carryBonus() OR node dead -> returning: walk to ctx.findDropOff(pos, type) (none -> idle); arrive -> ctx.addResource(type, amount, true), sound harvest, carry = null -> back toNode (node dead -> nearest same-nodeKind node within 24, else idle). toBuild/building: in range -> building w/ hammer swings (Building counts builders); building.built && hp full -> idle. toFarm/farming: stand near cropAnchor w/ scythe; farm.cropStage 3 -> 3s harvest timer -> farm.takeHarvest() -> carry {wheat, FARMING.wheatYield} -> returning to wheat dropOff -> toFarm again. Lost farm (destroyed/reassigned) -> idle. toAttack/attacking: chase target (repath if target moved > 3 from path end, throttle 0.8s), in range (def.range target.radius) -> swing on attackCooldown: melee target.takeDamage(ctx, ctx.effectiveDamage(def.damage, 'player'), this); archer: ctx.spawnProjectile(pos (0,1.4,0), target, dmg, this), sound arrow. Target dead -> villager: nearest carcass node within 8 -> orderGather, else idle; military: re-acquire ctx.findNearestEnemyOf('player', pos, aggroRange) else idle. idle: military scan every 0.5s aggro; villager scan every 1s for carcass within 8 (auto-harvest hunts). Villager hit while in a non-combat state -> flee: orderMove toward townhall. Carry visual rig.setCarry. setWalkPhase only while moving, setIdle when stationary.
### src/entities/enemy.ts
export class EnemyUnit extends Entity implements IEnemy. constructor(ctx: IGameCtx, kind: EnemyKind, x: number, z: number). faction enemy. rig makeHumanoid(kind); tools: raider club, brute club, shaman staff, warlord sword. Retarget think every 0.4s (stagger by id): target = nearest player unit OR building within aggroRange, else townhall, else nearest player building, else nearest player unit anywhere; path via pathfinder.nearestFreeAround(target.pos); findPath null -> pick nearest player building by straight-line (walls count) and path adjacent to it. Attack in range target.radius on cooldown: melee swing (setAttackPhase) or shaman: ctx.spawnProjectile (range 13). Wolf: targets nearest player unit OR animal within aggroRange (else wanders); when ctx.dayNight.isNight false -> dies quietly (no bounty: set a public diedQuietly flag the game checks). Movement identical to Unit (shared style, but implement locally). All damage to player passes through their takeDamage (armor applied there).
### src/entities/animal.ts
export class Animal extends Entity implements IAnimal. constructor(ctx: IGameCtx, kind: AnimalKind, x: number, z: number). faction wild. rig makeAnimal(kind). Wander: every 2-5s pick random walkable point within 12 and steer directly (no A*; if next cell unwalkable pick new target). Deer/sheep: on takeDamage flee 8u directly away from attacker, then graze 2s (deer is catchable because of the pauses). Boar: on damage, attack the attacker (melee ANIMALS.boar.damage, cooldown 1.3s) until it dies or is 25u away. foodYield -> game spawns carcass on death.
### src/systems/pathfinding.ts
export class Pathfinder implements IPathfinder. constructor(grid: IGrid, terrain: ITerrain). A* 8-directional (diagonal blocked if either orthogonal neighbor blocked), octile heuristic, iteration cap 20000. Start/goal snapped to nearest walkable cell (spiral search). Smooth result by line-of-sight waypoint skipping (grid walk between cell centers). Returns world-space Vector3 waypoints with terrain heights, or null. nearestFreeAround(target, radius=6 cells): spiral outward, first walkable cell center as Vector3 (null if none).
### src/systems/combat.ts
export class CombatSystem. constructor(scene: THREE.Scene). spawn(from: THREE.Vector3, target: IEntity, damage: number, attacker: IEntity, ctx: IGameCtx): void — arrow mesh (makeArrow), flight time dist/22, lerp from->target current pos with parabolic arc (peak ~dist*0.15), orient along velocity; on arrival: target alive -> target.takeDamage(ctx, damage, attacker); recycle. update(ctx, dt) advances all projectiles. (ctx.spawnProjectile delegates here.)
### src/systems/waves.ts
export class WaveSystem. constructor(). update(ctx, dt). Schedule from WAVES (then endlessWave(n) forever after). Nightfall moment = day fraction (1 - TIME.nightFraction). secondsUntil next wave = ((waveDay - day) * 1 (nightStart - time)) * TIME.dayLength (only when positive). Emit waveIncoming ONCE per wave at <= WAVE_WARNING_SECONDS remaining ( horn sound bus message warning). At nightfall of the wave day: spawn composition split across spec.sides distinct random map edges — spawn points = walkable cells within 3 cells of the border, clustered per side; create EnemyUnit per entry (import EnemyUnit from ../entities/enemy), track their ids; emit waveStarted message. While active: all dead -> ctx.state.wave = n, emit waveEnded success message. Also owns night wolves: on nightFell with day >= WOLVES.firstDay, chance WOLVES.nightlyChance -> pack of packMin..packMax wolves at one random edge (not tracked as wave). And wildlife respawn: on dayChanged spawn WILDLIFE_RESPAWN.deerPerDay deer at random edges (import Animal). getNextWaveInfo(ctx): { wave: number; secondsUntil: number } | null.
...