The world stops being a simulation of California and starts being California. **THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.** On today's live store the SoCal board contains 22 incidents. Every one has NULL acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that contains no other warm colour, so one glyph would be the most salient object on the board and twenty-two would spend its credibility permanently. `acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)` returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day, three correct answers. The empty board is a deliverable, not a fallback: it says "No active fire on this board — CAL FIRE and WFIGS, just now", states that 21 records were gated and why, lists the largest fires burning OUTSIDE the frame with distances, and counts the hot pixels it is deliberately not drawing. **The privacy leak is structurally impossible rather than carefully avoided.** cloud-1 serves a projection; the four home-relative columns never leave that box. `observations.threat` was the one that nearly got through — it is `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so with acreage and containment public it inverts to a distance circle around a house and three fires give an intersection. A grep of the built bundle for distance_km, bearing_deg, threat, 7762 and the street name returns nothing. **Deliberately not used, and both would have produced a confident wrong answer:** the store's `air` table retains only the last parameter of each poll, so all 78 rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive Groups" — haze driven off it would clear the sky during a smoke event. And `weather` is written only inside the NWS alerts loop, so a quiet day stores no wind at all. Tera's own per-region NWS wind is already correct and already what the clouds drift on. Satellite detections are drawn as evidence and never as incidents. The permanent industrial heat source 4.7 km from the owner's house is flagged persistent and dropped, asserted by a test that first proves it is present in the fixture. MODIS integer confidence and VIIRS string confidence are branched on `sat`. **The LA office is a twin.** Its entire authored second storey — Model Loft, Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5, verified against the real pack rather than a synthetic plan. Its two studio devices read real hardware through a field-allowlisted bridge: mute, volume and reachability only. Never level, because there is no passive level upstream and obtaining one would record a room with people in it. Never dB, because upstream is gainPct across four different native scales. The bridge refuses all writes. Fixed at its root: an anonymous visitor was getting permanently at-rest instruments backing off against a 401. The tier moves into `createDeviceSource`, so anon gets the living simulator three file headers already promised. **Item 8 is closed, not fixed, and the correction is the point.** The Bay Area "stutter" was GPU power management — the card sat at 500 MHz of 2725 through every run that reproduced it, 4096/2048/1024/256 shadow maps all render in 1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then 16.7. The allowance is removed and the cell is back to 16.7. Geometry is the gate; frame time is advisory. Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056 triangles were the second submission of the same buildings into the shadow pass. Mobile now has its own triangle caps and bay-area mobile draws 1,266,096. Also: bridges and the freeway corridor light up at night as emission, not lights — 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single change that made US-101 legible was moving its edge lines from the lit material to the unlit one: retroreflective paint, the argument the SFO night frame already makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts, because a rural interurban corridor genuinely is unlit. Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
37 KiB
Tera — architecture
Tera is the map view: California and its cities from above, rendered in three.js, public and self-hostable. Spaces is the other half — the offices you walk into. They are one engine and one asset library, seen from outside and from inside.
Lives at tera.lumbridgecorp.com. Apache 2.0, on the fleet's own Gitea.
The names
Getting these straight early matters, because two of them were nearly overloaded onto things that already exist.
| name | what it is |
|---|---|
| Lumbridge Simulate | the product line these sit under |
| Tera | the map view. Cities — SF, LA, NYC — from above. Public, no login. |
| Spaces | the interiors product. Offices you enter, host, and self-host. |
| Office | one building's interior, belonging to a tenant. You go into an office. |
| Space | a room or zone inside an Office — a desk bay, a meeting room, a lounge. |
Two things this avoids. lumbridge-simulate is already a Rust crate in the
private monorepo — the Governor's memory-admission simulator — and in its
vocabulary a "Scene" is a set of models, not a three.js scene. This engine
therefore does not take either word for itself: it is Tera, and its three.js
scene stays an implementation detail behind createScene().
One overload survives on purpose: Spaces the product versus a Space the room. It sits at two different levels and matches how people already talk about offices, so it is kept rather than invented around.
1. Why this is its own repo, and why it is public
Tera began as /live inside Workie, the private career portal. That is now
deleted, not deprecated — a 3D city engine has no business living in a job
tracker, and Workie is a private repo besides.
You cannot cleanly Apache-2.0 a subdirectory of a private repo. The licence
attaches to a distribution, and a consumer has to be able to clone the thing the
licence covers. A folder inside lumbridgecorp-website or inside
lumbridge-runtime would mean every release is a manual extraction, and every
extraction is a chance to ship something private by accident. The point of this
project — a reusable asset library other people can build on and self-host — needs
a repo that is the open-source artefact.
So: lumbridge-public/tera on the fleet's own Gitea. Not
GitHub. Self-hosting the forge is the same claim the engine makes: the whole
thing runs on hardware you own, and nothing about it requires somebody else's
platform to stay alive.
The 2D world comes down
The existing Lumbridge world at world1.lumbridgecorp.com is a Phaser 2D
office, and its tileset is exactly what stops it being open-sourceable. It has
no real users, so it is being retired rather than migrated — Tera and Spaces
replace it outright, with original assets, which is the whole point.
That reframes this repo: it is not a map that happens to sit next to the platform, it is the platform's renderer. The city is the outside of the world, offices are the inside, and a tenant moves between them.
2. Layout
tera/
├── src/engine/ # renderer. Knows about terrain, blocks, streets, flights.
│ ├── types.ts # City, District, Hill, Marker, FlightSource
│ ├── world.ts # projection + cached heightfield, built per city
│ ├── terrain.ts # relief, coastline, parks
│ ├── blocks.ts # the built city, on a per-district street lattice
│ ├── structures.ts # bridges, streets, freeways
│ ├── markers.ts # pins. Takes Marker[], knows nothing about companies.
│ ├── flights.ts # FlightSource interface + a simulated implementation
│ └── scene.ts # lights, sky, camera flights, render loop
├── src/interiors/ # the inside of the world — floorplans, desks, presence
├── src/assets/ # the library. Original meshes and materials, Apache 2.0.
├── src/transport/ # serialisable route packs + renderer-free fixed-step sim
├── src/cities/ # data packs. Pure geography, no code.
│ ├── california.ts # state/corridor scale; LA ↔ SF continuity
│ ├── sf.ts # ~1000 lines of coastline, hills, districts, landmarks
│ └── socal.ts # LA / OC / Riverside
├── src/realtime/ # strict game-state wire/client/interpolation adapters
├── src/media/ # separate screen signaling, ICE and texture lifecycles
├── src/profile/ # local profile plus ephemeral webcam consent/capture
├── src/adapters/ # generic HTTP and sample-data boundaries
├── src/devices/ # smart hardware: types, a fixed-step simulator, the feed seam
├── src/arena/ # the RL environments, wrapping the same simulators the renderer drives
├── src/ui/ # every interface decision, and the one module that writes to the page
└── src/main.ts # the standalone demo app — assembly, and nothing else
The split that matters: engine never imports cities, and neither imports
adapters. A city is data handed to the engine. A marker is a {id, lat, lng, label, colorKey} handed to the engine. The engine has no idea Workie exists,
which is what makes it publishable, self-hostable by someone with no Lumbridge
account at all, and free of anyone's private data.
interiors and assets sit beside engine rather than inside it: an office
and a city share the projection, camera, lighting and render loop, and differ
only in what they put in the scene. That is why scene.ts owns the loop and
knows nothing about terrain specifically.
ui is the newest of these and the split inside it is the one worth knowing.
ui/chromeState.ts is a pure function: hand it a plain object describing what
the application is, get a plain object back describing what the interface should
be. ui/mount.ts is the only module in the product that writes to the DOM, and it
writes exactly what that function returned. main.ts therefore makes one call —
chrome.apply(chromeState(inputs)) — where it used to make about forty
element.hidden = someCondition decisions with the condition and the write on the
same line, none of which could be reached without a WebGL context. The rule that
keeps it that way: a new piece of chrome is a field in ChromeInputs, a decision
in chromeState and a write in mount, and never a fortieth mutation in the app.
devices follows the same shape one level down, and for the reason §7 gives about
presence: a DeviceDeclaration is authored — it says a microphone exists, what
it can be asked to do and which prop is its hardware — and lives in the office
pack, public by construction. A DeviceState is what that microphone is hearing,
never appears in a pack, and arrives from a route that can refuse an anonymous
caller. src/interiors/devices.ts is the render layer for the first and
src/devices/sim.ts is the state machine behind the second; the arena imports the
state machine directly, so what a policy is trained against and what a viewer is
looking at are the same code.
transport also stays outside the renderer. A TransportPack is plain JSON and
VehicleSimulation returns plain geographic poses. Three.js enters only in the
roadTraffic render layer, which projects poses through the active World.
That separation lets the authoritative session service validate and replay the
same route state without importing a browser or a GPU.
2.1 Product/control-plane boundary
Tera/Spaces is the authoritative renderer and simulation client. The separate
lumbridgecorp service remains the control plane: identity, tenant registry,
membership, billing, provisioning, and short-lived launch grants. It must not
grow a second world renderer or own frame-by-frame simulation.
The public Tera build remains zero-config and useful without any service. Hosted presence, multiplayer and remote media arrive through adapters whose absence cannot stop the city, office, traffic, or sky from running. Local profiles and webcam faces require no hosted service; webcam pixels never enter the realtime or office-screen transports. This is the replacement path for the retired Phaser World 1; it is not a migration of that renderer.
The first hosted adapter is now concrete: the Fastify API owns short-lived,
in-memory authoritative presence sessions and streams validated deltas over
authenticated POST/SSE. src/realtime remains renderer- and transport-neutral;
the browser transport, interpolation buffer, procedural peer renderer and small
status UI are separate adapters. A city or full-depth office opts into remote
peers explicitly, while an anonymous/public scene does not even allocate their
assets. Interest changes clear the old coordinate frame before a new one is
shown. Game-state deltas never carry webcam or screen media. Webcam faces are
local-only; office screens use a separate consent, signaling and authorization
lifecycle.
3. Three rules that keep Apache 2.0 honest
Apache 2.0 is a promise that everything in the repo is ours to give away. Three things would quietly break that promise, and all three are easy to walk into.
3.1 No trademarks in the repo
Company logos are trademarks, not code. Salesforce's logo is not ours to
relicense no matter how the file got here, and an Apache-2.0 repo containing
logos/salesforce.svg is making a claim it cannot back.
The NYC atlas ships 47 of these in public/logos/. We do not.
- The shipped application does not fetch or display third-party logos.
- The repo carries zero logo files. Self-hoster asset directories are ignored, and the provenance/no-binary gates prevent a logo-shaped binary from quietly becoming part of the procedural source library.
NOTICEcarries the standard "trademarks are the property of their respective owners; their use here is nominative" line.
3.2 No OSM-derived coordinates in the repo
This is the subtle one, and it is the reason the geography in cities/sf.ts is
hand-traced rather than imported.
OpenStreetMap data — including anything that comes out of Nominatim geocoding — is ODbL. ODbL is share-alike: a "derivative database" has to be released under ODbL too. Committing a table of company lat/lngs geocoded from Nominatim into an Apache-2.0 repo mixes an incompatible share-alike obligation into a permissive one. That is exactly the kind of thing that makes a repo unusable for the people we want using it.
So:
- Geography (coastlines, hills, districts) is traced by hand from scratch. Original expression, ours, Apache 2.0. This is already true for SF.
- Elevation, when we want real terrain, comes from USGS/SRTM, which is US-government public domain. Not OSM.
- Geocoded company coordinates never enter this repo. They live in a private database and arrive over the API at runtime.
There is a real result here: the licence constraint and the privacy constraint
want the same thing. Company positions and pipeline status both stay behind the
API; the open-source repo holds the city and the renderer. The tempting shortcut
— "just commit an sf-companies.json" — breaks both at once.
Correction: containment is not discharge
An earlier version of this section stopped at the paragraph above, and it was wrong in a way worth recording rather than quietly fixing.
Keeping OSM-derived coordinates out of the repo solves licence mixing inside the repo. It does not touch ODbL's actual trigger. Serving a snapshot of those coordinates from a public endpoint is Publicly Using a Derivative Database, which brings ODbL §4.3 attribution and §4.4 share-alike onto the served data regardless of where the rows are stored. Moving the table off-disk hides the obligation; it does not discharge it.
So the rule is about the geocoder, not the storage:
- The sanctioned geocoder is the US Census Geocoder
(
geocoding.geo.census.gov) — a US Government work in the public domain, keyless, covering every city planned here. - Google, Mapbox and HERE do not solve this either. Their terms restrict storing and redistributing returned coordinates, which is precisely what a public snapshot does. "Not OSM" is not the test; "may be redistributed" is.
- Every synced row carries a provenance field, and the public-shape assertion rejects any row whose provenance is not on a non-ODbL allowlist. The gate that already checks field names now also checks where a coordinate came from.
Nothing had been built against the old reasoning when this was caught, which is the only reason it was cheap.
3.3 The engine takes no position on what a marker means
markers.ts renders Marker[]. A marker has a colorKey, not a status. The
mapping from "rejected" to red lives in the adapter, in the consuming app.
That is what lets the same engine serve the private Workie build (coloured by pipeline state), the public Lumbridge demo (coloured by sector), and whatever someone else builds from this repo, without any of them being a fork.
4. Flights: FlightRadar24 is the wrong source for this
Real aircraft over the Bay is a great idea. FR24 specifically is a problem, and it is worth being clear about why before any code is written against it.
FlightRadar24's terms prohibit scraping and prohibit redistributing their data; their commercial API is licensed per-seat and explicitly does not grant redistribution. An Apache-2.0 repo shipping an FR24 client is publishing instructions for violating a ToS, and any FR24 data that reached the browser could not be relicensed onward. Neither is fatal to a private deployment, but both are fatal to the open-source story that is the point of this repo.
The engine therefore takes a FlightSource interface, and the repo ships
only sources it can actually give away:
| source | licence | notes |
|---|---|---|
SimulatedFlights |
ours, Apache 2.0 | great-circle tracks on real SFO/OAK/SJC approach and departure paths. This is what ships first, and it is genuinely enough — the map wants convincing motion, not a spotter's log. |
adsb.lol / airplanes.live |
community, open terms | free, no key, real aircraft. The default "real data" adapter. |
| OpenSky Network | free, non-commercial | fine for a demo, awkward for a commercial product page. |
| your own receiver | no licence at all | an RTL-SDR + dump1090 on any fleet box in the Bay produces first-party ADS-B. Nothing to comply with, and it is very on-brand. |
| FlightRadar24 | commercial, no redistribution | if it is ever wanted, it is a private adapter in the Lumbridge deployment, not in this repo. |
Recommendation: build SimulatedFlights now, adsb.lol next, and treat a
receiver as the eventual real answer.
5. LA is not "SF but more data"
SF is 0.20° × 0.36°. At the 45 m cells the SF heightfield uses, that is 336k lattice points and a 1.0 s build. (The pack has since grown to the whole Bay Area: 0.53M points and about 2.3 s.)
LA + Orange County + Riverside is roughly 0.5° × 1.9° — about 14× the area.
The same approach would be 4.6M lattice points and a mesh nobody can load. This
is the one place where the engine as written does not simply extend, and it is
better to know that before writing cities/la.ts than after.
Two changes make it work, and both are cheap now:
- Cell size is per-city, not a constant. LA's basin gets coarse cells; nothing is lost, because LA's relief is mountains at the edges rather than SF's hills every four blocks.
- Cities declare
focusRegions— a handful of boxes (DTLA, Santa Monica, Culver, Irvine, Pasadena) rendered at fine resolution, with the basin between them coarse. This is the LOD story, and it is a per-city data declaration rather than engine machinery.
SF gets one focus region covering the whole city and behaves exactly as it does now. LA gets six. NYC, later, gets Manhattan plus the inner boroughs.
5.1 The heightfield is built off the main thread
Both numbers above are seconds during which nothing rendered and nothing
responded, because the build ran on the thread that paints. It runs in a Worker
now (src/engine/terrain.worker.ts), which is why createScene is async and
takes a Stage rather than a canvas.
Three consequences worth knowing before touching it:
- The city pack has to stay structured-cloneable. It is posted to the
worker as data. A
Citythat acquires a method, a class instance or a closure stops being sendable, and the fix is to remove it rather than to JSON round-trip around it — a city pack containing code is the thing §2 exists to prevent. - The sampled accessors stay synchronous.
elevationAt,groundAtandisLandare called in tight loops byterrain.ts,blocks.tsandminimap.ts; the asynchrony is confined to becoming ready, and sampling before then is a documented error rather than a silent zero. - There is a main-thread fallback and it is not optional. An environment
without Workers — a
file://open, a locked-down browser — still has to build and render, because "clone it and it works" has no exception clause.
The same reasoning put Spaces behind an await import(): the office is a large
slice of the bundle and most visitors never open it, so it is fetched when
somebody reaches for the door rather than by everybody at boot.
6. Hosted data and authority boundaries
Tera has no database requirement. The optional Fastify API supplies bounded
weather, flight, satellite, marker, office, realtime and screen-signaling
adapters behind same-origin /api/v1/*; a static clone continues with authored
geography and simulation when that API is absent.
Marker rows cross the strict public-shape and provenance allowlist before the
renderer sees them. The renderer receives only Marker[] and never pipeline or
tenant records. Realtime peers receive opaque page-scoped actor identifiers and
validated pose state, never auth subjects or profile faces. Screen media is
peer-to-peer: the API validates exact authored screen bindings and relays bounded
signaling, but it never receives media bytes, recordings or source locators.
The currently implemented hosted authorization is deployment-member plus global admin. It is deliberately not described as tenant/office membership. A control- plane membership provider must be added before private tenant offices share the same deployment authority boundary. TURN is also optional and fail-closed; the credential endpoint being implemented does not mean a public relay is deployed or approved.
7. Interiors — and why the asset library is the real product
The existing Lumbridge world is a Phaser 4 2D office, and Karti's read is right: its tileset is the thing that stops it being open-sourceable. Redrawing that in 3D with original assets is not a graphics exercise, it is a licensing unlock — it is what lets the walkable-office half of Lumbridge ship under Apache 2.0 at all.
That makes src/assets the highest-leverage part of this repo over time: an
original, consistent, Apache-2.0 library of desks, chairs, partitions, screens,
doors, lighting rigs, floor and wall materials. Built once, used by every
Lumbridge world and by anyone else who wants one.
Interiors reuse the engine's renderer, camera machinery and render loop, and swap the city layer for a floorplan layer. The city view and the office view are the outside and the inside of one world, and moving between them is a scene swap rather than a different application.
Two claims that stood here earlier were wrong, and CONTRACT.md carries the resolutions:
- They do not share a
THREE.Scene. They cannot. San Francisco'slatScaleputs one scene unit at ~94 m with 3.6× vertical exaggeration; an office renders at 1 unit = 1 m. There are two scenes over one renderer, and the city scene is retained and paused when you step inside rather than disposed — rebuilding its 0.53M-point heightfield costs about two and a half seconds on the way back out. - A person at a desk is not a
Marker. A marker is geographic — it has a lat/lng. Presence binds to a seat id and never to a coordinate, which is what lets the office geometry be public and open-source while who is sitting ineng-04stays private data behind an API. Same shape as the marker rule, one level in.
Reference point: the per-office spatial products in this space (Simile and
friends) are the shape to aim at. The difference Spaces is going for is that this
one is Apache 2.0 and self-hostable — you can run your own world on your own
hardware, which is the same claim lumbridge-compute makes about compute.
That phase is now implemented: offices consume the same procedural library and retain the city scene behind a metre-scale interior scene swap.
8. Current release topology
The repository now ships California corridor, Bay Area and Southern California
boards; walkable offices; procedural actors, vehicles and aircraft; optional
authoritative realtime; local webcam faces; and separately authorized office
screen signaling. BUILD_PLAN.md is the milestone evidence ledger.
Four things landed together and are worth naming, because each crosses a boundary this document describes:
- One environment map for the page.
src/engine/environmentRig.tsderives a sky (city) or a room (office) from theLightingStatethatAtmospherealready decided, so the eleven metallic material roles and the Model X's clearcoat have something to reflect. It sits at the end of §4's one-way street: it is handed a decision and never makes one, and it constructs no light. One rig is built beside the one renderer and shared by every scene; each scene releases itself from it on dispose. - The interface is a pure function plus an applier. See §2 above.
- Smart hardware in the studios, declared in packs and simulated in the tab
when a deployment has no device source — the same anon-first fallback
SimulatedFlightsandsample.tsalready make. - A Model X on each studio's arrival apron, driven by
src/transport/vehicleTelemetry.ts, and overhead traffic in a sited office's sky drawn from the sameFlightSourcethe board outside is polling. The aircraft are placed on a dome at their true bearing and elevation rather than at their true range: an airliner at twenty kilometres is past a room-scale far plane, and a map symbol drawn in 3-D is what §4's traffic has always been.
The source code is Apache-2.0. src/assets/LICENSE-ART additionally dedicates
the artistic output of the procedural asset library under CC0-1.0.
PROVENANCE.json, the dependency allowlist and SPDX SBOM gate the distribution.
Remaining questions are deployment questions, not missing renderer contracts:
- Which authoritative control-plane provider proves tenant/office membership?
- When do operators approve public TURN/DNS/firewall/certificate changes after relay-only and abuse-boundary acceptance?
- Which Firefox/WebKit versions become supported browser-test targets?
- Which additional worlds or optional asset packs enter through the same provenance and performance gates?
9. Fire — and why the privacy filter lives on cloud-1
Tera can draw the wildfires that are actually burning in California. The data
behind that is collected by fleet-tools/fires on cloud-1, into a sqlite
store that is centred on the owner's house. That single fact decides the whole
architecture of this feature, and it is worth stating before any of the shapes.
9.1 A projection, not a copy
cloud-1 serves GET /api/fires/incidents and /api/fires/detections as a
projection: a hand-written column list that a home-relative column is not in.
tera-api consumes those over createUpstream exactly the way it consumes
satellites, and never opens the sqlite. The store is not copied, mirrored,
exported on a cron, or replicated to cloud-2.
The reason is not convenience. These columns are computed against the owner's home and must never reach a public wire:
| Column | Table |
|---|---|
distance_km |
observations, detections, evac_zones |
bearing_deg |
observations |
threat |
observations |
threat is the one that is easy to miss. It is
(16.0 / distance_to_house)^2 x log10(acres) x momentum x containment x wind-alignment — so with acreage and containment public, publishing threat
solves for the distance. One circle around the house per fire; three fires
give an intersection. Two more are the coordinate with extra steps: air.area
is the literal string "Norco and Corona", a named two-town area next to the
house, and the fires near CLI prints the full street address in its header, so
no design may proxy that stdout.
If tera-api pulled the sqlite, every one of those would be sitting on cloud-2
one careless SELECT * away from a public route. Serving a projection makes the
leak structurally impossible rather than merely avoided — and it costs this repo
nothing, because adapters/http.ts and createUpstream already are exactly this
shape. It also refuses the failure mode this store exists to prevent: a cron
export that silently stops looks identical to "no fires", and BACKUP.md already
records what a timer reporting success is worth.
Note also that acres, pct_contained and the rest live in observations, not
in incidents — the projection is a join to the latest observation per incident,
and the join's SELECT list is precisely where a lazy o.* would put the
home-relative columns on the wire.
9.2 The promotion gate is the feature
promote() in src/server/fires.ts decides what is worth drawing:
acres >= 10
AND coalesce(pct_contained, 0) < 80
AND type != 'RX'
AND last_seen = (SELECT max(last_seen) FROM incidents)
Verified against the live store on a quiet day: zero rows inside the SoCal board bounds, and exactly five on the California board. Same data, same day, both answers correct. Ungated, SoCal draws twenty-two orange marks over Los Angeles on a day nothing is burning — every one with a NULL acreage, fifteen of them nameless LA County dispatch numbers — in a frame that contains no other warm colour, so each one is the most salient object on the board.
Three of the four clauses are there for a specific, verified reason:
last_seen = max(last_seen)—persist()writes every row and never deletes, so a deduplication loser keeps its oldlast_seenforever. 21 of 95 rows are stale ghosts; MP18 and Timber each appear twice, 850 m apart, with different acreage.type != 'RX'— a prescribed burn is deliberate, scheduled, frequently adjacent to a real one, and indistinguishable from a wildfire under a distance filter. It must never render as one.acres >= 10— an untested judgement, stated as such. The store has never held a SoCal fire between 1 and 100 acres, so this boundary has never been exercised against the case it exists for, and a genuinely dangerous 5-acre fire in Griffith Park would be invisible. It is accepted because WFIGS's own LA County records make the low-acreage false-positive rate overwhelming, and the constant is written where it is obvious and one edit away.
An empty board is therefore a result, not a gap, and the panel says so with
the age of the fetch: "No active fire on this board — CAL FIRE and WFIGS, 4
minutes ago." A silent board and a dead feed are indistinguishable without a
timestamp, which is the same argument health.ts already makes for degraded[].
9.3 A detection is evidence, not an incident
Satellite thermal detections are a separate, visually weaker layer and are
never promoted into an incident client-side. There is a permanent industrial heat
source 4.7 km from the owner's house at FRP ~1.0 that appears on every pass, on
every day in the store, with no matching incident: drawn as a fire, it draws a
fire on his house. Two more properties of that table have bitten already —
confidence carries incompatible scales in one column (MODIS is an integer
0-100, VIIRS is low/nominal/high, so read sat first), and the
highest-FRP detections in the store are in Nevada, because CA_BBOX is a
rectangle and in_ca() gates incidents only.
9.4 Two tables that must not be used
air—observed_atis the primary key andfires.pydoesINSERT OR REPLACEonce per parameter per poll, so only the last parameter survives. Every row says NO2 / AQI 20 / "Good" while the live feed reports ozone 101 and PM2.5 55 at the same instant. Haze driven off it clears the sky during a smoke event.weather— every row iszone='point', one grid sample at the owner's house, written only inside the NWS alerts loop, so on a quiet day no wind is stored at all. Tera's own per-regionWeatherBody.windDirDeg/windKphis already fetched, already correct for the board, and is already what the clouds drift on. The plumes drift on the same one, because a plume leaning on a different wind from the cloud beside it would be two opinions about one sky.
9.5 Where it attaches in the engine
scene.ts owns the wiring and constructs no fire geometry itself. The seam is
SceneOptions.fires, a factory with the same shape createCloudLayer has, and
five forwarders on SceneHandle:
type FireLayerFactory = (world: World, options: { span: number }) => FireLayer;
interface SceneHandle {
setFires(view: FireView | null): void; // null = nothing has answered yet
setFireSmoke(visible: boolean): void; // plumes on/off; the marks stay
fireSmokeLoadAt(lat: number, lng: number): number; // 0..1, for haze elsewhere
// and, already present: setLighting, setSolarElevation, setWind
}
FireView, DrawnFireMark and FireDetectionMark are declared in scene.ts
as the minimum a renderer needs, and FirePromotion — what promote() in
src/server/fires.ts actually returns — is assignable to FireView with no
mapping step. The duplication is deliberate and is what keeps the engine from
importing a wire module: the same rule that keeps Marker in
engine/types.ts and the marker row on the server. src/test/fireSeam.test.ts
is the one thing standing between that decision and silent drift — it assigns a
FirePromotion to a FireView at compile time, so a renamed column fails there
rather than in a render loop.
fireSmokeLoadAt is the one read-back, and it is what couples the LA courtyard
to the real sky. A fire sixty kilometres away in the San Gabriels is not a flame
seen from a courtyard: it is a brown horizon, a dimmed orange sun, and air that
stops being clear closer in. That is one scalar into daylight.ts, and zero
geometry.
ATMOSPHERE IS THE SOLE LIGHT OWNER still holds (CONTRACT §4). The night glow of
a fire is emissive material and one additive ground quad — the same two
mechanisms nightlights.ts uses to draw San Francisco's 12,038 street lamps in
one draw call — and not a THREE.PointLight.
10. First-party devices, and the anonymous visitor
A studio's instruments reach the renderer by one of two strategies, chosen once
at construction in createDeviceSource: the deployment's device route, or the
fixed-step simulator bundled in the tab. There is no runtime failover, because a
strategy that silently swapped a real bridge for a simulator mid-session would be
the first-party-sensor/simulated confusion DeviceProvenance exists to
prevent, arriving without a word in the interface.
That choice needs two facts and used to be made from one. Feeds.devices
says the deployment has a device source; Capabilities.liveDevices says the
viewer may read it. On cloud-2 the first is true — /health reports
devices: "sim" — and for an anonymous visitor the second is false, because the
route is members-only and answers 401. Passing only the deployment's answer sent
every anonymous visitor down the API strategy to be refused, whereupon
apiSource rendered atRest() — a rack of powered-off instruments, forever,
backing off exponentially against a request that could never pass — beside a
panel describing a studio that runs locally. Both gates are now applied at the
one call site in main.ts, and the fallback is the simulator that was always
meant to serve this case.
What a first-party studio may honestly mirror is bounded by what the upstream actually measures, and two refusals are load-bearing:
- No microphone level. There is no passive level upstream;
POST /levelscallsmeasureMicand records 1.5–3 s per mic. "Just add the level meter to make it feel alive" builds a continuously-recording microphone and it looks like a feature while doing it. No level capability, no JPEG from/cameras/:id/live.jpg(which captures on demand), no/sleep, no/automations— at any tier, behind any flag. - No decibels. Upstream speaks
gainPct, normalised over four different native scales (Yeti max 50, SMY18 and Anker 100, ThinkPad 63). Rendering 68% as "20.6 dB" would look completely plausible and would be a guess presented as a measurement — the exact failure this product names elsewhere. Without a declaredDeviceDeclaration.ranges, the gain row is not mirrored at all.
11. How the fire reaches the board, and where it stops
Sections 9 and 10 describe the two feeds. This one is the wiring, because every decision in it is about which board and which viewer, and those are made in exactly one file each.
11.1 Three gates, and none of them is a permission
main.ts polls /api/v1/fires only when all three hold, and each is about a
different thing:
| Gate | Question | Where |
|---|---|---|
FIRE_BOARDS.has(id) |
does fire happen on this rectangle? | main.ts |
access.feeds.fires |
has this deployment got a projection? | access.ts, from /health |
| — | may this viewer see it? | there is no third gate |
A wildfire is a public agency record. CAL FIRE publishes every one of these on
its own website, so there is no Capabilities twin to feeds.fires and there
must never be one: the fires are exactly as public as the weather and the
aeroplanes, and the anon-first rule that governs those governs this.
The board gate is the one people will want to remove. It is not laziness — the
Bay Area rectangle has held zero incidents on every day the upstream store has
existed, and it is the board already carrying the largest frame-time allowance
in the product. SceneOptions.fires is withheld rather than passed and left
empty, so on San Francisco there is no group, no material and no draw call.
11.2 One body, every board
/fires takes no query and is not per-region: the whole state's live incident
set is small, and the clip is promote(), which the client has to run anyway to
apply the tier ladder. That has a visible dividend and main.ts spends it —
firesBody is held for the page rather than for the board, so switching
from California to the Southland re-clips the answer already in hand and draws
the correct, and correctly empty, board in the frame of the switch. Only the
first board of a session ever shows the "nothing has answered" sentence.
The three states are distinct and the panel says which one it is in:
ageMs === null— nothing has ever answered. A fault sentence, never an all-clear. A dead feed and a quiet day are indistinguishable without a timestamp, which is the same argumenthealth.tsmakes fordegraded[].drawn.length === 0, suppressed > 0— the board is quiet and n live records were refused by the gate. The Southland today: nothing drawn, twenty-one refused, and the panel names the threshold that refused them.drawn.length === 0, suppressed === 0— nothing is happening on this rectangle at all.
11.3 Where the panel is, and where it goes away
#fire-section and #fire-host belong to main.ts, not to mount.ts — the
same arrangement #presence-host has, and the reason main.ts's "write to no
chrome node" rule survives. The panel is rebuilt per board because its bounds
are the board's, and one rectangle feeds both promote() and the caption so the
picture and the sentence cannot disagree about what is off-frame.
Inside a building the panel appears only when the fires are in that building's
sky. Outside, it is always up on a board that draws fire, because "nothing is
burning here" is the fact worth stating. Inside, a five-item list of incidents
three hundred kilometres away pushed the room's own controls below the fold on
the first frame a visitor sees of it. When the courtyard actually goes brown the
list is the explanation for what is on screen and it belongs there;
smokeCaption says the same thing in one sentence either way, and it sits with
the room's other disclosures rather than in the fire panel, because it is a
claim about this room's picture: the sky in here is derived from incidents on
the map and is not a measurement of the air at this address.
11.4 The office coupling is one argument
officeDaylight(state, site, smokeLoad) — third argument, clamped, and
bit-identical to no argument at zero. The scalar is
SceneHandle.fireSmokeLoadAt(site.lat, site.lng), and the adaptation happens
once, in main.ts, where the rest of the office's rig adaptation already
happens. A room may not reach into the fire layer and form a second opinion
about its own sky (CONTRACT §4).
One consequence is worth stating rather than hiding: the load is computed from
the board you came in from, so the same building is very slightly hazier
entered from California than from the Southland, because the two rectangles
contain different fires. smokeCaption's sentence is worded for exactly that —
"drawn from the fires currently on the board" — and the alternative, a statewide
query per building, is a second source of truth about the same sky.