-
Notifications
You must be signed in to change notification settings - Fork 1
Description
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:
- Call
worldActivity.currentVisitors()to fetch all visitor positions. - Manually compute Euclidean distances between every pair of visitors.
- 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()requiresapiKeycredentials. Interactive-only credentials cannot configure webhooks. - HMAC-SHA256 signature: Every POST includes
X-Topia-Signatureheader signed withinteractiveSecret. - 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:
interactiveOnlydefaults totrue-- 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:
- Spatial hashing: Divide world into cells of size
radius. Check same + adjacent cells only. O(n) vs O(n^2). - Delta-based: Only re-evaluate on position change events, not fixed timer.
- Pair state tracking: In-memory map of active pairs for enter/exit transitions + cooldown enforcement.
- Dwell timer: Fire
proximity:enteronly after pair remains within radius fordwellMs. - Zone filtering: If
zoneAssetIdset, 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.