diff --git a/README.md b/README.md
index 7bf9b32..83aca88 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,8 @@ Players only see a short card of the other players, never their sheets. Guessing
- **DM notes**: NPC tokens and props have a "Notes (only you see these)" field (map editor and table; e.g. the contents of a chest). On the table click the token to edit; a 📝 marks tokens with notes and the text shows as tooltip. Players never receive notes.
- **Fog without walls**: sight range and fog also work on maps without walls (circle around the token). Sight needs walls or a range above 0; the explored memory is now stored at up to 1024 px.
+- **Map ping**: everybody (players and DM) can point at a spot: double-click the map, or press *📍 Ping* and click. All see a ring in the pinger's colour with their name, and hear a short ping (rate limited to one per second and person). The DM can switch the sound off or upload an MP3 for it under *Lobby actions → Sounds → Map ping*; players can still mute sounds for themselves.
+
## API
All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie.
@@ -199,6 +201,7 @@ All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/l
| PUT | `/api/lobbies/{id}/doors/{wall}` , `/sight` | DM: open / close a door; line of sight on / off |
| GET / POST | `/api/lobbies/{id}/scenes` | DM: saved scenes / save a copy of the table (`name`) |
| PUT / DELETE | `/api/lobbies/{id}/scenes/{sid}` | DM: rename (`name`), save now (`save`) / delete |
+| POST | `/api/lobbies/{id}/ping` | Ping a spot (`x`, `y`); everybody gets a push |
| PUT | `/api/lobbies/{id}/scene` | DM: switch to a scene (`scene_id`); the current one is saved first |
| GET / POST / DELETE | `/api/props[/{id}]` | Your own prop images / upload (raw body, `?name=`) / delete |
| GET | `/api/props/{id}/image`, `/api/lobbies/{id}/prop-image/{pid}` | Prop image (owner / lobby members, DM's props) |
diff --git a/backend/app/maps.py b/backend/app/maps.py
index 610c7cc..90f116a 100644
--- a/backend/app/maps.py
+++ b/backend/app/maps.py
@@ -774,6 +774,29 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
hub.notify(lid, "map")
return {"ok": True}
+ pinged: dict = {}
+
+ @api.post("/lobbies/{lid}/ping")
+ def ping(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
+ """Anybody at the table can point at a spot of the map; everybody sees a ring there (and hears a ping)."""
+ with db() as con:
+ lobby = lobby_for(con, lid, user)
+ m = need_map(con, lobby)
+ try:
+ x, y = float(body.get("x")), float(body.get("y"))
+ except (TypeError, ValueError):
+ raise HTTPException(422, "Invalid position")
+ if not (0 <= x <= m["w"] and 0 <= y <= m["h"]):
+ raise HTTPException(422, "Outside of the map")
+ now = time.time()
+ if now - pinged.get((lid, user["id"]), 0) < 0.8:
+ raise HTTPException(429, "Slow down")
+ pinged[(lid, user["id"])] = now
+ if len(pinged) > 2000:
+ pinged.clear()
+ hub.notify(lid, "ping", delay=0, x=round(x, 1), y=round(y, 1), user=user["username"], dm=lobby["owner_id"] == user["id"])
+ return {"ok": True}
+
@api.put("/lobbies/{lid}/sight")
def set_sight(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""DM: switch line of sight on or off for the table (off: players see the whole map)."""
diff --git a/backend/app/sounds.py b/backend/app/sounds.py
index 0dfa870..c5907c2 100644
--- a/backend/app/sounds.py
+++ b/backend/app/sounds.py
@@ -6,8 +6,8 @@ from fastapi import Body, Depends, HTTPException, Request
from fastapi.responses import Response
MAX_SOUND = 1_500_000 # bytes per file
-SLOTS = ("roll", "crit_success", "crit_failure")
-FLAGS = ("result", "crit_success", "crit_failure") # result = the short tones for success / failure of a check
+SLOTS = ("roll", "crit_success", "crit_failure", "ping")
+FLAGS = ("result", "crit_success", "crit_failure", "ping") # result = the short tones for success / failure of a check
def schema(con):
@@ -20,7 +20,7 @@ def settings(raw) -> dict:
s = json.loads(raw or "{}")
except ValueError:
s = {}
- return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True),
+ return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True), "ping": s.get("ping", True),
"custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))}
diff --git a/frontend/src/Lobbies.jsx b/frontend/src/Lobbies.jsx
index 4e7f3dc..3df639b 100644
--- a/frontend/src/Lobbies.jsx
+++ b/frontend/src/Lobbies.jsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
-import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
+import { sound, playRoll, playPing, preload, DEFAULT_ROLL } from './sound.js'
import { MapView, MapLibrary, NpcPicker, PropForm, PropPicker, RadialMenu, RevealLayer, StatusForm, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
@@ -608,7 +608,7 @@ function SoundSettings({ lobby, id, act, sfx }) {