Skip to content

Message Beacons

Status: 🚧 Partial — beacon deploy/read kernel partially shipped; full player-facing beacon UX incomplete. (re-verified 2026-08-21 vs Sectorwars2102 46bce720.)

A small physical object a player can deploy in any sector. The beacon carries an arbitrary text message and an author identity, sits in space until salvaged or self-destructed, and can be read by any other player who arrives in the sector. Used for emergent storytelling, route-marking, warning fellow travelers about hazards, leaving graffiti on the universe, and giving players a way to communicate asynchronously across time without using the persistent messaging system.

This is the "message in a bottle" mechanic — orthogonal to direct player-to-player messaging (the persistent inbox / Message model documented in DATA_MODELS/player.md) and to the realtime chat bus. Beacons are physical objects in the world, discoverable by traversal, not by directory lookup.

Purpose

Message beacons fill three gaps:

  1. Asynchronous in-world communication. A player can leave a message at sector 47 saying "the desert planet here is mine, don't bother colonizing" without needing the recipient's player ID — the next person who arrives reads it.
  2. Route-marking and trail-blazing. "There's a Lumen Crystal anomaly two sectors north" left at a junction tells later explorers what's worth their time.
  3. Emergent worldbuilding. Players leaving graffiti at famous battle sites, the Nexus Capital, formation interiors, etc. The universe accumulates player-authored history that newer players discover organically.

How they work

Deployment

Any player ship that can carry cargo deploys a beacon by spending 5 turns + 500 credits + 1 inventory slot of equipment cargo. The beacon is configured with:

  • A message (1–500 characters of text; standard player-input sanitation per the AI-anti-griefing layer at ../../OPERATIONS/aria.md).
  • An optional read-once flag. When set, the first reader's act of reading destroys the beacon. Useful for treasure hints and dead-drops. Default: false.

There is no player-facing expiry-choice menu (24h/7d/30d/never) — that earlier design is retired (WO-BEACON-LIFECYCLE). Every deploy creates one fixed 30-day charge cell instead; see Lifecycle below for how it's extended or lapses.

Deployment commits via POST /api/v1/beacons/deploy with {sector_id, message, read_once} (no expiry field — WO-BEACON-LIFECYCLE, see above). The server: 1. Validates the player has the resources, is in the sector, is not docked. 2. Validates the message text against the AI-anti-griefing filters (XSS, profanity blocklist, prompt injection prevention). 3. Inserts a MessageBeacon row. 4. Adds a beacon entry to Sector.message_beacons JSONB. 5. Emits a beacon_deployed realtime event to the sector's room so other players currently in the sector see it appear.

Discovery

Any player arriving in a sector with active beacons sees them in the sector view. The presence is broadcast via the realtime bus (sector.beacons_present payload on the sector-arrival event).

