diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md
index 3960840..5d5f012 100644
--- a/docs/INTEGRATION.md
+++ b/docs/INTEGRATION.md
@@ -280,6 +280,22 @@ In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set
Render the current board from `GET /governors` (§6) on connect, then keep it live with these events.
+#### Presence (Protocol 2.0)
+
+Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, default 30s) and emitted **only when it changes**; region transitions arrive in real time.
+
+| kind | fields | notes |
+|------|--------|-------|
+| `presence.online` | `count`, `byFacet` `{map: n}`, `byRegion` `{region: n}` | The current online population. Emitted when the count or any breakdown changes. `GET /online` gives the latest; `GET /history?kind=presence.online` the time series. |
+| `region.enter` | `from` (or null), `to` (or null), `map`, `who` (actor object) | A player crossed into a new named region. `from`/`to` are region names (`Wilderness` is unnamed). Cheap "who's where" feed. |
+
+```json
+{"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
+ "byRegion":{"Britain":18,"Wilderness":9,"Despise":2},"t":1752489280000}
+{"kind":"region.enter","from":"Britain","to":"Despise","map":"Felucca",
+ "who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...}
+```
+
---
## 5. REST — read queries
@@ -576,6 +592,16 @@ GET /governors
Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city.
+### Online population (Protocol 2.0)
+
+```
+GET /online
+→ {"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
+ "byRegion":{"Britain":18,"Wilderness":9},"t":1752489280000}
+```
+
+The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`.
+
---
## 7. Status codes
diff --git a/docs/PROTOCOL_2.md b/docs/PROTOCOL_2.md
index 5455b73..9a5b993 100644
--- a/docs/PROTOCOL_2.md
+++ b/docs/PROTOCOL_2.md
@@ -381,8 +381,8 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must
## 13. Part B phasing
1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.*
-2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store.
-3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.)
+2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.*
+3. **Housing registry.** Extend the decay sweep to a full owner→houses list → `GET /houses`. (Selected from the §11 menu. Note: stock ServUO has no "for sale" flag on houses, so the registry is owner→houses; for-sale is dropped.)
4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`.
5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one.
diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index 6cb896e..bf4fc04 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -39,6 +39,15 @@ GuildSweepSeconds=60
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
CitySweepSeconds=300
+# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
+# interval and emitted as presence.online only when it changes. Region transitions come
+# through separately in real time as region.enter (EventSink.OnEnterRegion).
+PresenceSweepSeconds=30
+
+# Housing registry poll. Every house is diffed on this interval to emit house.update /
+# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
+HousingSweepSeconds=300
+
# Shown to a player when they run [link. The website page where they enter the code.
LinkUrl=https://yoursite/link
diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
index 6629494..88d3992 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
@@ -163,6 +163,7 @@ namespace Server.Custom.Bridge
BridgeChamps.Rearm();
BridgeSocial.Rearm();
BridgeGovernance.Rearm();
+ BridgePresence.Rearm();
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
break;
@@ -177,11 +178,13 @@ namespace Server.Custom.Bridge
BridgeChamps.SweepOnce();
BridgeSocial.SweepOnce();
BridgeGovernance.SweepOnce();
+ BridgePresence.SweepOnce();
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
break;
default:
@@ -194,6 +197,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
break;
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index 68ee2c3..e8aebcc 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -33,6 +33,8 @@ namespace Server.Custom.Bridge
public static int ChampSweepSeconds { get; private set; }
public static int GuildSweepSeconds { get; private set; }
public static int CitySweepSeconds { get; private set; }
+ public static int PresenceSweepSeconds { get; private set; }
+ public static int HousingSweepSeconds { get; private set; }
public static string LinkUrl { get; private set; }
@@ -91,6 +93,14 @@ namespace Server.Custom.Bridge
if (CitySweepSeconds < 1)
CitySweepSeconds = 1;
+ PresenceSweepSeconds = Config.Get("Bridge.PresenceSweepSeconds", 30);
+ if (PresenceSweepSeconds < 1)
+ PresenceSweepSeconds = 1;
+
+ HousingSweepSeconds = Config.Get("Bridge.HousingSweepSeconds", 300);
+ if (HousingSweepSeconds < 1)
+ HousingSweepSeconds = 1;
+
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
diff --git a/overlay/Scripts/Custom/Bridge/BridgePresence.cs b/overlay/Scripts/Custom/Bridge/BridgePresence.cs
new file mode 100644
index 0000000..d0a50f9
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgePresence.cs
@@ -0,0 +1,203 @@
+using System;
+using System.Collections.Generic;
+
+using Server.Mobiles;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// The presence stream (docs/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts:
+ ///
+ /// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted
+ /// on a sweep but only when it changes, so the site has a live "N online"
+ /// plus a change history without a firehose of identical frames.
+ /// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap
+ /// per-player movement signal PLAN.md §5.6 recommends over Movement.
+ ///
+ /// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the
+ /// same population the vitals sweep already walks; counting them by map and region is a handful
+ /// of field reads. region.enter is filtered to players.
+ ///
+ public static class BridgePresence
+ {
+ private static Timer _timer;
+
+ // Signature of the last-emitted snapshot, so an unchanged population emits nothing.
+ private static string _lastSig;
+
+ private static long _sweeps, _emitted, _regionEnters;
+
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ EventSink.OnEnterRegion += OnEnterRegion;
+ EventSink.ServerStarted += OnServerStarted;
+ }
+
+ private static void OnServerStarted()
+ {
+ // Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the
+ // current population within one sweep.
+ BridgeLink.Connected_Core += OnConnected;
+ Rearm();
+ }
+
+ private static void OnConnected()
+ {
+ _lastSig = null;
+ }
+
+ /// Stops and recreates the timer from current config. Called by `[bridge reload`.
+ public static void Rearm()
+ {
+ Stop();
+
+ _timer = Timer.DelayCall(
+ TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
+ TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
+ PresenceSweep);
+ }
+
+ public static void Stop()
+ {
+ if (_timer != null) { _timer.Stop(); _timer = null; }
+ }
+
+ public static string Status()
+ {
+ return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})",
+ _sweeps, _emitted, _regionEnters);
+ }
+
+ /// Runs one sweep now. Wired into `[bridge sweepnow`.
+ public static void SweepOnce()
+ {
+ PresenceSweep();
+ }
+
+ private static void PresenceSweep()
+ {
+ try
+ {
+ _sweeps++;
+
+ if (!BridgeLink.Connected)
+ return; // nothing is listening; do not fill the queue with perishable snapshots
+
+ int total = 0;
+ var byFacet = new SortedDictionary(StringComparer.Ordinal);
+ var byRegion = new SortedDictionary(StringComparer.Ordinal);
+
+ foreach (var m in World.Mobiles.Values)
+ {
+ var pm = m as PlayerMobile;
+
+ if (pm == null || pm.NetState == null || pm.Deleted)
+ continue;
+
+ total++;
+
+ var facet = pm.Map == null ? "Internal" : pm.Map.Name;
+ Bump(byFacet, facet);
+
+ var region = pm.Region;
+ var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name;
+ Bump(byRegion, regionName);
+ }
+
+ var sig = Signature(total, byFacet, byRegion);
+ if (sig == _lastSig)
+ return; // population unchanged since last emit
+
+ _lastSig = sig;
+ BridgeLink.Emit(WriteOnline(total, byFacet, byRegion));
+ _emitted++;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message);
+ }
+ }
+
+ private static void Bump(IDictionary map, string key)
+ {
+ int n;
+ map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
+ }
+
+ private static string Signature(int total, SortedDictionary byFacet, SortedDictionary byRegion)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.Append(total);
+ foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
+ sb.Append('#');
+ foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
+ return sb.ToString();
+ }
+
+ private static string WriteOnline(int total, SortedDictionary byFacet, SortedDictionary byRegion)
+ {
+ var sb = BridgeJson.Begin("presence.online").Num("count", total);
+
+ WriteCounts(sb, "byFacet", byFacet);
+ WriteCounts(sb, "byRegion", byRegion);
+
+ return sb.End();
+ }
+
+ /// Writes a nested object of {name: count} pairs.
+ private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary counts)
+ {
+ sb.Append(",\"").Append(field).Append("\":{");
+
+ bool first = true;
+ foreach (var kv in counts)
+ {
+ if (!first)
+ sb.Append(',');
+ first = false;
+
+ BridgeJson.Escape(sb, kv.Key);
+ sb.Append(':').Append(kv.Value);
+ }
+
+ sb.Append('}');
+ }
+
+ // ---- real-time region transitions ----
+
+ private static void OnEnterRegion(OnEnterRegionEventArgs e)
+ {
+ try
+ {
+ if (e == null || e.From == null || !e.From.Player)
+ return;
+
+ var from = e.OldRegion;
+ var to = e.NewRegion;
+
+ // Only meaningful when the named region actually changed.
+ var fromName = from == null ? null : from.Name;
+ var toName = to == null ? null : to.Name;
+ if (String.Equals(fromName, toName, StringComparison.Ordinal))
+ return;
+
+ var sb = BridgeJson.Begin("region.enter")
+ .Str("from", fromName)
+ .Str("to", toName)
+ .Str("map", e.From.Map == null ? null : e.From.Map.Name);
+
+ sb.Actor("who", e.From);
+
+ BridgeLink.Emit(sb.End());
+ _regionEnters++;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message);
+ }
+ }
+ }
+}
diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs
index a8f8c73..9ce5435 100644
--- a/sidecar/src/web.rs
+++ b/sidecar/src/web.rs
@@ -77,6 +77,7 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// and survive an outage with the last-known snapshot (docs/PROTOCOL_2.md §12.2).
.route("/guilds", get(guilds))
.route("/governors", get(governors))
+ .route("/online", get(online))
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new()
@@ -729,6 +730,26 @@ async fn governors(State(st): State) -> impl IntoResponse {
}
}
+/// The current online population: total plus per-facet and per-region counts. This is the most
+/// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the
+/// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the
+/// population time series. Returns `count: 0` if the shard has not reported one yet.
+async fn online(State(st): State) -> impl IntoResponse {
+ match st.store.recent(Some("presence.online"), 1).await {
+ Ok(mut events) => match events.pop() {
+ Some(latest) => (StatusCode::OK, Json(latest)),
+ None => (
+ StatusCode::OK,
+ Json(json!({"kind": "presence.online", "count": 0, "byFacet": {}, "byRegion": {}})),
+ ),
+ },
+ Err(e) => (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": e.to_string()})),
+ ),
+ }
+}
+
// ---- websocket ----
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse {