Skip to content

Feature Request: Visitor-to-Visitor Proximity Webhooks #121

@liebeskind

Description

@liebeskind

Feature Request: Visitor-to-Visitor Proximity Webhooks

Summary

There is currently no SDK mechanism to detect when two visitor avatars come within a configurable distance of each other without polling. In Lunch Swap (a social trading game for ages 7-17), players need to be near each other to trade food items at trading outposts. The only way to achieve this today is:

  1. Call worldActivity.currentVisitors() to fetch all visitor positions.
  2. Manually compute Euclidean distances between every pair of visitors.
  3. Poll this from every connected client every 2-3 seconds.

The cost is severe:

Metric Current (Polling) With Proximity Webhooks
API calls at 50 visitors ~25/sec (each client polls every 2-3s) 0 (event-driven)
Latency to detect proximity 2-3 seconds (poll interval) Under 200ms (server push)
Client complexity Distance calculation + poll loop + dedup Single webhook handler
Scalability O(n) API calls where n = connected clients O(1) server-side detection
User experience Sluggish -- kids aged 7-17 notice the delay and disengage Instant -- trades feel responsive and magical
Server load Grows linearly with player count Constant overhead per world

This feature would unlock an entire category of social and cooperative gameplay that the SDK currently makes impractical: trading, team formation, cooperative puzzles, hide-and-seek, proximity chat, NPC group interactions, and more.

Proposed API

World-Level Proximity Webhook Configuration

New methods on the World class:

world.setProximityWebhook(config: ProximityWebhookConfig): Promise<void>
world.fetchProximityWebhook(): Promise<ProximityWebhookConfig | null>
world.removeProximityWebhook(): Promise<void>

TypeScript Interfaces

interface ProximityWebhookConfig {
  /** URL to receive proximity events via POST */
  url: string;
  /** Distance in world units that triggers the event (default: 100) */
  radius: number;
  /** Only track visitors who have the interactive app open (default: true) */
  interactiveOnly?: boolean;
  /** Minimum ms between repeated events for the same pair (default: 5000) */
  cooldownMs?: number;
  /** Minimum ms a pair must remain within radius before firing (default: 0) */
  dwellMs?: number;
  /** Optional: restrict detection to visitors inside a specific zone asset */
  zoneAssetId?: string;
  /** Optional human-readable label */
  title?: string;
}

interface ProximityWebhookPayload {
  event: "proximity:enter" | "proximity:exit";
  timestamp: string;
  urlSlug: string;
  visitorA: {
    visitorId: number;
    profileId: string;
    displayName: string;
    position: { x: number; y: number };
  };
  visitorB: {
    visitorId: number;
    profileId: string;
    displayName: string;
    position: { x: number; y: number };
  };
  distance: number;
  radius: number;
}

interface ProximityWebhookHeaders {
  "x-topia-signature": string;
  "x-topia-public-key": string;
  "x-topia-event": "proximity:enter" | "proximity:exit";
}

Usage Pattern

Before: Polling Workaround (Current)

Every connected client polls the server, which fetches all visitors and calculates distances:

// SERVER: controllers/handleGetNearbyPlayers.ts
export const handleGetNearbyPlayers = async (req: Request, res: Response) => {
  try {
    const credentials = getCredentials(req.query);
    const { urlSlug, visitorId } = credentials;

    const visitor = await Visitor.get(visitorId, urlSlug, { credentials });
    const myX = (visitor as any).moveTo?.x ?? 0;
    const myY = (visitor as any).moveTo?.y ?? 0;

    // Fetch ALL visitors in the world -- expensive!
    const worldActivity = await WorldActivity.create(urlSlug, { credentials });
    const allVisitors = await worldActivity.currentVisitors();

    const TRADE_RADIUS = 150;
    const nearbyPlayers = [];

    for (const [id, v] of Object.entries(allVisitors)) {
      if (Number(id) === visitorId) continue;
      const vx = (v as any).moveTo?.x ?? 0;
      const vy = (v as any).moveTo?.y ?? 0;
      const distance = Math.sqrt((myX - vx) ** 2 + (myY - vy) ** 2);

      if (distance <= TRADE_RADIUS) {
        nearbyPlayers.push({
          visitorId: Number(id),
          displayName: (v as any).displayName,
          distance: Math.round(distance),
        });
      }
    }

    return res.json({ success: true, nearbyPlayers });
  } catch (error) {
    return errorHandler({ error, functionName: "handleGetNearbyPlayers", message: "Error finding nearby players", req, res });
  }
};

// CLIENT: every client polls every 2.5s
useEffect(() => {
  const poll = async () => {
    const { data } = await backendAPI.get("/api/nearby-players");
    if (data.success) setNearbyPlayers(data.nearbyPlayers);
  };
  poll();
  const interval = setInterval(poll, 2500);
  return () => clearInterval(interval);
}, []);

Problems: ~25 API calls/sec at 50 visitors, 2.5s latency, no exit detection, O(n^2) distance calcs.

After: Proximity Webhook (Proposed)

One-time setup + lean event handler. Zero polling.

// SERVER: one-time setup during app init
export async function configureProximityWebhook(urlSlug: string, credentials: any) {
  const world = World.create(urlSlug, { credentials });
  await world.setProximityWebhook({
    url: `${process.env.APP_URL}/api/webhook/proximity`,
    radius: 150,
    interactiveOnly: true,
    cooldownMs: 5000,
    dwellMs: 1000,
    title: "Lunch Swap Trade Detection",
  });
}

// SERVER: webhook handler -- receives events pushed from platform
export const handleProximityWebhook = async (req: Request, res: Response) => {
  try {
    // Verify HMAC signature
    const signature = req.headers["x-topia-signature"] as string;
    const expected = crypto
      .createHmac("sha256", process.env.INTERACTIVE_SECRET!)
      .update(JSON.stringify(req.body))
      .digest("hex");
    if (signature !== expected) return res.status(401).json({ success: false });

    const { event, visitorA, visitorB } = req.body;
    if (event === "proximity:enter") { /* Enable trading UI */ }
    if (event === "proximity:exit") { /* Disable trading UI */ }

    return res.json({ success: true });
  } catch (error) {
    return errorHandler({ error, functionName: "handleProximityWebhook", message: "Error handling proximity event", req, res });
  }
};

// CLIENT: No polling code needed!

Security Model

  • Admin-only configuration: setProximityWebhook() requires apiKey credentials. Interactive-only credentials cannot configure webhooks.
  • HMAC-SHA256 signature: Every POST includes X-Topia-Signature header signed with interactiveSecret.
  • Per-world scoping: Each webhook is scoped to a single world.
  • Rate limiting: Cooldown per pair (min 1000ms, default 5000ms). Max 100 proximity events/min/world.
  • Privacy: interactiveOnly defaults to true -- only visitors with the app iframe open are tracked. Critical for a platform used by minors (ages 7-17).
  • Auto-cleanup: 10 consecutive 5xx responses disables the webhook.

Implementation Suggestion

REST Endpoints

POST   /api/v1/world/:urlSlug/webhooks/proximity   - Create/update config
GET    /api/v1/world/:urlSlug/webhooks/proximity   - Fetch current config
DELETE /api/v1/world/:urlSlug/webhooks/proximity   - Remove webhook

Server-Side Detection (Platform)

The Topia platform already tracks visitor positions in real-time for rendering. Suggested approach:

  1. Spatial hashing: Divide world into cells of size radius. Check same + adjacent cells only. O(n) vs O(n^2).
  2. Delta-based: Only re-evaluate on position change events, not fixed timer.
  3. Pair state tracking: In-memory map of active pairs for enter/exit transitions + cooldown enforcement.
  4. Dwell timer: Fire proximity:enter only after pair remains within radius for dwellMs.
  5. Zone filtering: If zoneAssetId set, pre-filter to zone visitors first.

Additional Use Cases

Use Case How It Uses Proximity Webhooks
Trading Detect when two players are near a trading outpost together. Enable trade UI.
Cooperative Puzzles Require 2+ players near each other to activate a puzzle element.
Hide and Seek Seeker enters proximity of hiding player -- trigger "found" event.
NPC Group Interactions 3+ players gather near NPC, trigger group story event or boss fight.
Team Formation Walk up to another player to form a team. proximity:enter triggers join prompt.
Social Games (tag) "It" player enters proximity -- trigger tag. Sub-200ms latency feels instant.
Guided Tours Tour guide walks through world. Nearby visitors get contextual info.
Achievements / Badges "Meet 10 different players" badge tracked via unique profileId pairs.

Priority Justification

Social interaction is the core value proposition of a metaverse platform. Yet the SDK currently has no efficient primitive for the most fundamental social interaction: two players being near each other. Every app that needs player-to-player awareness must independently build the same inefficient polling workaround. This is a platform-level capability gap.

For Lunch Swap specifically, the 2-3 second polling latency makes trading feel broken for the target audience (ages 7-17). Kids expect instant feedback.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions