Skip to content

Message Beacons

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 expiry timer (24 hours, 7 days, 30 days, never — player chooses). Default: never.
  • An optional read-once flag (📐 Design-only). When set, the first reader's act of reading destroys the beacon. Useful for treasure hints and dead-drops. Default: false.

Deployment commits via POST /api/v1/beacons/deploy with {sector_id, message, expiry, read_once}. 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 (📐 Design-only column on Sector). 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). The player can:

  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.
  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).
  3. Ignore the beacon — it stays in place, visible to other arrivals.

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.
  • Expiry timer fires — after the configured TTL (24h / 7d / 30d) the beacon 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 if their region has a known dense-traffic context, but the default closes the spam vector.

Schema

MessageBeacon

Source: services/gameserver/src/models/message_beacon.py (📐 Design-only).

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 When the beacon auto-removes. NULL means "never expires."
read_once Boolean default false If true, first read destroys the beacon. 📐 Design-only — initial launch may ship with read_once = false only.
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: - regionRegion (FK). - deployerPlayer (FK).

Sector schema extension

Sector.message_beacons (📐 Design-only column) — JSONB array of beacon summaries denormalized for fast sector-view reads. Updated on deploy / salvage / expiry. 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 sanitization (DOMPurify-equivalent at the deploy endpoint).
  • 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 (📐 Design-only) — 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.
  • A per-player trust score read (per the ARIA trust model) — players with very low trust have their beacons auto-flagged for moderator review before becoming player-visible.

Reports against beacons (the player-facing "report" UI on a beacon read view) flag the beacon for moderation. A flagged beacon is hidden from view pending moderator action; the deployer can be warned, time-banned, or have their trust score docked depending on the violation.

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 (📐 Design-only direct messaging) 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

  • 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 the list — sender, deploy time, preview.
  • Click a beacon to read the full message. Costs 0 turns.
  • Salvage button on each beacon — costs 1 turn, refunds 250 credits.
  • Deploy beacon button in the sector toolbar — costs 5 turns + 500 credits + 1 equipment.
  • My Beacons screen shows all beacons the player has deployed across the universe, with read counts and salvage / expire links.

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 + report flow at read Auto-reject deploy; flag-and-hide on report
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

Status

📐 Design-only. No MessageBeacon table, no deploy endpoint, no Sector.message_beacons column, no read-once flag exists today. The feature is fully spec'd here and ready for implementation. The anti-griefing layer reuses the existing ARIA content-policy filters; the realtime broadcast reuses the existing bus rooms.

Cross-references