Можно ли запустить казуальную HTML5-игру на чистом JS в Яндекс Играх, не зная геймдева и верстки? Спойлер: можно, но придется пройти через 2-3 месяца модерации.
Под катом — честный постмортем инди-проекта, созданного по вечерам на коленке. Рассказываю про костыли SPA-архитектуры на тегах <div>, продвижение и реальные графики трафика и доходов за первые недели.
Если вам не терпится пощупать проект руками и не хочется читать долгие тексты — держите. Ну а для тех, кому интересен процесс разработки, грабли и цифры — устраивайтесь поудобнее.
1. Зачем это всё?
Всегда хотел создавать игры, из за этого в юности увлёкся программированием. По итогу стал программистом, но совсем в другой сфере.
К разработке игр возвращался набегами, раз в несколько лет. Менял языки, пробовал движки, но каждый раз бросал. Перерывы затягивались на годы.
В этот раз я твёрдо решил довести дело до конца и опубликовать проект. Чтобы не перегореть, выбрал формат супер-микропроекта, который реально завершить (спойлер: всё равно ушло 4–5 месяцев).
Почему браузерка и Яндекс Игры?
Браузерные игры удобны. Игроку не нужно ничего скачивать, регистрироваться и устанавливать. Они работают везде, где есть браузер.
При желании игру можно вынести отдельным ярлыком на рабочий стол ПК или экран смартфона как PWA-приложение.
Площадкой выбрал Яндекс Игры. Всё просто: моя 7-летняя дочка часто там залипает, да и я пробовал сам тоже, в ознакомительных целях.
Мне хотелось показать ей, что обычный человек может прямо дома создать вещь, доступную всему миру. Отличный способ расширить кругозор ребёнка и зажечь интерес.
Конечно, есть и другие платформы, куда можно опубликовать, если подключить их SDK. Но если получившееся мало кому будет интересно то в другие места и нет смысла публиковать (назовём это проверка идеи).
Никаких тяжелых движков
Чтобы не тратить время на изучение Unity, Construct или Defold, я принял решение: писать всё на «голом» стеке — HTML, CSS и ванильном JavaScript. Для казуального пазла про поиск отличий такой план казался оптимальным.
В итоге мы разделили разработку на двоих: я отвечал за код, интерфейс и сборку, а дочка стала главным саунд-дизайнером и тестировщиком. Да, игра получилась неказистой или кривой, но для меня это был шаг к давней мечте.
2. Пару слов об игре
Суть проекта проста и знакома каждому с детства: перед игроком две картинки, на которых нужно найти 5 отличий. Всего в игре 50 уровней. Отсюда и название, «Найди 250 отличий», но оно было не сразу, а появилось в процессе публикации, т. к. другие названия были заняты про 5 и отличия, а с таким изменением меньше переделывать медиаматериалов и описаний.
Похожие игры могут быть полезны для развития мозга ребёнка или взрослого, прокачка внимательности, запоминание и сравнивание деталей. (в похожую играл в своём детстве, только более качественную)
3. Техническая реализация и структура данных
В геймдеве на JS у меня был 0 опыта (только небольшой в веб-разработке на jQuery). В верстке я тоже не силён, поэтому дизайн верстал «на глаз» через инспектор кода, а результат проверял на двух домашних смартфонах.
Для старта я попросил нейросеть сгенерировать простейший прототип логики кликов. Но когда проект начал разрастаться, код пришлось полностью переписать. Сначала всё жило в одном файле, но со временем проект разбился на модули.
Ниже я покажу то, к чему пришёл на момент публикации. Код далек от идеала, мне явно есть куда расти, нейминг переменных, функций, даже переключение языка можно переписать получше. Но пока остаётся всё как есть, критика приветствуется тоже.

