Documentation
Everything needed to get Keyng $Koin running on your own hosting and connected to your own Telegram bot. Written against a real deployment, including the problems cheap hosting actually causes and how to get past them.
What you need
- PHP 8.2 or newer, with
pdo_mysql,mbstring,openssl,tokenizer,json,curlandfileinfo. - A MySQL database and its host, name, user and password.
- A Telegram bot token from @BotFather.
- Your Telegram numeric user ID, for admin access. @userinfobot will tell you.
- HTTPS. Telegram will not open a Mini App over plain HTTP.
The game runs comfortably on ordinary shared hosting. It needs no shell access, no queue worker, no Redis and no Node process at runtime — frontend assets are compiled ahead of time and shipped as static files.
Installing the game
Upload the application to your web root, then let the browser installer do the rest. On a host with SSH you can build in place:
composer install --no-dev --optimize-autoloader
npm ci && npm run buildOn a host without SSH, build those two commands somewhere else and upload the result — including vendor/ and public/build/, both of which are produced by the commands above and are not in the source tree.
Everything lives in the web root. Many PHP hosts restrict PHP to your account directory, so the usual layout — app code above the document root — is not possible. The included .htaccess handles this: it routes every request into public/ and returns 404 for .env, composer.json, artisan and the rest.
Directories that must exist and be writable
storage/, storage/framework/sessions, storage/logs and bootstrap/cache, at 755 (or 775 if your host needs it). The sessions directory matters more than it looks: with the default file session driver Laravel writes into it without creating it first, so a missing directory is a 500 on the very first request.
Run the installer
Visit https://your-domain/install. It checks the PHP version, extensions and directory permissions, then collects your game name, URL, bot token, database details and admin IDs, runs the migrations, seeds the boosts, skins and tasks, and closes itself.
The .env file
The installer writes most of this for you, but the file has to exist first, and it must already contain an APP_KEY — Laravel will not boot without one, and the installer does not generate it. Create it from .env.example and set the key with php artisan key:generate, or paste any 32 random bytes, base64-encoded, as APP_KEY=base64:….
| Variable | Notes |
|---|---|
APP_KEY | Required before first boot. Everything else can be blank. |
APP_URL | Your HTTPS URL. Set by the installer. |
DB_* | Host, port, database, username, password. Set by the installer. |
BOT_TOKEN | From @BotFather. Used to verify Telegram sign-ins. |
BOT_URL | https://t.me/your_bot. Used to build referral links. |
ADMIN_IDS | Comma-separated Telegram numeric user IDs. |
SESSION_DRIVER | Leave as file unless you have a reason not to. |
REFERRAL_DIRECT_BONUS | Points per invited friend. Default 1000. |
REFERRAL_SECOND_LEVEL_COMMISSION | Percent of an invitee’s own referrals. Default 10. |
ADS_RESET | Hours before the ad-watch task can be repeated. |
BOT_BRIDGE_URLBOT_BRIDGE_SECRET | Only for a host that cannot reach api.telegram.org. Lets the bot read the welcome message and the admin list, and enables broadcasting. See Administration. |
TELEGRAM_LOGIN_FALLBACK | Leave false. Only for a host that strips the URL fragment — see budget shared hosting. |
Never run php artisan config:cache on this app. Several controllers read env() at runtime, and once the config is cached those calls return null.
Pointing your bot at the game
This is the step that makes the game reachable, and it happens in BotFather rather than in the app. Send /mybots, pick your bot, then Bot Settings → Menu Button, and set it to your game URL.
Anyone who opens your bot now gets a button that launches the game. A player’s account is created on first launch from Telegram’s signed sign-in data, and referral links are credited in the same request — no webhook is involved in any of that.
/newapp additionally gives you a direct t.me/your_bot/app link, which is the better thing to put behind a “play” button anywhere you promote the game.
/setdomain is not part of this. It links a domain for the Telegram Login Widget, which is a different feature that this app does not use.
The bot’s welcome message
The menu button is enough to play. A welcome message when someone sends /start is optional polish, and it needs a webhook — which means your host must be able to receive requests from Telegram and make requests to api.telegram.org. Plenty of free hosts block both.
On a host that can do both, point setWebhook at your own server and you are done. If yours cannot, this landing site ships a drop-in bot at landing/app/api/telegram that runs on Vercel, independently of wherever the game is hosted. Deploy it, set these environment variables, and point setWebhook at it:
A webhook on a different host does not affect how the game authenticates players. Mini App login verifies a signature that Telegram put in initData, using your bot token — it is offline cryptography and never calls Telegram. Both halves just need the same BOT_TOKEN.
| Variable | Notes |
|---|---|
BOT_TOKEN | Required. |
TELEGRAM_WEBHOOK_SECRET | Required. Any long random string; must match the secret_token you give setWebhook. |
GAME_URL | Required. Your Mini App URL. |
MINI_APP_URL | Optional t.me/bot/app link. Set it and /start referral payloads survive into the game. |
BOT_BRIDGE_SECRET | Optional, but this is what puts the admin panel in charge of the welcome message. Must match the game’s own BOT_BRIDGE_SECRET. |
WELCOME_TEXT | Fallback copy, used only when the bot cannot reach the game. |
WELCOME_IMAGE_URL | Fallback HTTPS image, same condition. |
WELCOME_BUTTON_TEXT | Fallback label for the Play button. |
DOCS_URL, BUY_URL | Optional extra buttons. |
curl "https://api.telegram.org/bot<TOKEN>/setWebhook" \
-d "url=https://<your-vercel-app>/api/telegram" \
-d "secret_token=<TELEGRAM_WEBHOOK_SECRET>"The secret is not optional in practice: without it, anyone who learns the URL can post fake updates to your bot.
Where the wording comes from
Edit the message, its image and the Play button’s label in the admin panel, under Settings. The bot reads them from the game on each /start (cached for a minute), so a change takes effect immediately with nothing to redeploy. That request is authorised by BOT_BRIDGE_SECRET, which must be set to the same value on both sides.
If the bot cannot reach the game — some free hosts challenge every non-browser request, and the challenge is what a bot receives instead of an answer — it falls back to its own WELCOME_TEXT and WELCOME_IMAGE_URL variables, and then to built-in copy. A player who sends /start always gets a reply. On an ordinary single server none of that applies and the panel is simply in charge.
Administration
Admins are the Telegram user IDs in the ADMIN_IDS setting. Those accounts — and only those — see an Administratorbutton on the game’s home screen, which opens the admin panel at /admin inside the Mini App. The route is gated independently of the button, so hiding it is presentation rather than security.
The bot knows too. Send it /start as an admin and the welcome message carries an extra Administrator button that opens the panel directly. The bot keeps no list of its own — it reads the same ADMIN_IDS setting from the game, so the panel stays the one place admins are added or removed.
From there you can:
- Overview — players, active today and this week, new today, wallets linked, referrals, total points earned, and the top ten players.
- Tasks — create, edit and delete quests: name, type, reward, description, link and icon.
- Skins — create, edit and delete characters: cost, level, profit per hour, energy, damage per tap and art.
- Settings — game name, coin symbol, referral rewards, socials, ad link, the admin list itself, and the bot’s welcome message, image and Play button label.
- Broadcast — a Telegram message to every player, when the relay is configured.
The panel will not let you remove your own ID from the admin list, since that would lock everyone out on save. Skin 1 cannot be deleted either — it is the character every new player starts with — and a skin players already own has to be retired by setting its availability to zero rather than deleted out from under them.
Broadcasting sends through Telegram, so on a host that blocks api.telegram.org it needs the same relay as the welcome message. The admin page is served by your host but runs in your browser, which has ordinary internet access — so the game signs one broadcast, your browser carries that signature to landing/app/api/broadcast, and the relay sends it. The signature covers the message, the exact recipient list and an expiry, so the relay only ever sends something the game approved.
Set BOT_BRIDGE_URL and BOT_BRIDGE_SECRET in the game’s .env, and the same BOT_BRIDGE_SECRET on the relay. Leave them blank and the broadcast tab is simply hidden. Everything else — tasks, skins, settings, statistics — is local to the game and works anywhere.
How the game reacts
Rewards are announced with a celebration rather than a notification: the screen dims, coins or confetti fall, and the number counts up from zero. A reward you watch arrive reads as bigger than the same reward printed instantly, which is the whole reason to do it. Notifications are kept for the quiet things and for errors.
Anywhere in the app can raise one, and they queue rather than stack, so two things landing together are watched one after the other:
import { useCelebration } from "@/composable/useCelebration";
const { celebrate } = useCelebration();
celebrate({
kind: "reward", // reward | unlock | levelup | boost
title: "Daily bonus",
amount: 5000, // counted up when present
symbol: "Points",
subtitle: "Come back tomorrow for 15,000",
icon: "🪙", // an emoji, or an image path
});Tapping buzzes through Telegram’s haptic engine, the character squashes under the hit, and a run of fast taps raises a combo badge with a firmer buzz every fifth tap. Moving between screens ticks the selection haptic. All of it is wrapped: clients older than Bot API 6.1 throw rather than no-op, which would otherwise take a tap handler down with it.
Sound is synthesised with WebAudio when it plays rather than shipped as files, so it costs nothing to download — which matters on a Mini App opened over mobile data. It is off until a player turns it on from the home screen, because a game that starts making noise inside a chat app gets muted at the system level and never turned back on.
Everything here honours prefers-reduced-motion: players who have asked their phone for less movement still get the news, with nothing flying or pulsing.
Notes for budget shared hosting
These come from deploying to a free host, and they generalise to most cheap ones.
- Index key length. Some MySQL builds cap index keys at 1000 bytes. Laravel’s default 255-character string is 1020 bytes in
utf8mb4, so table creation fails with “1071 Specified key was too long”. This app callsSchema::defaultStringLength(191)already. - Per-file upload caps. Free hosts often cap uploads at 10 MB and silently discard anything larger. Deploy in archives smaller than the cap rather than one large one.
- Empty directories vanish. Many file managers do not create empty directories when extracting an archive. Anything that must exist but ships empty needs a placeholder file inside it.
- Zero-byte files. Over FTPS, a zero-length upload can fail the TLS data connection outright and abort a whole deploy. Do not commit empty files.
- Anti-bot challenges. Some hosts challenge every non-browser request with a JavaScript test, which is why webhooks and API calls never arrive. There is no setting to disable it.
- The challenge eats the login credential. Telegram hands a Mini App its credential in the URL fragment. A challenge page that redirects to
pathname + querythrows that fragment away, so the game loads with nothing to sign the player in and shows “Open in Telegram” to someone already standing in Telegram. It looks intermittent because it only happens on a cold load, before the challenge cookie is set. See below.
When the host strips the URL fragment
The query string survives that redirect even though the fragment does not, so the credential can travel there instead. Telegram authenticated the /start it delivered to your bot, so the bot signs a token saying who the player is and hangs it off the Play button; the game reads it when nothing else arrived.
Set BOT_BRIDGE_SECRET to the same value on both sides and TELEGRAM_LOGIN_FALLBACK=true in the game’s .env. Players need to send /start again to get a button carrying a fresh token; the tokens last 24 hours.
Leave this off on an ordinary host. It carries the bot’s word rather than Telegram’s signature and is replayable until it expires, so it is deliberately consulted only when initData is absent — never when initData is present but invalid, which means a corrupted credential cannot force a downgrade to the weaker check. It is a workaround for a broken host, not a login system. The real fix is a host that leaves the fragment alone.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| HTTP 500 on every page | Usually vendor/ is missing — public/index.php requires the autoloader before any framework code runs, so nothing reaches the log. Check vendor/autoload.php exists. Otherwise check APP_KEY is set. |
| “Open in Telegram” while already inside Telegram | The app could not find Telegram’s signed launch payload. Read the diagnostic under the message. If hash shows tgWebAppData but the app still refuses, the fragment is being parsed wrongly — its position among the other parameters is not fixed, so parse it as a query string rather than matching a prefix. If hash is empty, the credential never arrived at all: either the host stripped it (see budget shared hosting) or the button that opened the game was a plain link rather than a Mini App button. |
| Works only after opening the game two or three times | The host’s anti-bot challenge is discarding the URL fragment on a cold load, and the retry succeeds because the challenge cookie is set by then. Not a timing problem — the fragment is in the URL from the first byte and cannot arrive late. See when the host strips the URL fragment. |
| The Play button opens a browser, not the game | MINI_APP_URL is set to something other than a t.me/<bot>/<app> link. Anything else becomes an ordinary link that opens Telegram’s in-app browser, where there is no initData and nobody can sign in. Clear it, or set it to the direct link from BotFather’s /newapp. |
| “Please provide a valid cache path” | storage/framework/views does not exist. Laravel’s stock config runs it through realpath(), which returns false for a missing directory. Create it, or use the included config/view.php. |
| Session write fails with “No such file or directory” | storage/framework/sessions does not exist. The file session handler does not create it. |
| “CSRF token mismatch” after signing in | Signing in regenerates the session, which rotates the CSRF token. Do not pin the token from the page’s meta tag into a request header at boot — Laravel checks that header before the XSRF-TOKEN cookie, so a stale one suppresses the cookie that would otherwise stay current. |
| Rewards read “Not set” | COIN_SYMBOL holds a placeholder. Set it in the admin settings. |
/start does nothing | Expected without a webhook, and it does not affect play. See the welcome message. |
/start answers with nothing at all | An unpaired * or _ in the welcome message. Telegram refuses the whole message with “can’t parse entities” rather than sending it unformatted. The bot now resends without formatting so players still get something, and the admin panel refuses to save an unbalanced message — but the copy still wants fixing. Note Telegram uses single asterisks for bold, not the doubled ones other chat apps take. |
| The daily grid ticks the wrong days | The seven-day grid is a streak ladder, not a calendar: “Day 3” means the third consecutive claim. Deriving its state from dates rather than from the streak puts the ticks in the wrong boxes. |