Геогессер по спутниковым снимкам. Игроку показывают участок снимка — без подписей и без координат — и он ищет это место на карте мира. Чем ближе поставленная точка к центру участка, тем больше очков.
Сервер авторитетен: до принятой догадки клиент не получает координаты цели ни в одном ответе API. Снимок приходит через прокси по локальным координатам тайлов, так что подсмотреть ответ в DevTools нельзя.
Область раунда — это один тайл Web Mercator и его потомки на четыре уровня вглубь. Цель раунда — центр этого тайла.
Места берутся из каталога backend/scripts/seed.py — почти три сотни областей
в сотне стран. Уровень партии выбирает, что из него достанется:
| Уровень | Что показывают | Мест |
|---|---|---|
| Легко | узнают по силуэту: Париж, Венеция, Манхэттен | 45 |
| Средне | крупные города знакомых стран: Гамбург, Казань, Сиэтл | 107 |
| Сложно | города, о которых знают мало, и обжитая местность | 82 |
| Хардкор | дикая природа: горы, пустыни, тайга, лёд | 40 |
Точка раунда берётся внутри зоны случайно, но не любая: в кадре обязана быть суша. Приморская зона наполовину состоит из моря — Александрия это десять километров города и столько же Средиземного, — и без проверки каждый шестой раунд по ней в узком кадре показывал ровную синеву, по которой не угадать ничего. Сушей считаются границы стран, те же, по которым работает режим стран: море внутрь границ не входит, поэтому отдельной карты суши не нужно.
Проверяется именно кадр, а не сама точка: чем шире вид, тем дальше может
стоять берег, оставаясь видимым. Иначе из игры выпала бы Венеция, которая
целиком стоит в лагуне, — а в её кадре берег виден со всех сторон. Насколько
каталог согласен с этим правилом, показывает scripts/check_zones.py.
Уровень записан у самой зоны — полем tier в каталоге и в базе. Из категории
он не выводится: Гамбург и Сурабая оба city, но первый называют с ходу, а
второй не назовёт почти никто. Пока уровень выводился из категории, «средне»
было четырьмя пятыми каталога и означало «всё, кроме дикой природы».
Уровни не вложены друг в друга: выбрав «средне», Манхэттен не получишь — он на «легко». Каждое место размечено ровно одним уровнем, и это проверяется тестом, как и то, что ни один уровень не съедает половину каталога.
Радиус городской зоны ограничен пятью километрами. Точка раунда берётся из квадрата вокруг центра и в углу отходит от него на семь километров — при виде в пятнадцать километров центр места остаётся в кадре. При десяти километрах радиуса он уходил за край, и «Париж» показывал безымянный пригород. У диких зон разброс больше — это часть замысла. Оба правила проверяются тестами.
Клиент запрашивает тайлы по локальной сетке:
GET /api/rounds/{id}/tiles/{z}/{x}/{y}.jpg, где z — уровень от 0 до
максимального, а x и y — номера внутри уровня. Сервер переводит их в
координаты тайлового сервера, забирает снимок и кладёт в Redis. Запрос за
пределы области возвращает 404, чужой раунд — 403.
Расстояние и очки считает только сервер, единственной формулой из
app/services/scoring.py.
В любой момент раунда можно взять подсказку. Она раскрывает самое широкое из того, чего игрок ещё не знает из условий партии: по всему миру — часть света, внутри выбранной части света — страну, внутри одной страны — регион. Подсказка, повторяющая условия партии, ничего не добавляет, и такую сервер не выдаёт вовсе. Стоит она трети от максимума раунда, и цену называет сервер: клиент показывает то число, которое пришло в ответе, а не считает его сам. Координат в подсказке нет — правило «до конца раунда клиент не может вычислить цель» она не нарушает.
Частота запросов ограничена: вход и регистрация — по адресу клиента, старт
партии и тайлы — по игроку. Незавершённая партия у игрока может быть только
одна: начиная новую, он бросает предыдущую. Партии, брошенные посреди игры,
закрывает scripts/cleanup.py — его стоит поставить в расписание.
Зачёт делится по условиям игры: отдельно хардкор, отдельно лёгкий уровень, отдельно Россия и США. Общая таблица складывала бы несравнимое — партия в тайге и партия по Парижу стоят разного труда, а очки у них одни и те же.
Считается по завершённым партиям, а не по итогам в профиле: профиль знает сумму за всё время, и разделить её обратно по условиям нельзя. Поэтому условия набора запоминаются вместе с серией раундов.
«Лучшая партия» меряется очками за раунд, а не суммой: иначе партия из десяти раундов всегда обходила бы партию из трёх, и зачёт превращался бы в соревнование по длине.
Раз в сутки всем игрокам достаётся один и тот же набор раундов. Партия засчитывается, только если доиграна до конца, — за брошенные серия не растёт.
Серия считается по дням, в которые челлендж доигран, и остаётся живой, пока её можно продолжить: сыграл вчера и ещё не садился сегодня — впереди целый день. Пропуск обрывает счёт. Хранится это нигде — считается запросом по датам партий, чтобы счётчик не разошёлся с правдой.
Партию можно ограничить по времени: 30 секунд, минута или две на раунд. Срок назначает сервер и он же проверяет опоздание — часы игрока к делу не относятся. Ответ после срока закрывает раунд нулём, но партия продолжается. Скорость не добавляет очков сверх максимума, а медлительность их отнимает: на последней секунде остаётся 80% от заработанного, иначе результаты партий с таймером и без были бы несравнимы.
Каждые сутки собирается одна серия из пяти раундов, одинаковая для всех игроков. Серия создаётся при первом обращении за день и дальше не меняется: иначе двое, начавшие игру в разное время, играли бы в разное. Границы суток считаются в UTC, так что день сменяется у всех одновременно. Сыграть челлендж можно один раз — это гарантирует уникальный индекс, а не только проверка в коде.
Комната — это та же серия раундов, но собранная не на сутки, а по просьбе игрока. Хост выбирает условия, получает шестизначный код и раздаёт ссылку; все, кто вошёл, играют одни и те же раунды каждый в своём темпе и сравнивают результаты в общей таблице.
Живого обмена ходами нет намеренно: он потребовал бы соединения, которое рвётся вместе с вкладкой, а сравнивать результаты можно и после. Войти в комнату дважды нельзя — иначе серию можно было бы переигрывать, пока не выпадет удачный счёт; это гарантирует частичный уникальный индекс. Хост может закрыть набор, чтобы опоздавшие не портили таблицу.
Игроки добавляют друг друга по короткому коду, а не по имени: имена не уникальны, и поиск по чужому имени — способ найти не того человека, а заодно собрать чужие имена. Свой код игрок показывает сам, кому захочет; алфавит у него тот же, что у кода комнаты, — без символов, которые путаются на слух.
Дружба хранится одной строкой на пару, а не двумя встречными: две пришлось бы держать согласованными, а «дружба есть у одного и нет у другого» — состояние, которого быть не должно. Встречная заявка не заводит вторую связь, а подтверждает первую: двое, позвавшие друг друга, уже договорились.
Чужого идентификатора в списке друзей нет — только идентификатор самой связи, по которому её принимают и убирают.
В таблице лидеров у друзей свой зачёт: тот же расчёт, но по короткому списку игроков. Сама таблица про дружбу ничего не знает — список ей приносят готовым.
По умолчанию аватарка — не файл, а два числа: форма узора и цвет. Узор по ним рисует клиент, поэтому такая аватарка есть у каждого с первой минуты, её не нужно выбирать и хранить для неё нечего. Числа приезжают в каждом ответе, где виден игрок, а не выводятся клиентом из идентификатора: в таблице комнаты чужих идентификаторов нет и не будет. Новому игроку аватарка достаётся по его идентификатору — у зарегистрированных подряд она разная.
Кто хочет своё лицо, загружает картинку. Файл не сохраняется как пришёл: он обрезается по центру в квадрат, приводится к 256 пикселям и перекодируется в WebP. Перекодирование здесь не про вес — оно оставляет от файла одни пиксели. Ни EXIF с координатами съёмки, ни постороннего содержимого, спрятанного за картинкой, в базу не попадает.
Картинка лежит в отдельной таблице, а не колонкой в users: профиль читается
на каждый запрос и в каждой строке таблицы лидеров, а тащить за ним килобайты
незачем. В базе, а не файлом на диске, — тогда её забирает тот же pg_dump,
что и всё остальное, а удаление учётной записи уносит её каскадом, ничего не
оставляя на диске.
Адрес картинки несёт версию и меняется при каждой замене: иначе браузер
показывал бы прежнюю неделю. Забирает её клиент авторизованным запросом, как и
тайлы снимка, — тег img не умеет отправлять токен, а открывать аватарки без
авторизации значило бы отдать их наружу перебором идентификаторов. Сторонних
доменов при этом по-прежнему нет: картинка приходит со своего, как того и
требует строгая CSP.
Загруженную аватарку видно другим игрокам, то есть это публикация. Что за неё отвечает загрузивший и что именно загружать нельзя, написано в условиях использования; кнопка «Убрать» возвращает узор.
Тот же снимок и та же карта, но игрок выбирает не точку, а страну: на карте догадки лежат контуры, страна под курсором подсвечивается, нажатие её выбирает. Серверу уходит код страны, он же сверяет его с правильным — по геометрии ничего не пересчитывается, поэтому разойтись клиенту и серверу не в чем.
Контуры для карты отдаются отдельным упрощённым набором: полмегабайта вместо двух, кэш на сутки и ETag. Ответа они не выдают — на карте лежат границы всех стран сразу. Полные границы остаются в базе, и по ним PostGIS считает, в какой стране находится место со снимка.
Очки другие, потому что вопрос другой. Угадал страну — весь максимум, как бы далеко от её центра ни находилось место. Не угадал — не больше половины, и тем меньше, чем дальше названная страна от места на снимке: назвать соседнюю страну и назвать другое полушарие — разные ошибки.
Место в условиях партии в этом режиме не выбирается: «Россия» рядом с «ответ страной» — это готовый ответ на все раунды. Интерфейс такой выбор не показывает, а сервер отклоняет его и в запросе, собранном руками.
Страна цели считается один раз, при сборке серии: у всех, кто играет одну серию, правильный ответ обязан быть один и тот же.
Выбирается режим там же, где остальные условия партии, — «Чем отвечать» в настройках одиночной; комната собирается по ним же. В результате раунда вместо промаха и точности стоят две страны: правильная и та, в которую попал игрок. Отвечать промахом в километрах на вопрос про страну значило бы объяснять очки не тем, за что их дали.
Границы из OpenStreetMap (ODbL) через simonepri/geo-maps, версия закреплена
и загружается scripts/load_countries.py. Разрешение — пять километров: 236
стран и два мегабайта.
На этом наборе 271 зона каталога из 274 получает страну, совпадающую с записанной руками. Остальные три в режим стран не попадают: Монако мельче разрешения — страны такого размера в источнике нет вовсе, — а у Игуасу и Иерусалима центр зоны ложится по другую сторону границы, чем в каталоге. Расхождения отсеиваются при сборке серии, до игрока они не доходят.
Километровый набор — 248 стран и двадцать два мегабайта — при замере на каталоге из 277 зон добавлял ровно одну годную. Одна зона не стоит одиннадцатикратного веса, который качается на каждом развёртывании.
За текстом лежит координатная сетка, а по ней ходит прицел и называет широту и долготу точки, на которую наведён. Числа настоящие: меридианы и параллели стоят там, где им положено в проекции Меркатора — той самой, в которой в игре показана карта мира, — и подпись под перекрестием с ними сходится. Отсюда и неравные промежутки между параллелями: к полюсу они расходятся. Сетка, у которой линии ничего не значат, была бы просто решёткой на фоне.
Мышью прицел ведут курсором, пальцем — касанием. Положение пишется прямо в
стиль узла из одного кадра requestAnimationFrame: перерисовывать React на
каждое движение мыши — это десятки кадров в секунду впустую.
Арифметика проекции лежит в frontend/src/domain/graticule.ts и проверяется
тестами: такое сверяют вычислением, а не скриншотом.
Тем две — тёмная и светлая, плюс «как в системе». Выбор хранится у игрока в
базе, а не в браузере: он должен пережить и очистку хранилища, и переход на
другое устройство. Копия лежит в localStorage и нужна ровно затем, чтобы
знать тему до ответа сервера.
Ставит её public/theme.js — отдельный файл, а не строка в index.html:
строгая CSP запрещает встроенные скрипты, а тема должна встать до первой
отрисовки, иначе светлая начинается с тёмной вспышки. В разметку попадает уже
разрешённое значение, dark или light, поэтому светлая палитра описана в
tokens.css один раз.
Светлая — тот же прибор при дневном свете: то же зелёно-серое семейство нейтральных и тот же янтарь, взятый глубже. Ярким янтарём на белом нельзя писать текст, а очки, рейтинг и код комнаты им написаны.
Дуэль — это комната на двоих, которую собрал сервер. Игрок встаёт в очередь, и когда рядом находится соперник с близким рейтингом, обоим приходит код той же самой комнаты: серия раундов общая, партии считаются как обычно, таблица результатов та же.
Формат дуэли фиксирован — пять раундов, средний уровень, весь мир, минута на раунд. Это не упрощение, а условие: рейтинг сравнивает игроков между собой и работает, только пока все дуэли устроены одинаково.
Рейтинг — Эло, старт 1000. Считается только по дуэлям, потому что там оба играют одно и то же и условия партии сокращаются. По обычным партиям его вывести нельзя: средний промах на лёгком уровне по Европе в десятки раз меньше, чем на хардкоре по всему миру, и рейтинг из него поднимал бы наверх того, кто выбрал условия помягче. Первые десять дуэлей двигают рейтинг вдвое сильнее, чтобы новичок быстро попал на своё место. Считается исход, а не разрыв в очках: один удачный раунд качает разрыв сильно, а победу — нет.
Очередь живёт в Redis, потому что воркеров четыре и очередь в памяти процесса была бы у каждого своя. Живого соединения нет: клиент опрашивает сервер раз в несколько секунд, и тот же запрос продлевает запись в очереди — закрыл вкладку и выпал, а счётчик ищущих остался честным. Полоса поиска расширяется с ожиданием: сначала ±50 очков рейтинга, дальше шире, через две минуты — с кем угодно. Пара подбирается под коротким замком в Redis, иначе два воркера свели бы одного игрока дважды.
Недоигранная дуэль через десять минут отдаётся тому, кто дошёл до конца:
иначе рейтинг чинился бы закрытием вкладки. Обычно рейтинг начисляет тот, кто
закончил последним; если не вернулся никто, дуэль добирает scripts/cleanup.py.
Отметка rated_at и блокировка строки не дают начислить рейтинг дважды.
Игрок пишет из раздела «Профиль»: выбирает, впечатление это или проблема, и оставляет текст. Два вида, а не свободная тема, — впечатление читают, когда есть время, а проблему когда её чинят, и разбирать одно от другого глазами по тексту значит однажды пропустить вторую.
Отзывы лежат в базе и читаются командой:
docker compose exec backend python scripts/feedback.py
docker compose exec backend python scripts/feedback.py --kind problem --limit 50Экрана администратора нет намеренно: пока отзыв — это строка с текстом и именем, экран ради неё стоил бы дороже, чем даёт. Отзыв уходит вместе с учётной записью: политика обещает, что данные можно стереть, и он — такие же данные игрока, как партии.
- Backend: Python 3.12, FastAPI, SQLAlchemy 2.0 (async), Alembic
- База: PostgreSQL 16 + PostGIS — зоны хранятся полигонами, точка раунда
выбирается через
ST_GeneratePoints - Кэш: Redis 7 — спутниковые тайлы
- Аутентификация: свой JWT, пароли argon2id
- Frontend: React 18 + TypeScript + Vite, карты на OpenLayers, шрифты свои — ни одного запроса на сторонние домены
- Инфраструктура: Docker Compose, Nginx
git clone https://github.com/vomas7/location_king
cd location_king
make dev # postgres, redis, backend и dev-сервер фронтенда
make migrate # накатит миграции
make seed # загрузит игровые зоныИгра: http://localhost:5173. Документация API: http://localhost:8000/api/docs.
Остановить — make down, вместе с данными — make down-v.
cd backend
python3 -m venv venv && source venv/bin/activate
pip install -r requirements-dev.txt
cp .env.example .env # заполните JWT_SECRET
alembic upgrade head
python scripts/seed.py
uvicorn app.main:app --reloadФронтенд отдельно:
cd frontend
npm install
npm run dev # http://localhost:5173, /api проксируется на бэкендПеред коммитом:
cd backend && ruff check . && ruff format --check . && pytest
cd frontend && npm run lint && npm run buildCI выполняет то же самое и падает, если проверки не проходят или покрытие бэкенда опускается ниже 85%.
Структура бэкенда:
app/
routers/ HTTP-слой: разбор запроса, вызов сервиса, ответ
services/ бизнес-логика и правила игры
models/ модели SQLAlchemy
schemas/ схемы Pydantic
utils/ чистые функции без зависимостей от БД
Структура фронтенда:
src/
api/ транспорт: fetch, токены, типы ответов
domain/ чистые функции форматирования и оценки
map/ карты OpenLayers
state/ контекст авторизации и состояние партии
components/ разметка и стили в CSS-модулях
| Метод | Путь | Что делает |
|---|---|---|
POST |
/api/auth/register |
регистрация по email и паролю |
POST |
/api/auth/login |
вход |
POST |
/api/auth/refresh |
обновление пары токенов |
GET |
/api/auth/me |
профиль и статистика |
PATCH |
/api/auth/me |
сменить имя и узор аватарки |
PUT |
/api/auth/me/avatar |
загрузить свою картинку |
DELETE |
/api/auth/me/avatar |
убрать картинку и вернуть узор |
GET |
/api/avatars/{id} |
аватарка игрока картинкой |
POST |
/api/auth/me/delete |
удалить учётную запись и все данные |
POST |
/api/sessions |
начать партию, получить первый раунд |
GET |
/api/sessions/{id} |
состояние партии и история раундов |
POST |
/api/sessions/{id}/finish |
завершить партию досрочно |
GET |
/api/rounds/{id} |
активный раунд, без координат |
POST |
/api/rounds/{id}/guess |
догадка, в ответе появляется цель |
POST |
/api/rounds/{id}/hint |
подсказка ценой части очков раунда |
POST |
/api/rounds/{id}/timeout |
закрыть раунд, на который не успели |
GET |
/api/rounds/{id}/tiles/{z}/{x}/{y}.jpg |
тайл снимка по локальным координатам |
GET |
/api/sessions |
история партий игрока |
GET |
/api/sessions/current |
незаконченная партия или null |
GET |
/api/zones |
зоны с фильтрами по месту и уровню |
GET |
/api/leaderboard |
таблица лидеров с зачётом по условиям |
GET |
/api/challenge/today |
челлендж дня и таблица дня |
POST |
/api/challenge/today/start |
начать челлендж дня |
POST |
/api/duels/queue |
встать в очередь на соперника |
POST |
/api/duels/queue/poll |
продлить поиск и узнать, нашлась ли пара |
DELETE |
/api/duels/queue |
прекратить поиск |
GET |
/api/duels/searching |
сколько человек ищет соперника |
GET |
/api/duels/format |
условия дуэли: одни для всех |
GET |
/api/friends |
друзья, заявки и свой код игрока |
POST |
/api/friends |
позвать в друзья по коду |
POST |
/api/friends/{id}/accept |
принять заявку |
DELETE |
/api/friends/{id} |
отклонить, отозвать или расстаться |
POST |
/api/matches |
создать комнату, получить её код |
GET |
/api/matches/mine |
комнаты, созданные игроком |
GET |
/api/matches/{code} |
состояние комнаты и таблица результатов |
POST |
/api/matches/{code}/join |
войти в комнату и начать её серию |
POST |
/api/matches/{code}/close |
закрыть набор, только хост |
Полное описание — в /api/docs.
До входа открывается лендинг: что за игра, как проходит раунд, какие есть режимы, почему ответ нельзя подсмотреть, и частые вопросы. Форма входа стоит там же, на первом экране.
Те же вопросы уходят в разметку JSON-LD, чтобы поисковик показывал их в
выдаче, — собирает её плагин в vite.config.ts из того же файла с текстами,
что и страница. Картинка для соцсетей рисуется браузером из вёрстки:
node scripts/make-og-image.mjs после правок оформления.
Открытый в интернет сервер получает поток запросов, которых никто не звал:
перебор wp-admin и .env, попытки войти по чужим паролям, долбёжка по
несуществующим адресам. Защита от этого стоит слоями, и каждый слой дешевле
следующего.
Первым отвечает nginx. Пути, которых в игре нет вовсе — панели чужих CMS,
забытые дампы, .php, — закрывают соединение без ответа (444): сканер не
получает даже кода, по которому мог бы отличить «нет такого» от «есть, но
закрыто». Дальше — ограничение частоты по адресу: шестьдесят запросов в
секунду на игровые пути и один в секунду на вход с регистрацией. Перебор
пароля упирается в это после десятой попытки, не дойдя до argon2 — а именно
argon2 стоит дорого.
Вторым считает приложение: у каждого действия, которое пишет в базу, свой
лимит в одной таблице RULES (app/services/rate_limit.py). Эти счётчики
про игру — сколько партий начато, сколько заявок в друзья отправлено, — а не
про сетевой шум, и до них шум уже не доходит.
Адрес, по которому всё это считается, приходит от nginx одним проверенным
значением: в контуре Cloudflare это CF-Connecting-IP от самого Cloudflare,
в остальных — $remote_addr после real_ip. Клиентскую цепочку
X-Forwarded-For не пересылает ни один контур: её первый элемент пишет сам
клиент, и пока она доходила до приложения, лимиты по адресу обходились сменой
одного заголовка.
За Cloudflare origin принимает соединения только из его сетей — список собирается при каждом развёртывании. Иначе сканер, нашедший адрес сервера перебором сетей хостинга, обходил бы и защиту Cloudflare, и подсчёт настоящего адреса игрока.
Что настроить на самой машине — файрвол, вход по ключу, автообновления — в docs/deployment.md.
Мы храним почту, пароль в виде хеша argon2id, имя для таблицы лидеров и ход партий. Никакой аналитики, счётчиков и сторонних скриптов на странице нет, и ни одного файла cookie игра не ставит: в браузере остаётся только токен входа.
Учётная запись удаляется вместе со всеми данными кнопкой в разделе «Профиль» —
это обычный эндпоинт POST /api/auth/me/delete, а не письмо в поддержку.
Условия использования, политика конфиденциальности и страница про хранилище
открываются из подвала посадочной страницы, а в меню игры — из раздела
«Профиль». Тексты лежат в frontend/src/legal/documents.ts, что
перед публикацией нужно заполнить — в docs/legal.md.
На сервере с Docker — одна команда:
./deploy.shОна создаёт .env со сгенерированными паролями, собирает образы, поднимает
контур, накатывает миграции, загружает зоны и проверяет, что игра отвечает.
Обновление — git pull && ./deploy.sh.
По умолчанию сертификат выпускает Let's Encrypt: игроки приходят прямо на
сервер, продление берёт на себя контейнер certbot, и от человека нужны
только записи в DNS. Контур за Cloudflare тоже поддерживается — он прячет
адрес сервера, но привязывает доступность к доступности самого Cloudflare.
Подробности, свои сертификаты и провайдеры снимков — в docs/deployment.md.
Push в main → проверки в CI → ветка deploy → сервер забирает её сам.
Ветку двигает только CI и только после зелёных проверок; сервер раз в две
минуты смотрит, не появилось ли нового.
Ключей от сервера нигде, кроме самого сервера, при этом нет: он ходит наружу сам, а к нему никто не ходит. Не поднялась новая версия — сервер возвращается на предыдущую и остаётся работающим.
Подробности и ручное управление — в docs/deployment.md.
Копия базы снимается каждую ночь и тут же проверяется разворачиванием в отдельную базу — копия, которую ни разу не восстанавливали, это не копия. Хранятся неделю, лежат рядом с игрой. Как восстановиться — в docs/deployment.md.
Вместе с игрой поднимается Grafana с Prometheus и Loki — на своём поддомене,
grafana.<домен>. Три готовых дашборда: игра, сервер и логи. Логи всех
контейнеров собираются автоматически, идентификатор запроса сквозной, так что
жалоба игрока находится в них по одному значению.
Не нужно — MONITORING=false в .env. Подробности — в
docs/monitoring.md.
MIT — см. LICENSE.