index.html — точка входа
Это обязательный файл для публикации.
index.html — файл
<!DOCTYPE html><html lang="ru"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes"> <title>Найди 250 отличий</title> <link rel="stylesheet" href="css/main.css?v=80"> <script> function showButtons(){ document.querySelectorAll('.btn-hide').forEach(el => { el.classList.remove('btn-hide'); }); } function initSDK() { YaGames.init().then(res => { window.ysdk = res; if (ysdk.environment.i18n.lang !== undefined) { if (ysdk.environment.i18n.lang == 'ru') { checkLangRu.click(); showButtons(); } else if (ysdk.environment.i18n.lang == 'en') { checkLangEn.click(); showButtons(); } else if (ysdk.environment.i18n.lang == 'tr'){ checkLangTr.click(); showButtons(); } else if (ysdk.environment.i18n.lang == 'zh'){ checkLangZh.click(); showButtons(); } } document.getElementById('loader').style.display = 'none'; document.getElementById('game-menu').style.display = 'flex'; window.ysdk.features.LoadingAPI?.ready(); }); } </script></head><body id="game111"> <div class="geme-div"> <div id="loader" class="loader"> </div> <div id="game-menu" class="game-menu-all" style="display:none"> <h1 class="lang-game-name" >...</h1> <div class="container-4" id="start-btn"> <div class="btn btn-four btn-hide"> <span >...</span> </div> </div> <div class="container-4" id="btn-options"> <div class="btn btn-four btn-help btn-hide"> <img class="flag" src="assets/images/ui/set.png" style="filter: invert(1);" /><span>Настройки</span><img class="flag" src="assets/images/ui/lang.png" style="filter: invert(1);" /> </div> </div> <div class="container-4" id="btn-about"> <div class="btn btn-four btn-help btn-hide"> <span>...</span> </div> </div> </div> <div id="game-menu2" class="hidden game-menu-all" > <h1 class="lang-game-name">...</h1> <h4 id="text-levals">...:</h4> <div id="menu-btn-levals" class="scroll-box"> </div> <div class="container-4" id="btn-game-menu2"> <div class="btn btn-four btn-help btn-hide"> <span class="text-main-manu">...</span> </div> </div> </div> <div id="game-menu3" class="hidden game-menu-all" > <h1 class="lang-game-name">...</h1> <h4 id="h4-text-pause">...</h4> <div class="container-4" id="btn-pause-play"> <div class="btn btn-four btn-hide"> <span>...</span> </div> </div> <div class="container-4" id="btn-game-menu"> <div class="btn btn-four btn-hide"> <span class="text-main-manu">...</span> </div> </div> </div> <div id="game-menu4" class="hidden game-menu-all" > <h1 class="lang-game-name" >...</h1> <h4 id="text-vin-leval">...</h4> <div class="container-4" id="btn-next-leval"> <div class="btn btn-four btn-hide"> <span>...</span> </div> </div> </div> <div id="game-menu5" class="hidden game-menu-all" > <h1 class="lang-game-name" >...</h1> <h4 id="text-vin">...</h4> <div class="container-4" id="btn-next-leval-fin"> <div class="btn btn-four btn-hide"> <span class="text-main-manu">...</span> </div> </div> </div> <div id="game-menu6" class="hidden game-menu-all" > <h1 class="lang-game-name" >...</h1> <h4 id="text-bad-fin-leval">...</h4> <div class="container-4" id="btn-next-leval-bad"> <div class="btn btn-four btn-hide"> <span class="text-main-manu">...</span> </div> </div> </div> <div id="game-menu7" class="hidden game-menu-all" > <h1 class="lang-game-name" >...</h1> <h4 id="text-options" >...</h4> <div class="options-title" id="text-check-lang">...</div> <div style="display: flex;"> <div class="container-4 lang-btn" > <div class="btn btn-four btn-help btn-hide" id="check-lang-ru"> <span><img class="flag" src="assets/images/ui/flag/ru.png" />RU</span> </div> </div> <div class="container-4 lang-btn "> <div class="btn-grey btn-four btn-help" id="check-lang-en"> <span><img class="flag" src="assets/images/ui/flag/en.png" />EN</span> </div> </div> <div class="container-4 lang-btn"> <div class="btn-grey btn-four btn-help" id="check-lang-tr"> <span><img class="flag" src="assets/images/ui/flag/tr.png" />TR</span> </div> </div> <div class="container-4 lang-btn"> <div class="btn-grey btn-four btn-help" id="check-lang-zh"> <span><img class="flag" src="assets/images/ui/flag/zh.png" />ZH</span> </div> </div> </div> <div class="options-title" id="text-sound" >...</div> <label class="switch" > <input type="checkbox" checked id="check-enabled-sound"> <span class="slider round"></span> </label> <br/> <div class="container-4" id="btn-game-menu3"> <div class="btn btn-four btn-help btn-hide"> <span class="text-main-manu">...</span> </div> </div> </div> <div id="game-menu9" class="hidden game-menu-all" > <h1 class="lang-game-name" >...</h1> <h4 id="text-show-video" style="background: #ca75e987;">...</h4> <div class="container-4" id="btn-show-video"> <div class="btn btn-four btn-hide"> <span >...</span> </div> </div> <div class="container-4" id="btn-close-show-video"> <div class="btn btn-four btn-hide"> <span >...</span> </div> </div> </div> <div id="game-menu8" class="hidden game-menu-all" > <h1 class="lang-game-name" >...</h1> <div class="scroll-box"> <h4 class="game-menu8-h4" id="text-about">...</h4> <p class="ppp" id="text-about-all-1">...</p> <p class="ppp" id="text-about-all-2"> ... </p> <h4 class="game-menu8-h4" id="text-instructions">...</h4> <p class="ppp" id="text-help-all">...</p> </div> <!--<h4 class="game-menu8-h4" id="text-authors">Авторы:</h4> <div class="background-blue" id="text-authors-all">...</div>--> <br/> <div class="container-4" id="btn-game-menu4"> <div class="btn btn-four btn-help btn-hide"> <span class="text-main-manu">...</span> </div> </div> </div> <div id="game-container" class="hidden" > <div class="container" > <div class="box"> <div class="container-4" > <div class="btn btn-four btn-game-scene btn-hide " id="btn-paus"> ... </div> </div> <div class="text-this-leval"><span id="text-this-leval2">...</span>: <span id="this-leval-n">1</span></div> </div> <div class="box"> <h4 class="h4-game-scena class-not-found" ><span id="text-bad-click">...</span>: <span id="not-found">0</span> / 3</h4> <h4 class="h4-game-scena class-status" ><span id="text-good-click">...</span>: <span id="status">0</span> / 5</h4> </div> <div class="box"> <div class="container-4"> <div class="btn btn-four btn-game-scene btn-hide " id="btn-help-found"> <span id="text-help">...</span> (<span id="help-found-count">0</span>) </div> </div> <div class="btn-help-found-text-buy">...</div> </div> </div> <canvas id="gameCanvas" ></canvas> <div ></div> </div> </div> <script src="src/lang.js?v=80" ></script> <script src="src/levals.js?v=80" ></script> <script src="src/sound.js?v=80" ></script> <script src="src/main.js?v=80" ></script> <script src="src/menu.js?v=80" ></script> <script> window.onload = function() { //document.body.style.opacity = '1'; };function init123(){const script = document.createElement('script'); script.src = '/sdk.js'; script.async = true; script.onload = initSDK; document.body.append(script); }if (document.readyState === 'complete') {init123();} else {window.addEventListener('load', init123() );} </script></body></html>
Архитектура игры — это классическое SPA (Single Page Application) на минималках. Вся игра — это одна страница, а экраны (главное меню, игровой процесс, пауза, настройки) сделаны обычными тегами <div>. Переключение между ними происходит через простое скрытие и показ нужного блока в JS.
Сначала экранов было всего два: «Старт» и «Игра». Но аппетит приходит во время еды, и структура разрослась до 8 экранов. Обращался я к ним по ID, просто добавляя цифры: game-menu1, game-menu2 и так далее. Пока экранов мало — это работает. Когда их стало 8, удерживать в голове, какой номер за что отвечает, стало мучением.
Тут бы провести рефакторинг и дать им понятные имена, но проект уже шёл к публикации, и я решил не трогать то, что работает. Из-за этого ожидаемо поползли баги: то один <div> забудет скрыться и поверх запущенной игры висит экран паузы, то показываются сразу два экрана. Отлавливать такие баги в подобной структуре — сомнительное удовольствие.
Стилизация и адаптив (CSS)
В CSS-файле экраны отстилизованы на мой вкус. Цвета, формы кнопок и фоны менялись десятки раз, пока я не пришёл к финальному варианту.
Чтобы игра адекватно смотрелась и на смартфонах, и на мониторах (как в горизонтальном, так и в вертикальном режимах, хотя горизонтальный режим в релиз не вошёл, а так же медиаматериалы для горизонтальных экранов), я пошёл по пути наименьшего сопротивления: зафиксировал игровой контейнер по центру экрана. Из-за этого на ПК игра выглядит суженой по вертикали, зато верстка влезает на мобильных.
В этом же index.html происходит инициализация Яндекс SDK, а также подключаются все стили и скрипты.
Порядок подключения JS-файлов
Скрипты подключаются друг за другом в строго определённом порядке, так как назовём это модули зависят от данных друг друга:
<script src="src/lang.js?v=80" ></script><script src="src/levals.js?v=80" ></script><script src="src/sound.js?v=80" ></script><script src="src/main.js?v=80" ></script><script src="src/menu.js?v=80" ></script>
Локализация (lang.js)
Я решил перевести игру сразу на 4 языка: русский, английский, турецкий и китайский. Для этого я написал простейшую систему локализации.
В файле lang.js хранится большой объект с переводами всех фраз. Вот сокращённый пример:
const langObject = { "ru" :{ "game-name": "<span class='deep-pink'>Н</span><span class='orange'>а</span><span class='light-green'>й</span><span class='royal-blue'>д</span><span class='yellow'>и</span> <span class='orange'>250</span> <span class='deep-pink'>о</span><span class='royal-blue'>т</span><span class='orange'>л</span><span class='orchid'>и</span><span class='yellow'>ч</span><span class='light-green'>и</span><span class='orchid'>й</span>", "start-btn" : "Начать игру", //... }, "en": { "game-name": "<span class='deep-pink'>F</span><span class='orange'>i</span><span class='light-green'>n</span><span class='royal-blue'>d</span> <span class='yellow'>250</span> <span class='orange'>d</span><span class='deep-pink'>i</span><span class='royal-blue'>f</span><span class='orange'>f</span>", "start-btn" : "Start game", // ... }, "tr": { "game-name": "<span class='deep-pink'>b</span><span class='orange'>u</span><span class='light-green'>l</span><span class='royal-blue'>u</span><span class='orange'>n</span> <span class='yellow'>250</span> <span class='orange'>f</span><span class='deep-pink'>a</span><span class='royal-blue'>r</span><span class='orange'>k</span>", "start-btn": "Oyunu başlat", // ... }, "zh": { "game-name": "<span class='deep-pink'>找</span><span class='royal-blue'>出</span> <span class='orange'>250</span> <span class='deep-pink'>個</span><span class='royal-blue'>不</span><span class='orange'>同</span><span class='orchid'>點</span>", "start-btn": "開始遊戲", // ... }};
За переключение текстов на экране отвечает файл menu.js. В самом его конце находится функция, которая пробегается по элементам и подставляет нужные строки:
function updateLand( lang ){ //console.log( langObject[lang]['game-name']); // заголовок langGameName = document.querySelectorAll('.lang-game-name'); langGameName.forEach(el => { el.innerHTML = langObject[lang]['game-name']; }); ////// // кнопки document.getElementById('start-btn').querySelector('div').querySelector('span').innerText = langObject[lang]['start-btn']; document.getElementById('btn-options').querySelector('div').querySelector('span').innerText = langObject[lang]['btn-options']; document.getElementById('btn-about').querySelector('div').querySelector('span').innerText = langObject[lang]['btn-about']; ...
Язык выбирается двумя способами: вручную игроком по клику на флаг в меню или автоматически при старте игры — язык подтягивается из SDK Яндекс Игр.
checkLangRu.addEventListener('click', () => { greyBtnLang(); checkLangRu.classList.add('btn'); // Скрыть меню updateLand( 'ru' );});checkLangEn.addEventListener('click', () => { greyBtnLang(); checkLangEn.classList.add('btn'); // Скрыть меню updateLand( 'en' );});...
Автоматическая смена языка через SDK
Яндекс SDK определяет язык игрока по URL (например, через параметры .com, ?lang=en, tr или zh). На основе этих данных игра автоматически включает нужную локализацию. Для тестов можно просто вручную менять этот параметр в адресной строке.
Чтобы не писать лишний код для активации интерфейса, JS просто имитирует клик по нужной кнопке выбора языка.
if (ysdk.environment.i18n.lang !== undefined) { if (ysdk.environment.i18n.lang == 'ru') { checkLangRu.click(); showButtons(); } else if (ysdk.environment.i18n.lang == 'en') { checkLangEn.click(); showButtons(); } else if (ysdk.environment.i18n.lang == 'tr'){ checkLangTr.click(); showButtons(); } else if (ysdk.environment.i18n.lang == 'zh'){ checkLangZh.click(); showButtons(); }}
Структура уровней (levels.js)
Все данные об уровнях хранятся в файле levels.js. Вот сокращённый пример структуры:
const levals = { "levals": [ { "n": 1, "diffs": [ { "x": 384, "y": 454, "r": 50, "found": false }, { "x": 518, "y": 217, "r": 50, "found": false }, { "x": 891, "y": 337, "r": 50, "found": false }, { "x": 597, "y": 124, "r": 50, "found": false }, { "x": 290, "y": 360, "r": 50, "found": false } ], "img" : "assets/images/leval1.jpg", 'lock': false }, //... { "n": 50, "diffs": [ { "x": 163, "y": 393, "r": 50, "found": false }, { "x": 842, "y": 360, "r": 50, "found": false }, { "x": 860, "y": 545, "r": 50, "found": false }, { "x": 451, "y": 342, "r": 50, "found": false }, { "x": 214, "y": 536, "r": 50, "found": false } ], "img" : "assets/images/leval50.jpg", 'lock': true }, ]};
Здесь для каждого уровня прописаны — номер уровня, координаты и радиус отличия, ссылка на картинку уровня, флаг доступности уровня.
Прогресс игрока сохраняется в localStorage. Уровни открываются строго по очереди. Конечно, если вы продвинутый «хацкер», данные можно перезаписать вручную через консоль разработчика — именно этим легальным читом я и пользовался во время тестов.
Сердце игры: Отрисовка и Canvas (main.js)
Вся магия игрового процесса сосредоточена в файле main.js. Загрузка уровня начинается с прелоадера, пока скрипт скачивает картинку:
Загрузка уровня
function loadLavel(leval){ if (leval > levals.levals.length) { gameContainer.classList.add('hidden'); menu5.classList.remove('hidden'); if (window.ysdk) { window.ysdk.features.GameplayAPI?.stop(); } playSound( 'finishGame' ); } else { window.fin = 0; window.fin2 = 0; window.badFin = 0; loader.style.display = 'block'; gameContainer.classList.add('hidden'); window.globalLevel = levals.levals[ Number(leval)-1 ]; window.badFound = 3; notFound.innerText = (3 - window.badFound); img.src = window.globalLevel.img; window.thisLeval = leval; // сбросить найденное на уровне foundCount = 0; window.globalLevel.diffs.forEach(d => { d.found = false; }); status.innerText = `0`; render(); ///// helpFoundCount.innerText = window.helpFoundCount; img.onload = () => { render(); loader.style.display = 'none'; gameContainer.classList.remove('hidden'); thisLevalN.innerText = leval; // Ждем, пока браузер выполнит отрисовку requestAnimationFrame(() => { requestAnimationFrame(() => { // событие — картинка уже на экране // setTimeout(() => { if (window.ysdk) { window.ysdk.features.GameplayAPI?.start(); } // }, 20); }); }); }; }}
Затем происходит рендер уровня на Canvas (холсте):
Рендер
function render() { ctx.drawImage(img, 0, 0, canvas.width, canvas.height); window.globalLevel.diffs.forEach(d => { if (d.found) { createCircle(d.x, d.y, d.r, 'lime'); } });}
А вот так обрабатываются клики по Canvas. Скрипт проверяет, попал ли игрок в радиус координат отличия:
Скрипт проверяет, попал ли игрок в отличие
function checkClick(e) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; err = 0; finClick = 0; if (window.badFin == 0 ){ window.globalLevel.diffs.forEach(d => { // Проверка попадания в радиус (с учетом смещения для второй картинки) const dist = Math.sqrt((x - d.x)**2 + (y - d.y)**2); const distRight = Math.sqrt((x - (d.x + canvas.width/2))**2 + (y - d.y)**2); if ((dist < d.r || distRight < d.r) && !d.found) { d.found = true; foundCount++; status.innerText = `${foundCount}`; playSound( 'soundFound' ); render(); finClick = 1; if (foundCount == 5){ window.fin =1; } } else { err = 1; } }); } if ( (window.fin == 1) && (window.fin2 == 0) && (window.badFin == 0)){ window.fin2 = 1; setTimeout(function(){ gameContainer.classList.add('hidden'); menu4.classList.remove('hidden'); if (window.ysdk) { window.ysdk.features.GameplayAPI?.stop(); } playSound( 'soundFinishLeval' ); },2000); } if ((finClick == 0) && (err ==1) && (window.fin == 0) && (window.badFin == 0)){ window.badFound--; notFound.innerText = (3 - window.badFound); playSound( 'soundNotFound' ); createCircle(x, y, 30, 'red'); if (window.badFound < 1) { window.badFin = 1; setTimeout(function(){ window.badFound = 3; notFound.innerText = (3 - window.badFound); gameContainer.classList.add('hidden'); menu6.classList.remove('hidden'); if (window.ysdk) { window.ysdk.features.GameplayAPI?.stop(); } playSound( 'badLeval' ); },1000); } }}
В этом же файле main.js прописана первичная загрузка (игра ждёт, пока скачаются звуки и фоны), адаптивный ресайз холста под размеры экрана, а также важные «костыли» для мобильных браузеров. Я заблокировал стандартный скролл страницы, зум двумя пальцами и обновление экрана по свайпу вниз (pull-to-refresh), по требованию яндекс игр.
Логика интерфейса (menu.js)
Файл menu.js отвечает за реакцию на нажатие кнопок. Логика простая: кликнули на кнопку — скрываем один <div> и показываем другой. Здесь живут обработчики для меню выбора уровней, кнопки помощи, настроек звука и смены языка.
Монетизация и реклама
Для монетизации я подключил стандартный SDK Яндекс Игр. Показ межстраничной рекламы (Fullscreen) вызывается встроенным методом. Я вызываю его довольно часто, но Яндекс сам регулирует частоту показов по своим внутренним алгоритмам, чтобы не раздражать игрока. Как правильно рассчитывать тайминги рекламы самому, я пока не разобрался, поэтому доверился платформе.
Также я включил боковой баннер (Sticky) — он активируется буквально одной галочкой в консоли разработчика Яндекса. Всё стандартно, как и в большинстве игр на этой платформе.
Где брать контент, если ты не художник?
Графику для уровней я искал на бесплатных стоках со свободной лицензией. Дальше начиналась ручная работа в GIMP. я создавал отличия, где-то стирал мелкие детали, где-то дорисовывал новые элементы, а где-то просто менял цвет объектов.
Со звуком помогла дочка. Она наигрывала несложные мелодии и эффекты на электронном пианино, а я записывал их на микрофон обычного смартфона. Получился хэндмейд-саунд-дизайн!
Нюансы мобильного геймплея
Во время тестов на смартфонах всплыл важный нюанс: играть на маленьком экране бывает тяжело, потому что в релизной версии нет зума картинок. Приходится буквально вглядываться в экран.
Интересно, что я сделал рабочее приближение (зум) для теста, но специально вырезал его перед публикацией. Оказалось, что с зумом игра превращается в читерство, которое убивает весь интерес к поиску ошибок. Зато на ПК проект играется нормально.
Исходный код
Весь проект я выложил на GitHub под лицензией Unlicense, можно использовать как угодно не упоминая автора (оставил 5 уровней — картинок, что бы много не весило), вот ссытка (одна ветка с sdk, другая без). Код далек от идеала, и его можно оптимизировать еще очень долго.
4. Особенности проекта
У игры стоит возрастной ценз 6+, поэтому я постарался совместить в ней простые и понятные детям задания с добрым юмором. Вообще, в проекте хватает своеобразных, местами даже «упоротых» шуток, а также небольших отсылок к популярным играм и мультфильмам.
Из самых забавных фишек я бы выделил две:
-
Поиск отличий в коде. На одном уровне игроку нужно искать баги прямо в исходном коде этой самой игры. Отличный способ с детства приучать детей к код-ревью (шутка… или нет).
-
Уровень из реальной жизни. Для одной из локаций я не стал брать картинку со стока. Я просто зашёл в свой зал, сфотографировал его, а потом начал физически перекладывать, добавлять и убирать вещи в комнате, чтобы сделать второй снимок.


5. Интеграция Яндекс SDK и круги модерации
Сама по себе игра содержит совсем немного строк кода. Базовую логику я написал очень быстро — буквально за 1–2 недели, работая по выходным и по вечерам после основной работы. Чуть дольше заняло производство контента: на создание всех 50 уровней ушло недели 3-4.
Но когда пришло время релиза и подключения SDK Яндекс Игр, начались настоящие мучения. Всплыл целый ворох технических проблем. Благо модераторы Яндекса попались очень лояльные: они подробно расписывали каждый баг, прикладывая скриншоты и даже видеозаписи экрана.
Процесс выглядел как классический пинг-понг:
Отправка на проверку → Получение списка ошибок → Исправление → Повторная\ отправка.
При этом с каждым разом срок для повторной отправки увеличивался. В итоге эта эпопея растянулась на 2–3 месяца. Чтобы пройти модерацию, пришлось переделать кучу всего: иконки, промо-материалы, код и логику работы со звуком.
Отдельной головной болью стали видеоролики для карточки игры. Я переснимал их на телефон, чтобы в кадре была видна моя рука, кликающая по экрану. Делал вертикальные и горизонтальные версии, под разные языки. В итоге эти видео так и не вошли в релиз, а я просто устал всё это монтировать и переснимать.
Очень много проблем с воспроизведением звука, при каждой отправки на модерацию старался и визуально улучшить игру.
В какой-то момент руки опускались настолько, что хотелось либо вообще всё бросить, либо удалить самописный код и переписать игру на готовом движке (вроде Unity или Construct ). Там хотя бы есть официальные плагины для SDK и «из коробки» решены проблемы с аудио-контекстом. Но я решил дожать свой чистый HTML/JS. И вот, игра наконец-то прошла модерацию и опубликована.
6. Метрики, продвижение и первые доходы
Игра была опубликована 5 мая. С момента релиза прошло около двух с половиной недель, и у меня на руках появилась первая статистика, графики и рейтинги.
Как работало продвижение?
Яндекс Игры дают новым проектам стартовый буст. Примерно неделю игра висит в разделе «Новинки». Правда тут в разные дни одновременно находится от 300 до 500 конкурирующих игр. Но именно это подарило проекту основной трафик и первые деньги (при наличии промо видео были бы наверно лучше результаты).
Помимо механизмов платформы, я пытался продвигать игру сам:
-
Дал поиграть примерно 5 друзьям.
-
Пост в Дзене: принёс околонулевой выхлоп.
-
Пост на Пикабу: попытался написать текстовый разбор. За час статья набрала 1 000 просмотров, после чего модераторы удалили её с пометкой «Реклама». Как переписать пост так, чтобы он не выглядел рекламным (даже без прямой ссылки на игру), я так и не придумал.
-
Статья на DTF (11 мая): пост набрал около 3,5 тысяч просмотров. На графиках ниже виден небольшой скачок трафика в этот день (и ещё один такой же через день, из за чего уже непонятно).
Разбор полётов в цифрах
Вот так выглядит общая панель разработчика на текущий момент. Финальный баланс — 642 рубля. Вывести их пока нельзя, так как минимальный порог на платформе составляет 3 000 рублей.


Графики ниже перерисовал что бы больше информации влезло.
1) Динамика рейтинга
У Яндекс Игр есть правило: если рейтинг игры 3 недели опускается ниже 31 баллов, её снимают с публикации. Пока вроде с этим нормально.

2) Время в игре (удержание)
Вот сколько минут в среднем пользователи проводят за поиском отличий каждый день:

3) Доход по дням
Самый интересный график. На нём отлично видно, как плавно угасает интерес к игре по мере того, как она опускается всё ниже в списке «Новинок».

4) Оценки пользователей
Текущая оценка игры: 4.0 из 5.0. Всего пользователи оставили 28 оценок. Яндекс показывает текст только одного отзыва, остальные 27 человек просто нажали звёздочки видимо.
Выводы
Миллионером я не стал, но главную цель выполнил:
-
Мечта сбылась. Я написал и опубликовал свою игру без использования тяжелых движков.
-
Дочка счастлива. Она увидела, что игры можно делать прямо дома, и примерила на себя роль геймдизайнера.
-
Опыт получен. Теперь я знаю, как устроен конвейер модерации Яндекса и с какими багами сталкивается HTML5-разработчик.
Код открыт, игра доступна, а впереди — новые проекты. Буду рад ответить на ваши вопросы и выслушать конструктивную критику в комментариях!
7. Что можно было сделать лучше (Ретроспектива)
-
Использовать готовый движок. Т. к. есть готовые плагины, возможно проще было бы подключать яндекс sdk, подгрузку ресурсов, работа со звуком и т. д.
-
Готовить маркетинг заранее. Прогревать аудиторию статьями, постами и тизерами нужно было до нажатия кнопки «Опубликовать», а не запрыгивать в уходящий поезд уже после релиза.
-
Правильно выбирать окно релиза. если игра не тематическая то перед праздниками наверно не надо выпускать, + конкретно в то время у какого-то количества людей не было интернета даже что бы поиграть.
Главный итог: Дерзайте и пробуйте! Не бойтесь выпускать свои проекты на публику. Пусть финал кажется странным, код — кривым, а дорогой графики нет и в помине. Главное — проект запущен, доведен до конца, и сделан он полностью своими руками.
Оценить старания «вживую» можно по ссылке.
Что дальше?
Мы с дочкой уже набросали на бумаге идею для новой игры — это будет детский несложный платформер (без грабежа караванов). В связи с этим у меня есть вопрос к Хабру.
Посоветуйте, под какую платформу лучше разрабатывать такой проект и где его распространять, чтобы была реальная возможность хоть немного заработать? Стоит ли снова идти в HTML5-браузерки, или лучше сразу целиться в мобильный стор вроде RuStore / Google Play? Буду очень благодарен за ваши советы и опыт в комментариях!
ссылка на оригинал статьи https://habr.com/ru/articles/1042262/