* feat(ascii-city-engine): deep enrichment — street furniture, signage, dense Raleigh Enriches the merged v0.1 skill with a dense, real-data streetscape. Schema (backward-compatible): props gain optional label + provenance; new optional signs layer for street-name text; buildings gain name/address/use. world.schema.json admits signs; all v0.1 packs still validate. Validator: per-kind prop glyph map with unknown-kind flag; sign validation; signs included in content-bounds; v0.1 guards (isinstance crash-guard, O(n^2) DoS caps) intact. Engine: spatially-indexed prop billboards (signals, trees, crossings, transit stops, bollards, benches, hydrants) depth-tested at terrain(x,y); perspective-projected street-name sign text as an overlay pass; road surface-material and lit ground styling; crosswalk bands; wayfinding HUD naming the current street and the building faced. Reads spawn + first tile from manifest.json. Raleigh sample regenerated dense (same authoritative bbox): 159 buildings (64 named, addresses, uses), 899 surfaces (86 named, surface/lit/lanes), 298 props across 7 kinds, 29 real street-name signs. 520K, under 2 MB. Docs + evals: contract/engine-architecture/gis-ingestion/raleigh-poc updated for the new layers and acceptance checks; 2 new evals (no fabricated signage; props anchored to terrain). Verified: validator 1410 PASS / 0 FAIL; broken fixture + AttributeError repro exit 1; 5000-vertex DoS rejected in 31 ms; node --check OK; HTTP smoke 200 on engine/manifest/tile; validate-skills.rb 154 green; eval-coverage passes; blocklist clean; headless-Chrome render shows readable in-world 'North Wilmington Street' sign, signals, crosswalks, and on-street HUD. AI-assisted contribution (Hermes Agent, spec-driven-development pipeline). * fix(ascii-city-engine): address droid-review findings on PR #322 1. [P1] facingBuilding(): wrap the heading delta to [-pi,pi] before taking abs, so the Facing: HUD stops reporting a behind-the-camera building after the player turns past ~180 degrees. 2. [P2] buildIndices(): rasterize surface polyline edges into grid cells so long straight road segments register for surface styling and the On: HUD street name, instead of only indexing endpoint vertices. 3. [P1] validator: guard the new props kinds comprehension and the props/ signs loops in all_points() against null, so 'props: null' or 'signs: null' produce a structured FAIL instead of an uncaught TypeError traceback. 4. [P3][security] validator: enforce the previously-declared MAX_FEATURES_PER_TILE (buildings+surfaces+props+signs) to keep the O(n^2) geometry and duplicate-ID paths bounded in aggregate. 5. [P3] engine: paint marked crossings as a distinct ground band (=) instead of a floating billboard, matching the documented crosswalk rendering. 6. [P3] eval: align prop-null-terrain expected_output with the render-time skip behavior actually implemented. Verified: dense pack 1411/0 exit 0; broken/crash/dos/null packs all exit 1 with clean FAIL reports (no tracebacks); JS node --check OK; validate-skills 154 green; blocklist clean. * fix(ascii-city-engine): address droid-review round 2 on PR #322 1. [P1] Engine: skip crossing props in the billboard loop so crosswalks render only as the documented ground band (no more floating '=' above each of the 168 crossings — a regression from the prior fix). 2. [P3] Engine: nearestStreet() filters to kind==='road' so the HUD 'On:' line names the street, not a named plaza/sidewalk ('Market Plaza' etc). 3. [P2] Validator: require each sign's text to be a recorded road name (collected from surfaces), enforcing the documented never-invented signage contract. A fabricated 'Made Up Avenue' sign now FAILs. 4. [P2][security] Validator: short-circuit the tile loop when MAX_FEATURES_PER_TILE is exceeded, and replace O(n^2) duplicate-id .count() scans with single-pass Counters, bounding the quadratic paths. Verified: dense pack 1411/0 exit 0; fabricated-sign repro flags only the injected sign and exits 1; broken/crash/dos/null packs all exit 1 with no tracebacks; JS node --check OK; validate-skills 154 green; blocklist clean. * fix(ascii-city-engine): address droid-review round 3 on PR #322 1. [P2] Validator: emit the signs rule unconditionally so a null/non-list 'signs' value FAILs instead of passing silently (was gated on a truthy list check). 2. [P2] Validator: validate sign text against a pack-wide road-name set gathered across all tiles, so a sign in one tile may name a road whose surface lives in another (the documented multi-tile case). 3. [P2] Schema: require non-empty id/kind/text (minLength 1) on props and signs so the schema and validator agree on empty-string rejection. 4. [P3] Engine: drop dead signGrid/IX.key (the sign overlay iterates world.signs directly); cap edge-rasterization steps so a degenerate resolution (0) or pathologically long edge cannot spin unboundedly. 5. [P3] Validator: unknown prop kinds now pass with a reported fallback-'?' note instead of hard-failing, matching the documented fallback glyph and the engine's behavior. Verified: dense pack 1411/0 exit 0; signs:null FAILs; fabricated sign FAILs; multi-tile sign-to-road reference PASSes; broken/crash/dos/null all exit 1; JS node --check OK; validate-skills 154 green; blocklist clean. * fix(ascii-city-engine): address droid-review round 4 on PR #322 1. [P2] Engine: render props as once-per-frame perspective-projected one-cell billboards in an overlay pass (like signs) instead of during the ray march, eliminating the multi-row vertical streak a close prop produced. Verified in a live browser: signals/trees/crosswalks now render as discrete single cells. 2. [P3][security] Engine: bound aggregate rasterization in buildIndices() — cap surfaces (5000) and cells per surface (40000) so a crafted pack cannot freeze the tab on load (the validator's caps are not applied client-side). 3. [P2][security] Engine: guard sign text (missing/non-string text now skips the sign instead of throwing in the rAF loop and freezing the view). 4. [P3] Validator: still collect building/surface IDs for oversized tiles so pack-wide uniqueness detection runs even when the per-feature geometry checks are short-circuited (duplicates in an over-cap tile are no longer hidden). Verified: dense pack 1411/0 exit 0; live browser render shows discrete props (no streaks); broken/crash/dos/null/nullsign/fabric all exit 1, valid multi-tile pack exit 0; JS node --check OK; validate-skills 154 green; blocklist clean. * fix(ascii-city-engine): address droid-review round 5 on PR #322 1. [P2][security] Engine: guard terrain() against non-finite x/y and guard the prop/sign overlay passes against non-array, non-object entries, so a malformed pack (missing y, signs=42, null entries) degrades gracefully instead of throwing in the rAF loop and freezing the view. Verified in a live browser: a pack with signs=42 + a prop missing y renders with the frame loop alive and no console errors beyond the favicon 404. 2. [P3] Engine: per-surface 'seen' set now dedupes cells across edges (was per-edge), eliminating the repeated linear includes() scan that made the rasterizer quadratic in the worst case. 3. [P3] Engine: raise the per-edge step cap to 20000 since the per-surface cell cap bounds total work, so long edges are fully sampled at the 2-5 m resolutions raleigh-poc.md recommends (fixes road-styling drops). 4. [P3] Validator: lower MAX_FEATURES_PER_TILE to 50,000 (shipped pack is 1,385), bounding the quadratic pair tests more tightly. 5. [P3] raleigh-poc: correct walkthrough step 3 — East Hargett sign is ~141 m behind the spawn, not ahead; only North Wilmington is ahead. HUD count guards signs/props as arrays. Verified: dense pack 1411/0 exit 0; broken/crash/dos/null/nullsign/fabric all exit 1, valid multi-tile pack exit 0; malformed-pack live render survives; JS node --check OK; validate-skills 154 green; blocklist clean. * fix(ascii-city-engine): address droid-review round 6 on PR #322 1. [P1] Engine: props/signs overlay passes now use the corrected perpendicular distance (d*cos(ray_angle-heading)) for row projection, distance scaling, and the depth test — matching the ray march — so FOV-edge objects project to the right row and no longer falsely occlude or poison later depth tests. 2. [P2][security] Engine: terrain() guards malformed terrain metadata (missing terrain/resolution, non-positive resolution, missing origin, null elevations), so a crafted pack degrades to a clean error instead of freezing the tab. Verified live: a resolution-0/null-elevations pack shows 'Cannot load...' with no page errors. 3. [P3] raleigh-poc: walkthrough step 3 corrected — W/S only translate, so a 167-deg-off sign needs A/D rotation, not 'hold S'. 4. [P3][security] Validator: all_points() guards buildings/surfaces/props/signs against truthy non-iterables (e.g. props=42), matching the other null guards, so malformed packs report structured FAIL instead of an uncaught TypeError. Verified: dense pack 1411/0 exit 0; crash/dos/null/nullsign/fabric/props42/ broken all exit 1 (no tracebacks); valid multi-tile exit 0; malformed-terrain live render shows clean error, no freeze; JS node --check OK; validate-skills 154 green; blocklist clean. * fix(ascii-city-engine): address droid-review round 7 on PR #322 1. [P2] Engine: spatial-index buildings (footprint bbox -> grid cells) so the render loop and collision test find nearby buildings in O(nearby) instead of scanning the whole O(buildings) list per ray sample. Browser-measured frame cost dropped ~62ms (16 FPS) to 12.5ms mean (~80 FPS) on the dense pack. 2. [P2][security] Engine: terrain() guards null/ragged elevation rows, so a pack with a null row degrades to a clean error instead of freezing the tab (live-verified: null-row pack shows 'Cannot load...', no page errors). 3. [P2][security] Engine: cap sign text at 80 chars in the overlay pass, so a pathological pack-supplied sign cannot drive an unbounded per-frame loop. 4. [P3] Validator: reference FALLBACK_GLYPH constant (was dead) in the unknown-kinds report message. 5. [P3] raleigh-poc: fix stale expected validator tail (was 30/25; actual is 159/899, rules_passed=1411). Verified: dense pack 1411/0 exit 0; crash/dos/null/nullsign/fabric/props42/ broken all exit 1, valid multi-tile exit 0; null-row pack shows clean error, no freeze; ~80 FPS browser-measured on dense pack; JS node --check OK; validate-skills 154 green; blocklist clean. * fix(ascii-city-engine): address droid-review round 8 on PR #322 1. [P1][security] Engine: bound the building spatial-index rasterization with MAX_BUILDING_PTS (2000) and MAX_BUILDING_CELLS (40000) and require >=3 finite footprint points, so a ~100-byte crafted footprint cannot drive a ~1e10- iteration synchronous hang on load (the surface rasterizer's cap, applied to the building index I added in round 7). 2. [P2][security] Engine: facingBuilding() filters footprints to finite points before reducing, so a building with a null element in its footprint no longer throws in the rAF loop on frame 1. 3. [P2][security] Engine: collides() and pointNearPolyline() filter footprints/ polylines to valid array points before edge tests, so null footprint points no longer throw once the player enters those cells. 4. [P3] Validator: correct the feature-cap comment to 'buildings + surfaces + props + signs combined' (signs were already counted). Verified: dense pack 1411/0 exit 0; ~85 FPS browser-measured (perf fix intact); badfoot pack (null-point + missing-footprint buildings) renders with zero page errors and frame loop alive; crash/dos/null/nullsign/fabric/props42/broken all exit 1, valid multi-tile exit 0; JS node --check OK; validate-skills 154 green; blocklist clean.
6.3 KiB
Engine Architecture
Coordinate and world model
Use a right-handed local coordinate system in meters: x east, y north, z up. The terrain is a regular grid with origin (ox, oy), spacing r, and elevations H[row][col]. Bilinear interpolation defines the ground:
terrain(x, y) -> z | null
u=(x-ox)/r; v=(y-oy)/r
z=(1-fu)(1-fv)H[j][i] + fu(1-fv)H[j][i+1]
+ (1-fu)fvH[j+1][i] + fu*fvH[j+1][i+1]
Return null when outside the grid or any interpolation corner is null. A null cell is darkness and is not walkable.
A building record contains:
{id, footprint:[[x,y],...], base_elev_m, height_m, color,
provenance:{source, url?, license?, retrieved?, confidence:0..1}}
The closed footprint is solid from base_elev_m to base_elev_m + height_m. The camera is {x,y,heading_rad,fov_rad,eye_height_m}; derive its vertical coordinates from terrain rather than storing an independent flying z.
Pedestrian physics
At the accepted position:
feet_z = terrain(x, y)
eye_z = feet_z + eye_height_m
For proposed horizontal movement from p0 to p1:
z0 = terrain(p0.x,p0.y); z1 = terrain(p1.x,p1.y)
if z1 is null: reject
height_delta = abs(z1-z0)
horizontal = hypot(p1.x-p0.x,p1.y-p0.y)
slope = height_delta / max(horizontal, 1e-9)
if height_delta > max_step_m or slope > max_slope: reject
if player circle at p1 touches/intersects any building footprint: reject
otherwise accept; feet_z=z1
Use max_step_m=0.45, max_slope=0.35, and player radius 0.30 m as starting values. Point-in-polygon plus minimum point-to-edge distance detects circle/polygon intersection. Treat equality as collision. If a diagonal proposal fails, test its x-only and y-only components separately to slide along walls without crossing them.
Raycast-to-ASCII-grid pipeline
Render a fixed grid such as 100 columns by 36 rows on Canvas 2D. Each screen column casts a horizontal ray at angle
ray_angle = heading - FOV/2 + (column+0.5)/columns * FOV
ray(t) = camera_xy + t * [cos(ray_angle), sin(ray_angle)]
March t from a near plane to max_distance (for example, 150 m) in increments no larger than half the terrain resolution. At each sample:
- Evaluate terrain. Null means no ground sample.
- Test whether the point lies in a building footprint. The first solid hit supplies wall distance, top elevation, stable color, and building ID.
- Project vertical values using corrected distance
d=t*cos(ray_angle-heading)to avoid fish-eye distortion:screen_y = horizon - focal_px*(world_z-eye_z)/max(d,epsilon)wherefocal_px=(columns/2)/tan(FOV/2). - Fill the visible wall interval and terrain below it into character-grid cells only when nearer than that cell's depth buffer.
- If the ray reaches maximum distance without geometry, explicitly reset the column to sky/darkness; never reuse a previous frame.
Pseudocode:
clear(chars=' ', fg=sky, depth=infinity)
for sx in columns:
ray = make_ray(sx)
for t in march(near,max_distance,step):
sample terrain and solids
project visible span
for sy in span: if corrected_distance < depth[sy][sx]: write cell
if opaque building covers remaining span: break
paint character grid to Canvas 2D
Glyph density, brightness, and color
Map normalized distance q=clamp(d/max_distance,0,1) to a concrete near-to-far density ladder:
q < .12: '@'
q < .25: '%'
q < .42: '#'
q < .60: '+'
q < .78: ':'
otherwise: '.'
Scale foreground brightness with brightness=0.25+0.75*(1-q)^1.4; terrain can use .,:;+=xX#@ according to both distance and local slope. Glyph cell dimensions stay fixed; apparent size changes through projected vertical span, while density and brightness fall with distance.
For stable building color, honor a valid pack-provided color. Otherwise hash the UTF-8 building ID with FNV-1a, select hue hash % 360, and use fixed saturation/lightness such as hsl(hue 65% 58%). Recompute deterministically or cache by ID; never pick random colors per frame. Adjacent equal colors are acceptable.
Dynamic cars or pedestrians may be depth-tested billboard ASCII sprites anchored at terrain(x,y). They are optional and do not alter static collision.
Street furniture, signs, and surface cues
A dense pack adds three enrichment layers, all depth-tested against the same buffer:
- Props (street furniture). Each prop renders as a one-cell billboard at
terrain(x,y)plus a small height offset, using the documented per-kind glyph (traffic_signal=T, tree=t, bus_stop=B, bench=b, bollard=o, fire_hydrant=f, crossing==, street_lamp=i; fallback?) and a per-kind color faded by distance. Props are spatially indexed (a grid keyed on the terrain resolution) so the per-ray-sample lookup stays near-constant; do not linear-scan the whole prop list per ray. - Signs (street-name text). Each sign is a perspective-projected text billboard rendered as an overlay pass after the raycast loop. Project the world anchor to a screen column from its angle relative to heading, place the row from
terrain(x,y)plus a sign height, truncate the text deterministically with distance, and write characters left-to-right through the depth buffer. Text always comes from a recorded sourcename, never generated. - Surface material and lighting. Road surfaces may carry
surfaceandlit. Map material to a ground glyph (for example asphalt., concrete:, paving;, cobble,) and brighten lit roads slightly at night-style falloff, so the ground plane reads as pavement rather than void. Marked crosswalks (crossing:markingsorcrossingprops) render as a distinct ground band at their recorded location.
The scaffold's HUD surfaces two wayfinding aids computed from the same data: the named street the player is standing on (nearest named surface within tolerance) and the name/address of the building the player faces within a proximity threshold. Both read from pack records, never hardcoded strings.
Reference scaffold
../assets/ascii-city-engine.html is a single dependency-free file. It reads the pack's manifest.json for the spawn coordinate and first world tile (falling back to world/tile-0.json), builds terrain, footprint, prop, sign, and surface indices, applies the movement rules above, and renders colored characters on Canvas 2D. Serve the repository root over HTTP because browsers commonly block fetch from file:// URLs.