Sector-view popup (tip 46bce720): 🚧 preview-only — windshieldTableauPopupContent.tsx case 'beacon' shows title, deployer nickname, meta.preview quote, and date. There are no Read/Salvage CTAs on that popup (PC LEG-481/#492).

Gameserver routes remain; player reachability is split:

  1. Read the beacon (GET /api/v1/beacons/{id}/read) — costs 0 turns. The full message and author identity (player nickname or username) become visible. If read_once = true, the beacon row is deleted on this call. āœ… Reachable from the My Beacons dossier tab (MyBeaconsTab.tsx); not from the sector popup until #492.
  2. Salvage the beacon (POST /api/v1/beacons/{id}/salvage) — costs 1 turn. The beacon is removed; the salvager recovers 250 credits (50% of the deploy cost) but no equipment cargo (the equipment is destroyed with the beacon's casing). āœ… My Beacons tab; not sector popup.
  3. Ignore the beacon — it stays in place, visible to other arrivals.
  4. Recharge the beacon (POST /api/v1/beacons/{id}/recharge, 200 credits) — extends the charge cell by another 30 days. Presence in the sector is not strictly required: the owner may recharge remotely, or another player physically in the sector may top it up on the owner's behalf.

A player cannot edit a beacon after deployment. To change the message, they must salvage their own beacon and redeploy a new one (full deploy cost).

Lifecycle

Beacons exist until one of:

  • Salvaged — by any player (the deployer included). Removes the row.
  • Read with read_once = true — first read destroys it.
  • Charge cell lapses — each deploy starts a fixed 30-day charge cell; a beacon can be recharged (200 credits, extends by another 30 days, stacking indefinitely — 30d → 60d → 90d…) any time before it lapses. If the charge cell isn't recharged, the beacon survives a further 7-day grace period past the charge deadline, then auto-removes via the periodic beacon-expiry tick. The deployer is not notified; the beacon is just gone.
  • Sector destroyed / region terminated — CASCADE delete with the sector / region row.

Per-sector visibility cap (per ADR-0056 N-V2): 10 beacons visible per sector at any time. Once at cap, the next deployment auto-displaces the oldest beacon (FIFO); the displaced beacon is hard-deleted. Region operators may raise the cap up to 50 (MAX_SECTOR_CAP) for dense-traffic regions via Region.trade_bonuses['beacon_sector_cap'] (DECISION message-beacon-sector-cap-admin-route, 2026-08-07). āœ… Admin REST setter tip-shipped on feat/new-feature-development 46bce720 / PR #766 — GET/PATCH /api/v1/admin/regions/{id}/beacon-sector-cap (admin.py, clamp 1..50). 🚧 Admin UI editor Soft-HOLD until PR #764 / LEG-1149 / Fibril #1219 tip-lands (BeaconSectorCapEditor absent on tip). No player-facing route to set the cap. Default 10 closes the spam vector.

Schema

MessageBeacon

Source: services/gameserver/src/models/message_beacon.py (āœ… Shipped).

name type constraints notes
id UUID PK
region_id UUID FK regions.id not null, CASCADE Region containing the sector.
sector_id Integer not null The sector where the beacon sits; compound (region_id, sector_id) per the canonical sector identity.
deployer_player_id UUID FK players.id not null The author.
deployer_nickname_at_deploy String(50) not null Snapshot of the deployer's nickname at deploy time, so the message survives the deployer renaming or going inactive.
message String(500) not null The text. Up to 500 characters; multi-line allowed. Sanitized at deploy time.
expiry DateTime nullable REPURPOSED (WO-BEACON-LIFECYCLE) — the hard-delete deadline only, always charge_expires_at + GRACE_PERIOD once a beacon has ever been charged; the earlier "player-facing expiry-choice menu, NULL = never expires" behavior is retired. NULL only for a legacy pre-migration row (should not exist post-migration).
read_once Boolean default false If true, first read destroys the beacon.
read_count Integer default 0 How many times the beacon has been read. Updated atomically on each read; visible to the deployer in their beacon-management UI.
deployed_at DateTime not null Timestamp.
last_read_at DateTime nullable Updated on each read.

Indexes: - (region_id, sector_id) — the dominant query: "what beacons are in this sector?" - (deployer_player_id, deployed_at DESC) — deployer's beacon-management UI. - (expiry) partial WHERE expiry IS NOT NULL — the periodic expiry tick scans this.

Relationships: - region → Region (FK). - deployer → Player (FK).

Sector schema extension

Sector.message_beacons (āœ… Shipped) — JSONB array of beacon summaries denormalized for fast sector-view reads. Rebuilt from the live MessageBeacon rows on every deploy / salvage / read-once-read / expiry / sector-cap displacement, under a per-sector advisory lock. Shape:

[
  {
    "id": "<uuid>",
    "deployer_nickname": "<str>",
    "deployed_at": "<iso8601>",
    "preview": "<first 60 chars of message>",
    "expiry": "<iso8601 | null>"
  }
]

Players read the JSONB array for a quick sector-arrival summary; they fetch the full message body via the MessageBeacon row when they actually read.

Anti-griefing

Beacon text passes through the same content-policy / anti-abuse layer as ARIA-mediated player input (../../OPERATIONS/aria.md):

  • Length capped at 500 characters.
  • XSS defense is encode-at-output, not sanitize-at-storage: beacon text is stored raw (canonical, unescaped) and every consumer encodes for its output context — the player-client renders it through React's automatic escaping; any non-React or innerHTML-based consumer (a moderator tool, an admin view) must escape at render. The deploy endpoint does not HTML-sanitize the stored value. (Input-side, validate_input rejects prompt-injection / control-character obfuscation; the ASCII </> XSS delimiters are caught there and neutralized again at output.)
  • Profanity blocklist (configurable wordlist per region — Federation Zone is stricter; Frontier Zone permits saltier language).
  • Prompt-injection / jailbreak detection (since beacons can theoretically be read by a player whose ARIA is summarizing or translating; the AI-security service flags suspicious patterns).
  • A per-player rate limit: 5 beacon deploys per UTC day — prevents beacon-spam griefing without constraining genuine use.
  • Personal-rep gate (per ADR-0056 N-V2): placement requires personal_rep ≄ neutral (not Wanted, not deeply negative; threshold per ./ranking.md). Existing beacons by accounts that subsequently go negative remain visible until they expire or are displaced.
  • Multi-account discount (per ADR-0056 E-V5): free-tier accounts in a flagged cluster have beacon weight 0Ɨ — their beacons don't count toward the per-sector cap and aren't surfaced in the sector-view list. Paid-tier accounts unaffected. Detection lives in ../../OPERATIONS/multi-account-detection.md.
  • āœ… Shipped — a per-player trust score read (Player.aria_trust_score, ARIA trust model) that auto-flags very-low-trust beacons for moderator review before becoming player-visible (deploy() sets flagged when trust is below TRUST_AUTOFLAG_THRESHOLD 0.2 [NO-CANON], 34d5cd7a). Flagged cells are excluded from sector denorm until admin clear_flag.

āœ… Shipped. Reports against beacons (POST report endpoint) flag the beacon for moderation; a flagged beacon is hidden from the sector-view list and read endpoint (anti-oracle 404, not a leaked "flagged" state) via a flagged bool + hide denorm/read path in message_beacon_service.py. Admin clear/review (clear_flag, list_flagged_beacons) is also shipped, giving admins a reversible unflag path. Trust-score auto-flag on deploy is also āœ… shipped (above). āœ… Shipped — admin confirm_abuse docks the deployer's aria_trust_score by TRUST_DOCK_CONFIRMED_ABUSE 0.1 [NO-CANON — same class as ARIA rate-limit / inappropriate-content default], increments aria_violation_count, and removes the beacon row (POST /admin/beacons/{id}/confirm-abuse). Does not auto time-ban / suspend — that remains Max-gated.

Cross-region behavior

A beacon is regional. Its region_id ties it to one region; it cannot be discovered from another region even if a player traverses through the Nexus. A player who wants to communicate cross-region uses the persistent messaging system (see ./messaging.md — 🚧 Partial overall, with send / inbox / conversations / mark-read / soft-delete shipped) or just leaves duplicate beacons in multiple regions.

When a region is terminated (per the subscription-lapse → 30-day flow in ../../OPERATIONS/monetization.md), all beacons in the region are deleted via CASCADE. Deployers are not migrated or refunded — beacons in a dying region are part of the history that goes down with it.

Player UX

āœ… My Beacons dossier (1bc7540d, MyBeaconsTab.tsx) — deploy / read / salvage / recharge / report as listed below. 🚧 Sector-view popup is preview-only on origin/feat 46bce720 (windshieldTableauPopupContent.tsx beacon case) — no Read/Salvage CTAs (PC LEG-481/#492).

āœ… Shipped (1bc7540d) — sector-map beacon presence via BeaconLayer (windshieldTableauChrome.tsx / WindshieldTableau.tsx, always-visible per canon, not scan-gated).

  • Sector view shows beacon presence. A small icon (per the player-client UI per ../../OPERATIONS/player-client.md) indicates "N beacons here" with the count.
  • Click the icon to expand a preview — sender, deploy time, meta.preview. 🚧 Not a Read/Salvage surface on tip.
  • āœ… My Beacons tab lists beacons the player has deployed, with read / salvage / recharge / report actions (same-sector required for read/salvage/report).
  • āœ… Deploy from the My Beacons dossier tab — costs 5 turns + 500 credits + 1 equipment (current sector).
  • āœ… Read / Salvage from that tab — Read costs 0 turns (full message); Salvage costs 1 turn, refunds 250 credits.

Still šŸ“ design-only (unchanged): auto time-ban / suspension on confirmed abuse — see Status.

Failure modes

Mode Detection Recovery
Beacon-spam griefing Per-player rate limit + per-sector cap Reject with rate-limit error
Profanity / abuse Content-policy filter at deploy (shipped); report flow at read (āœ… shipped); admin confirm-abuse (āœ… shipped) Auto-reject deploy; flag-and-hide on report; admin clear-flag reverses a false report; confirm-abuse docks deployer trust + removes beacon
Beacon survives sector deletion CASCADE FK Beacon is deleted automatically
Deployer goes inactive deployer_nickname_at_deploy snapshot Beacon survives; nickname display is the snapshot, not a live lookup
Sector.message_beacons JSONB drifts from MessageBeacon rows Periodic invariant check Reconcile from rows; rebuild JSONB

Player-facing affordances

  • āœ… My Beacons dossier — MyBeaconsTab.tsx Read / Salvage / Deploy / Recharge / Report (StatusBar beacons tab; origin/feat 46bce720).
  • āœ… Sector-map beacon presence — BeaconLayer in windshieldTableauChrome.tsx, mounted from WindshieldTableau.tsx; always visible, not scan-gated.
  • 🚧 Sector popup preview-only — windshieldTableauPopupContent.tsx case 'beacon' shows title, deployer, preview quote, date; no Read/Salvage CTAs until PC LEG-481/#492 lands.
  • šŸ“ Auto time-ban on confirmed abuse — admin confirm_abuse docks trust and removes beacon; no auto suspend/time-ban (Max-gated).

Status

🚧 Partial. The gameserver kernel is āœ… shipped: MessageBeacon model, message_beacon_service.py (deploy / read / salvage / expiry sweep), routes/beacons.py, the Sector.message_beacons JSONB denorm, the read-once flag, the per-sector FIFO cap, and every anti-griefing gate described above — rate limit, personal-rep gate, multi-account discount, encode-at-output XSS defense, and trust-score auto-flag on deploy (34d5cd7a). Report + admin-clear are also shipped (player-facing report endpoint, flag-and-hide, admin clear_flag/list_flagged_beacons). Admin confirm-abuse deployer consequences also shipped (confirm_abuse: trust dock + violation bump + row delete; no auto-ban). āœ… Admin region beacon-sector-cap REST tip-shipped on 46bce720 / PR #766 (GET/PATCH /api/v1/admin/regions/{id}/beacon-sector-cap). 🚧 Admin UI BeaconSectorCapEditor Soft-HOLD until PR #764 / LEG-1149 / Fibril #1219 tip-lands (grep empty on tip admin-ui). Player-client split on origin/feat 46bce720: āœ… My Beacons dossier (MyBeaconsTab.tsx, 1bc7540d) wires Read/Salvage/Deploy; āœ… sector-map presence via BeaconLayer (windshieldTableauChrome.tsx, always-visible per canon, not scan-gated); 🚧 sector-view popup (windshieldTableauPopupContent.tsx case 'beacon') is preview-only — no Read/Salvage CTAs (PC LEG-481/#492). Still šŸ“ design-only: auto time-ban / suspension on confirmed abuse (Max-gated). The anti-griefing layer reuses the existing ARIA content-policy filters; the realtime broadcast reuses the existing bus rooms. (Re-verified 2026-08-21 vs Sectorwars2102 46bce720 — LEG-1126 admin REST honesty; Admin UI Soft-HOLD.)

Cross-references