Моє бінго на матч тримає всю гру в адресі сторінки. Картка — це чотирисимвольний код плюс бітова маска клітинок, які ти вже відмітив, тож посилання має вигляд #K7QM-1a2b. Воно переживає перезавантаження і навіть перехід із телефона на ноутбук. Як це влаштовано, я розповідав у попередній статті, і для футболу цього вистачало.
Наступне бажання було передбачуване: хай люди пишуть власні події. Під Євробачення, під день народження. А раз картка вже живе в адресі, найлінивіший план — покласти туди ж і список подій.
Цей план не пережив калькулятора.
Адреса несе не літери
Спершу я прикинув, що 35 подій дадуть посилання на 600–1000 символів. Довге, але його ж кидають у чат один раз і забувають. Тільки я рахував літери, а адреса несе байти, і події в мене українською.
Тож я поміряв на справжніх даних: узяв 35 подій із футбольного пулу, в середньому по 19 символів кожна.
| Що кладемо в адресу | Довжина |
|---|---|
| Звичайний текст, події через перенесення рядка | 674 |
| Той самий текст у байтах UTF-8 | 1 230 |
| Ці байти в base64url | 1 640 |
| Звичайний текст, який браузер закодував сам | 3 674 |
Кожна кирилична літера в UTF-8 займає два байти. Base64 додає зверху ще третину. Із відсотковим кодуванням ще гірше, бо кожен байт стає трьома символами, і «П» виходить з дому вже як %D0%9F. Англійський список був би вдвічі коротший, але мої друзі пишуть кирилицею, тож мені від цього ні холодно, ні жарко.
Браузер посилання на 1 640 символів відкриє, Telegram його надішле. Ну а в чаті воно приїде сірою стіною, яку половина компанії просто прогорне.
Чотири виходи, і кожен чогось коштує
Першим спало на думку стиснення. CompressionStream уже є в браузерах, і deflate перед base64 мав би дати десь 900–1000 символів. Я цього не будував, тож цифра — здогадка, і навіть якщо вона правильна, посилання все одно завдовжки з абзац.
Другий варіант — каталог із галочками: 200 подій у HTML, а вибір зберігається бітовою маскою, як і натискання. Близько 50 символів, але формулювання тільки мої.
Чесна відповідь: текст мусить жити не в посиланні, а на сервері. А весь сенс цих ігор у тому, що сервера немає.
Скорочувачі посилань лікують довжину і більше нічого. Помре сервіс — помруть усі картки. Віддавати памʼять ігри для вечірки компанії, яка заробляє на підрахунку кліків, я не хочу.
GitHub Gist — варіант, від якого я нічого не чекав. Це безкоштовне сховище, акаунт у мене вже є, secret gist може прочитати будь-хто, хто знає його ID, а API віддає заголовки CORS, тож статична сторінка на GitHub Pages тягне його напряму.
Отже: готові набори для тих, хто хоче просто грати, і gist для тих, кому потрібен свій список.
Шість наборів і одна заміна
Набори лежать у самому HTML, тож посиланню вистачає ключа набору: aalyoshin.github.io/match-bingo/#euro роздає нову картку для Євробачення, і це 45 символів. Футбол зберіг свої 44 події, решта наборів отримали по 30, і цього запасу досить, щоб двоє в одній кімнаті майже ніколи не витягли ті самі 24. У першій чернетці списку стояв радянський новорічний «Голубой огонёк», той, що російські канали досі крутять кожного 31 грудня, а я живу в Україні, тож він вилетів одразу. Замість нього зʼявилася «Новорічна ніч» удома, а ще Нацвідбір, Формула-1 та «Оскар». Центральна клітинка в кожного набору своя: на Євробаченні там «Перша пісня», у Формулі-1 «Згасли вогні».
Старі посилання мали працювати далі
Картки в старому форматі #K7QM-1a2b вже могли розійтися по чатах, і вони мусили відкривати рівно ту саму картку, що й раніше. Вирішилося порядком перевірки: спочатку старий шаблон, а вже потім нові.
// #K7QM-1a2b футбол, старі посилання // #euro або #euro-K7QM-1a2b набір; без коду картки = нова картка // #g.<id> або #g.<id>-K7QM-1a2b список із GitHub Gist function parseHash(h){ var m=/^#([A-Z0-9]{4})-([0-9a-z]+)$/.exec(h||""); if(m) return {src:"fb", code:m[1], marks:parseInt(m[2],36)|0}; m=/^#(g\.[0-9a-f]{20,40}|[a-z][a-z0-9]{0,9})(?:-([A-Z0-9]{4})-([0-9a-z]+))?$/.exec(h||""); if(!m) return null; if(m[1].indexOf("g.")!==0 && !PACKS[m[1]]) return null; return {src:m[1], code:m[2]||null, marks:m[3]?parseInt(m[3],36)|0:0}; } Функцію зерна я теж не чіпав. Картка, як і раніше, — це rng(seedOf(code)), який тасує пул, тож той самий код і той самий пул дають ті самі 24 події. Перевірив найнуднішим способом: відкрив #ABCD-35s у старому файлі й у новому та порівняв усі 25 клітинок. Збіглися.
Футбольні посилання й далі пишуться без префікса, тож старе й нове виглядають однаково.
Як працює gist
Пишеш у gist по одній події в рядку, зберігаєш, вставляєш адресу gist у бінго й отримуєш посилання з #g. і ID в кінці. З 32-символьним ID уся адреса займає 75 символів. Два необовʼязкові рядки змінюють картку: той, що починається з #, стає заголовком, а Центр: … — центральною клітинкою.
function fetchGist(id, cb){ var key="bingo-gist:"+id, cached=null; try{ cached=JSON.parse(localStorage.getItem(key)||"null"); }catch(e){} if(cached && Date.now()-cached.t<30*60*1000){ cb(null,cached.text); return; } fetch("https://api.github.com/gists/"+id) .then(function(r){ if(r.status===404) throw new Error("404"); if(r.status===403||r.status===429) throw new Error("limit"); if(!r.ok) throw new Error("net"); return r.json(); }) .then(function(j){ var f=j.files[Object.keys(j.files)[0]]; return f.truncated ? fetch(f.raw_url).then(function(r){ return r.text(); }) : f.content; }) .then(function(t){ try{ localStorage.setItem(key,JSON.stringify({t:Date.now(),text:t})); }catch(e){} cb(null,t); }) .catch(function(e){ if(cached) cb(null,cached.text); else cb(e.message); }); } Кеш тут через ліміт, який GitHub прописує для запитів до API без авторизації: 60 на годину з однієї IP-адреси. Шестеро друзів у спільному Wi-Fi — це одна IP. Без кешу кожне перезавантаження з кожного телефона зʼїдало б запит, а так кожен телефон питає один раз і пів години читає з localStorage. Якщо посеред матчу зник інтернет, а на телефоні лежить старіша копія, картка відкриється з неї.
Так, трохи вище я казав, що віддавати памʼять гри сторонній компанії погана ідея. GitHub — теж стороння компанія. Різниця для мене в тому, що список належить тому, хто його написав, лежить у його власному акаунті й редагується там, а не ховається за чужим редиректом.
Ще одну пастку я обійшов заздалегідь: location.replace("#…") смикає слухача hashchange на тій самій сторінці. Тож сторінка памʼятає останню адресу, яку записала сама, і пропускає її.
Що впіймали тести і чого вони не бачать
Я проганяв сторінку через Playwright у Chromium на ширині 375 і 320 пікселів. Для кожного набору: роздати картку, тапнути клітинку, перезавантажити й перевірити, що повернулися ті самі події й та сама позначка. Окремо перевіряв, що текст жодної клітинки не вилазить за її межі, бо саме це минулого разу зламалося на iPhone.
На 320 пікселях кнопка «Очистити» закінчувалася на 350-му. Тридцять пікселів за краєм екрана. Винна була моя ж правка: я заборонив кнопкам переносити текст (white-space: nowrap), щоб «Нова картка» не розпадалася на два рядки, і стара панель перестала влазити. Старий файл на тій самій ширині вміщувався, я перевірив. Медіазапит для екранів, вужчих за 360 пікселів, підтискає відступи й міжлітерний інтервал у цьому рядку, і тепер він уміщується.
Шрифти Google в тестах не вантажилися, тож справжні Oswald і Rubik я дивився вже на iPhone. Стовпці влізли, а от слова Safari різав по складах («Оплески сто-ячи»), і автоперенос я вимкнув.
Фальшивий api.github.com у тестах віддає нормальний список, список на 10 подій (менше 24 бінго не приймає) і 404. Справжній gist я потім перевірив руками: заголовок і центр підхопилися з першого разу. А от ліміт запитів не перевіряв ніхто.
Якщо потрібної події немає в жодному з шести наборів, від мене нічого не треба: відкриваєш бінго, вибираєш «Свій список» і проходиш три кроки. Для картки вистачить двадцяти чотирьох рядків у gist, а весь код лежить на GitHub одним файлом.

