Developer docs
Everything you need to put an HTML5, WebGL or WASM game on Nekoland: the neko CLI, the nekogame.json config, the game SDK (payments, cloud saves, login, friends) and the server SDK.
Overview
Nekoland is a web game platform. You upload a build folder with the neko CLI, and players open it in the browser or in the Nekoland apps — no install. The platform handles accounts, the cube currency, payments, cloud saves, friends and chat.
- Each game runs in an iframe on its own origin:
https://{gameId}.games.nekoland.net/{version}/. The site around it (header, dialogs, loading screen) is the platform. - The SDK is the only bridge between the game and the platform. It talks to the host page with
postMessage; the purchase, top-up, login and friend-picker dialogs are drawn by the platform outside the iframe, so a game can't fake them. - Players pay with cubes, the platform currency (1 cube = $0.01). Your products are priced in cubes; you don't set up a payment provider.
playerIdis different in every game for the same person, so games can't track players across each other.- Nekoland has no leaderboards. Games that need rankings, server-side currency or multiplayer keep that on their own server (see Game servers).
Quick start
You need Node.js 18.17 or later. From your game folder:
npm i -g @launchercapital/nekoland-cli
neko login
neko init --id gem-rush --title "Gem Rush" --category casual --build dist
# add the SDK to your game, then build
neko validate
neko dev # http://localhost:8080 with a mock platform
neko deploy # test version: only you can open the preview URL
neko submit --checked # publish after the release check (first release: review)
- Install the CLI. When it says a newer version exists, run the same install command again.
neko loginopens the browser and prints an 8-letter code (likeBCDF-GHJK). Sign in, check that the code matches, and click Allow. No browser on this machine? Useneko login --no-browserand open the URL elsewhere.neko initcreatesnekogame.json. Pick the category fromneko categories. The game ID becomes the game's address — choose it carefully.- Add the SDK (Installing the SDK) and call
sdk.init(). neko validatechecks the config, build folder and images without uploading. Fix every error and warning.neko devserves the build athttp://localhost:8080with a mock platform, so purchases and saves work locally.neko deployuploads a test version to check at the preview URL;neko submitpublishes it.
neko deploy uploads a test version. It isn't public: the previewUrl it prints opens only for you (and reviewers), and purchases there are test purchases. When it looks right, publish with neko submit --checked (or upload and publish at once with neko deploy --release --checked). Without --checked they print the release checklist (mobile safe area, languages, SDK loading, mobile touch) and stop. For web games, neko validate and neko deploy flag likely gaps as "Release check" warnings. The first release goes to review; neko status shows the result, and reviewNote explains a rejection. Once the game is live, publishing goes live immediately. If an update breaks, neko rollback switches back to the previous version.Publish with an AI agent
If you build with an AI coding agent (Claude Code, Codex, Cursor…), you can let it do the whole publishing flow. Copy the prompt from Make a game (or the Create a game with AI button in the developer console) and paste it into the agent in your game folder.
The prompt tells the agent to:
- install the CLI and run
neko login— it shows you the 8-letter code, and you approve in the browser; - install the Nekoland skill with
neko skill installand follow it — the skill (SKILL.md) is a compact version of this page written for agents; - create
nekogame.json(it asks you for the game ID), add the SDK, then build and runneko validateandneko dev; - run
neko deployand give you the preview URL.
NEKO_TOKEN environment variable.nekogame.json
The config file at the root of your game project. neko init creates it; neko deploy reads it on every deploy.
{
"gameId": "gem-rush",
"title": "Gem Rush",
"description": "One or two sentences shown on the game page",
"category": "casual",
"orientation": "landscape",
"build": "dist",
"thumbnail": "thumbnail.png",
"products": [
{ "productId": "gems_500", "type": "consumable", "title": "500 Gems", "priceCoins": 100 },
{ "productId": "no_ads", "type": "non_consumable", "title": "Remove Ads", "priceCoins": 300 }
],
"media": [
{ "type": "image", "file": "shots/1.png" },
{ "type": "youtube", "url": "https://youtu.be/..." }
],
"links": { "x": "https://x.com/...", "youtube": "https://youtube.com/@...", "discord": "https://discord.gg/..." }
}
| Field | Description |
|---|---|
gameId | Required. The game's address and identity: 4–40 lowercase letters, digits and hyphens, starting with a letter. Never change it after the first deploy — a new ID is a new game, and saves and purchases don't carry over. |
title | Required. 1–80 characters. |
description | One or two sentences for the game page (up to 2,000 characters). Expected in review. |
category | A category ID from neko categories (e.g. casual). |
orientation | any (default), landscape or portrait. The latter two show a "rotate your device" hint on phones held the other way; the game must still fit any size. |
languages | Languages the game supports (first = default), e.g. ["ko", "en"]. Shown on the game page. Omit it to keep the current value; new games are Korean only (ko). Codes: ko en ja zh-CN zh-TW es fr de pt ru vi th id. |
build | Folder with index.html at its root. Up to 500MB and 1,500 files. |
thumbnail | Square image, at least 256×256 (512×512 recommended), PNG/JPG/WebP, up to 2MB. Expected in review. |
products | In-game products. productId: 1–64 of a-z 0-9 _ .; type: consumable or non_consumable (can't change later); title; optional description; priceCoins: a positive integer. Synced on every deploy — products you remove are taken off sale. A consumable with "variablePrice": true is variable-price: priceCoins is the maximum per purchase and the game passes the amount (integer 1..max) with purchase({ productId, priceCoins }). The confirm dialog and the receipt show that amount. |
media | Optional, up to 10: { "type": "image", "file" } or { "type": "youtube", "url" }. Images must be 16:9, at least 960×540, up to 4MB. The first image is also the home-page card. |
links | Optional x, youtube, discord (https URLs on those sites). |
threads | Optional. Multithreaded Unity/Godot builds are detected automatically and served cross-origin isolated; set true or false only to override. |
media and links replace what's on the server when present and are left alone when omitted.
Game SDK
The SDK (@launchercapital/nekoland-sdk, currently 0.6.2) runs inside your game. Everything else — login, payment, chat — happens on the platform side.
Installing
Script tag — no bundler needed. The platform (and neko dev) serves the SDK at /__neko/sdk.js, the one absolute path that works in a game:
<script src="/__neko/sdk.js"></script>
<script>
const { sdk, NekoSdkError } = window.Nekoland;
</script>
npm — with a bundler:
npm i @launchercapital/nekoland-sdk
import { NekoSDK, NekoSdkError } from "@launchercapital/nekoland-sdk";
const sdk = new NekoSDK();
Unity and Godot have their own wrappers — see Unity & Godot.
Init & lifecycle
// Await init before payments, storage or player (otherwise NOT_INITIALIZED).
// on() and track() are fine earlier. init also closes the platform loading screen.
const player = await sdk.init({ gameId: "gem-rush" }); // the gameId in nekogame.json
sdk.on("pause", () => { /* stop the loop and mute */ });
sdk.on("resume", () => { /* restore sound */ });
sdk.on("player.changed", () => { /* signed in or switched account → reload the save */ });
init(options)resolves with the player. Options:gameId(required),manualReady,mode("auto"|"platform"|"local"),local(see Local testing),timeoutMs(default 10,000) andextraHostOrigins(local development only).pausefires when the tab is hidden, the app goes to the background or a purchase dialog opens — stop the loop and mute.resumefires when it's back.player.changedfires when a guest signs in or the account changes. Reload the save;playerIdmay be different.on()returns an unsubscribe function and is safe to call beforeinit.
Long loads
By default the platform loading screen closes as soon as init finishes. For big games (Unity etc.) keep it up and drive its progress bar:
await sdk.init({ gameId: "gem-rush", manualReady: true });
for (let i = 0; i < assets.length; i++) {
await load(assets[i]);
sdk.loadingProgress((i + 1) / assets.length); // 0..1
}
await sdk.gameReady(); // first screen is up → the loading screen closes
The platform closes the loading screen after 60 seconds regardless.
Play time
sdk.gameplayStart(); // leaving the menu, unpausing
sdk.gameplayStop(); // menu, pause, game over
Optional, for accurate play-time metrics. If you never call them, all time the game is visible counts as play. Both are safe before init.
Platform menu and screen edges
// Keep the HUD out of the platform menu button (top-left)
layoutHud(sdk.insets.menu); // { x, y, width, height } or null
sdk.on("insets.changed", (i) => layoutHud(i.menu));
// Esc / the menu button pauses the game (pause + menu.open)
sdk.on("menu.open", () => showPausedOverlay());
sdk.on("menu.close", () => hidePausedOverlay());
// Need Esc yourself? Turn it off and open the menu from your pause screen
await sdk.init({ gameId: "my-game", escapeMenu: false });
pauseScreen.onMenuClick = () => sdk.openMenu();
- The platform menu button (exit, friends, report, help) sits at the top-left of the game. Its area is
sdk.insets.menu({ x, y, width, height }in CSS px);insets.changedfires when the screen rotates. Keep HUD buttons out of it. - The phone's safe area (notch, home indicator) is
sdk.insets.safeArea({ top, right, bottom, left }, CSS px from each edge). CSSenv(safe-area-inset-*)is 0 inside the game iframe, so use this. Keep HUD and text inside it; the background may extend to the edges. Escopens the menu: the game getsmenu.openandpause, thenmenu.closeandresumewhen it closes. If your game needs Esc, passinit({ gameId, escapeMenu: false })and callsdk.openMenu()from your own pause screen.
Player & login
const { playerId, displayName, avatarUrl, isGuest, locale } = sdk.player;
if (sdk.player.isGuest) {
try {
await sdk.login(); // platform login / sign-up dialog
} catch {
// the player stayed a guest
}
}
sdk.player:playerId(stable per game, different in every game),displayName,avatarUrl(absolute profile photo URL, missing if none — CORS-enabled so you can draw it on a canvas),isGuest,locale(e.g.en-US).sdk.login()asks a guest to log in or sign up and resolves with the member (immediately if already a member); it rejects if they decline. Guests keep their cloud save when they sign up.sdk.platform:"web"(browser),"ios"or"android"(the Nekoland app).sdk.device:"mobile"(phone or tablet, touch) or"desktop"(computer). Use it for touch buttons and UI size; accept touch, mouse and keyboard either way.- Languages: pick strings by the language of
sdk.player.locale(e.g.en-US→en) and fall back to the default language when it isn't supported. List supported languages inlanguagesinnekogame.json. sdk.sandboxis true in a pre-approval preview and in local mode (purchases are test purchases).sdk.isLocalis true in local test mode. Behave the same either way.
Payments
Declare every product in nekogame.json first — neko dev and the platform only sell products listed there. Then follow this grant → save → consume pattern so a purchase is never lost or granted twice:
const CONSUMABLES = new Set(["gems_500"]); // purchases don't carry the type
const owned = new Set(); // permanent products
async function grant(p) {
if (!CONSUMABLES.has(p.productId)) { owned.add(p.productId); return; } // permanent: unlock, never consume
if (p.state !== "completed") return; // already consumed
if (!save.grantedIds.includes(p.purchaseId)) {
save.gems += 500;
save.grantedIds = [...save.grantedIds, p.purchaseId].slice(-50);
await sdk.storage.set({ save: JSON.stringify(save) }); // ① save the grant
}
await sdk.payments.consume(p.purchaseId); // ② then consume
}
// On every start: anything paid for but not granted yet
for (const p of await sdk.payments.getPurchases()) await grant(p);
// Buy button
try {
await grant(await sdk.payments.purchase({ productId: "gems_500" }));
} catch (e) {
if (e instanceof NekoSdkError && e.code === "USER_CANCELLED") return;
// NOT_LOGGED_IN · INSUFFICIENT_COINS · ALREADY_OWNED · PURCHASE_PENDING · PRICE_CHANGED · INVALID_PRICE · PRODUCT_NOT_FOUND · TIMEOUT
}
purchase({ productId, developerPayload? })shows the platform's confirm, top-up and sign-in dialogs outside the iframe. Never draw your own confirm dialog; guests are asked to sign in automatically.developerPayloadcomes back unchanged on the purchase and receipt.getPurchases()returns consumables not yet consumed and every permanent product the player owns. Call it on every start to restore ownership.consume(purchaseId)marks a consumable as granted. Until you call it, it keeps showing up ingetPurchases(). Don't consume non-consumables.- A purchase is
{ purchaseId, productId, state, purchasedAt, developerPayload?, signedReceipt };stateiscompleted,consumedorrefunded. - An item used at once (e.g. "Revive"): grant it as above (count +1, save, consume), then spend it from your own count (−1, save).
For your shop UI, read titles and prices from the platform:
const products = await sdk.payments.getProducts(); // or getProducts(["gems_500"])
// [{ productId, type, title, description, priceCoins, variablePrice?, iconUrl? }]
| Error code | What to do |
|---|---|
USER_CANCELLED | The player closed the dialog. Ignore it. |
NOT_LOGGED_IN | A guest declined to sign in. |
INSUFFICIENT_COINS | The top-up screen was shown and closed without buying cubes. |
ALREADY_OWNED | The player already owns this non-consumable — unlock it. |
PURCHASE_PENDING | Payment still processing. Check getPurchases() later. |
PRICE_CHANGED | The price changed after the dialog was shown. Refetch products and try again. |
INVALID_PRICE | Variable-price product without priceCoins, or not an integer from 1 to the max. |
PRODUCT_NOT_FOUND | Not on sale (or unknown purchase ID for consume). Check nekogame.json and deploy. |
TIMEOUT | The platform didn't answer in time. |
Cloud saves
// Values are strings, so store JSON
const raw = (await sdk.storage.get(["save"])).save; // missing keys → null
const save = raw ? JSON.parse(raw) : { gems: 0, grantedIds: [] };
save.gems += 10;
await sdk.storage.set({ save: JSON.stringify(save) }); // merged with existing keys
- Values are strings — store JSON.
get(keys)returnsnullfor missing keys;set(entries)merges with existing keys. - 1MB per game and player. Going over rejects with
INVALID_ARGUMENT. - Use
sdk.storage, notlocalStorage— it follows the player across devices, and guests keep it when they sign up. Previews keep their storage separate from the live game.
Friends & invites
Friends live on the player's Nekoland account. Friend requests, 1:1 chat and online status are handled by the platform, outside your game. The game only sees friends as playerIds of this game (new in SDK 0.6.0).
// [{ playerId, displayName, avatarUrl?, online, inGame, hasPlayed }] — empty for guests
const friends = await sdk.friends.list();
const playingNow = friends.filter((f) => f.inGame);
// Platform friend picker → invite card in each chosen friend's Nekoland chat
try {
const { sent } = await sdk.friends.invite({ data: roomCode }); // data: up to 500 characters
} catch (e) {
// USER_CANCELLED: picker closed · NOT_LOGGED_IN: a guest declined to log in
}
// Friend request to a player you just played with (platform asks to confirm)
const { status } = await sdk.friends.add(otherPlayerId); // "sent" (waiting for them) or "friends"
// Opened from a friend's invite?
if (sdk.invite) {
const { fromPlayerId, fromName, data } = sdk.invite; // data is null when none was sent
joinRoom(data);
}
friends.list()→[{ playerId, displayName, avatarUrl?, online, inGame, hasPlayed }].online: on Nekoland right now (false when the friend hides their status — status is shown by default);inGame: playing this game now;hasPlayed: has opened this game before. Empty for guests.friends.invite({ data? })opens the platform's friend picker. Each chosen friend gets an invite card in their Nekoland chat that opens your game. Resolves with{ sent }. Guests are asked to log in first (NOT_LOGGED_INif they decline);USER_CANCELLEDif the picker is closed.friends.add(playerId)sends a friend request to another player of this game. The platform shows its own confirm dialog first; if that player had already sent a request, confirming makes you friends right away. Resolves{ status: "sent" | "friends" }("sent" = waiting for them to accept on Nekoland).INVALID_ARGUMENTfor the player's own playerId or a player who can't receive requests,USER_CANCELLEDif the dialog is closed.sdk.inviteis{ fromPlayerId, fromName, fromAvatarUrl?, data }when this session was opened from an invite, otherwisenull.data(up to 500 characters) reaches the friend's game as-is. Use it for a room code — never for anything your server must trust.
Analytics
sdk.track("level_complete", { level: 3, stars: 2 });
sdk.track("tutorial_complete");
- Name: up to 40 lowercase letters, digits and
_, starting with a letter.neko_is reserved for the platform. - Props: a flat object, up to 20 keys (same naming rule); strings up to 100 characters, finite numbers, booleans; 2KB total.
- Safe before
init(up to 100 events are queued) and never throws — invalid events are dropped with a console warning in local mode. - Suggested:
level_start/level_complete/level_fail,tutorial_complete,item_earn/item_spend. Don't send purchases (the platform already knows) or personal data. - Watch them with
neko events(-fto follow) or in the developer console. Load time, errors and drop-off are collected automatically.
Player tokens
To prove who the player is to your own server, send a player token and verify it there:
// In the game
const { token, expiresAt } = await sdk.getPlayerToken(); // valid for 1 hour
await fetch("https://api.example.com/session", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});
The token is an ES256 JWT valid for 1 hour (guests get one too). Request a new one when it expires, and after player.changed. Tokens from neko dev are unsigned and fail verification — test server integration on the preview.
Game servers
@launchercapital/nekoland-server is for your game's own backend: rankings, server-side currency, multiplayer. It has no dependencies and runs on Web Crypto (Node 18+, Bun, Deno, edge runtimes).
npm i @launchercapital/nekoland-server
Verifying players
import { NekoServer, NekoServerError } from "@launchercapital/nekoland-server";
const neko = new NekoServer({
gameId: "gem-rush", // gameId from nekogame.json
apiKey: process.env.NEKO_GAME_API_KEY, // Developer Console → Server integration (server only!)
webhookSecret: process.env.NEKO_WEBHOOK_SECRET, // shown once by `neko webhook set`
});
// Token the game got from sdk.getPlayerToken()
const player = await neko.verifyPlayerToken(token);
// { playerId, displayName, isGuest, sandbox, expiresAt }
verifyPlayerTokenchecks the signature, issuer, game, kind and expiry. UseplayerIdas the account key on your server.- Tokens last 1 hour. For long sessions, verify once and use your own session, or have the game send a fresh token.
- Preview tokens and purchases have
sandbox: true. Keep them apart from real records. - Keep the game API key and webhook secret in server environment variables only — never in game code.
Purchases on your server
Grant from a verified webhook or a receipt the client sent (purchase.signedReceipt), save the grant, then consume:
await neko.verifyReceipt(signedReceipt); // receipt the client sent
await neko.getPurchase(purchaseId);
await neko.listPlayerPurchases(player.playerId); // catch up on missed purchases
await neko.refund(purchaseId, "Accidental purchase"); // developer-initiated refund
consume is safe to call more than once. refund returns the cubes to the buyer, deducts them from your revenue and sends a purchase.refunded webhook. listPlayerPurchases(playerId, { all?, sandbox? }) lists ungranted consumables and owned non-consumables by default.
Webhooks
neko webhook set https://api.example.com/nekoland # prints whsec_… the first time
neko webhook test # sends a ping
neko webhook # URL and recent deliveries
neko webhook secret --rotate # regenerate the secret
// Pass the raw body string, before parsing
const ev = await neko.verifyWebhook(rawBody, req.headers.get("nekoland-signature"));
switch (ev.type) {
case "purchase.completed":
// save the grant (dedupe by ev.data.purchaseId), then consume
await neko.consume(ev.data.purchaseId);
break;
case "purchase.refunded": /* take back what you granted */ break;
case "player.suspended": /* disconnect if online */ break;
case "player.updated": /* display name / guest status changed */ break;
}
- Types:
purchase.completed,purchase.refunded(withreason),player.updated,player.suspended,player.unsuspended,ping. Player events arrive only for players who have opened this game. - Answer 2xx within 10 seconds. Otherwise the delivery is retried after 1 min, 5 min, 30 min, 2 h and 6 h — dedupe by
ev.id(andpurchaseId). - Without the package: the header is
Nekoland-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>; the/v1API takesAuthorization: Bearer <game API key>; tokens and receipts are ES256 JWTs (keys athttps://nekoland.net/.well-known/jwks.json,iss=nekoland,aud= gameId,sub= playerId).
Friends on the server
const player = await neko.verifyPlayerToken(token);
const friends = await neko.getFriends(player.playerId);
// [{ playerId, hasPlayed }] — playerIds in your game
getFriends(playerId) returns the player's Nekoland friends as playerIds of your game, with hasPlayed. Verify the player token first and pass its playerId. Use it for friend leaderboards or "join a friend's room".
Server errors
| NekoServerError.code | Meaning |
|---|---|
INVALID_TOKEN | Bad signature or format |
TOKEN_EXPIRED | Get a new one in the game with sdk.getPlayerToken() |
WRONG_GAME | Token, receipt or webhook belongs to another game |
WRONG_KIND | E.g. a receipt passed where a player token was expected |
INVALID_SIGNATURE | Webhook signature mismatch (check the secret and that the body is unmodified) |
STALE_WEBHOOK | Webhook timestamp too old (possible replay) |
CONFIG | Missing gameId, apiKey or webhookSecret |
API_ERROR | Platform API call failed — see status and apiCode |
Local testing
neko dev serves your build at http://localhost:8080 (--port to change) and the SDK runs in local mock mode:
- the player is "Local Tester";
- products come from
nekogame.json(refresh the page after editing it); - purchases, logins and invites show a small test dialog — no real payment;
- saves and purchases live in the browser's localStorage;
- tokens and receipts are unsigned, so test your game server on the preview after deploying;
- a placeholder for the platform menu button (
≡) sits at the top-left. Clicking it or pressingEscsendsmenu.openandpauselike the platform, closing sendsmenu.closeandresume. WithmanualReady, the console warns ifgameReady()isn't called within 60 seconds; - save rules match the platform (string values, keys up to 128 characters, 1MB counted in UTF-8 bytes).
Test other languages and phone screens from the URL, e.g. http://localhost:8080/?neko_locale=en-US&neko_device=mobile&neko_safearea=47,0,34,0 (safe area as top,right,bottom,left in CSS px; roughly 47,0,34,0 for an iPhone held upright). Combine it with the browser's device mode.
Local mode turns on automatically when the page is opened in a top-level window on localhost, 127.0.0.1 or file:. On another dev server (Vite etc.), pass the options yourself:
await sdk.init({
gameId: "gem-rush",
local: {
products: [{ productId: "gems_500", type: "consumable", title: "500 Gems", priceCoins: 100 }],
guest: true, // start as a guest to test login
autoApprove: false, // true: skip dialogs (automated tests)
friends: [
{ playerId: "p_friend1", displayName: "Mina", online: true, inGame: false, hasPlayed: true },
],
invite: { fromPlayerId: "p_friend1", fromName: "Mina", data: "room-42" },
},
});
Other options: displayName, store to replace localStorage in tests, locale · device · platform · safeArea to set those in code instead of the URL, and purchaseError (e.g. "INSUFFICIENT_COINS") to test failed purchases. Force a mode with mode: "local" or "platform".
Unity & Godot
Both engines wrap the same JS SDK. Run neko sdk add in the project folder (run it again to update), then follow the README it copies.
npm i -g @launchercapital/nekoland-cli
neko sdk add # run in the Unity / Godot project folder
Unity
Unity 2021.3 or later. The package lands in Packages/com.nekoland.sdk. Then use Nekoland ▸ Setup WebGL Project: it installs the Nekoland WebGL template (includes /__neko/sdk.js) and sets Brotli compression with decompression fallback off. In the Editor the SDK runs against a mock platform, so Play just works.
using Nekoland;
async void Start()
{
var player = await NekoSDK.InitAsync(); // always first
NekoSDK.OnPause += () => Time.timeScale = 0;
NekoSDK.OnResume += () => Time.timeScale = 1;
NekoSDK.OnPlayerChanged += async p => await LoadSave();
await LoadSave();
foreach (var p in await NekoSDK.Payments.GetPurchasesAsync()) await Grant(p);
await NekoSDK.GameReadyAsync(); // dismisses the platform loading screen
NekoSDK.GameplayStart();
}
The template inits with manualReady, so the loading screen stays until GameReadyAsync(). Set "build": "Build/web" (your WebGL output folder) in nekogame.json. Add a CJK font for Korean, Japanese or Chinese text.
Godot 4
The addon lands in addons/nekoland. Enable Nekoland SDK in Project Settings ▸ Plugins (registers the NekoSDK autoload) and add <script src="/__neko/sdk.js"></script> to the Web export preset's Head Include. Every async method returns { ok, data } or { ok: false, code, message }.
func _ready():
var res := await NekoSDK.init() # always first
if not res.ok: return
NekoSDK.paused.connect(func(): get_tree().paused = true)
NekoSDK.resumed.connect(func(): get_tree().paused = false)
NekoSDK.player_changed.connect(func(p): load_save())
await load_save()
for p in (await NekoSDK.payments_get_purchases()).data: await grant(p)
NekoSDK.gameplay_start()
Thread Support can stay off; multithreaded exports also work (they're served cross-origin isolated) but older browsers can't run them.
CLI reference
Every command is non-interactive. Add --json for machine-readable output (errors are {"error":{"message","code"}} with a non-zero exit code). neko --help lists everything.
| Command | What it does |
|---|---|
neko login | Sign in through the browser. --no-browser prints the URL only. |
neko logout | Revoke the token and delete it locally. |
neko whoami | The signed-in account and what the token can do. |
neko games | Your games. |
neko categories | Category IDs for nekogame.json. |
neko init | Create nekogame.json: --id --title --category --orientation --build --force. |
neko dev | Local test server with the mock platform. --port (default 8080). |
neko validate | Check the config, build and images without uploading. |
neko deploy | Upload a test version (not public; only you can preview it). --release --checked uploads and publishes (first release: review). |
neko status | Review status, rejection reasons and the live version. |
neko submit [versionId] --checked | Publish a test version you uploaded (first release: request review). Without --checked it only prints the release checklist. |
neko rollback [versionId] | Change the live version (default: the previous approved one). |
neko products | Products on sale and off sale. |
neko purchases | Purchases. --player, --sandbox. |
neko refund <purchaseId> --reason "…" | Refund a purchase. |
neko events | Recent events. -f follows, --names summarizes, --event filters. |
neko stats | Players, retention, revenue, funnel, load time, errors. --days 7|30|90. |
neko open | Open the game page. --play, --console, or a version ID for its preview. |
neko webhook … | Game server webhook: set, test, secret [--rotate], remove. |
neko sdk add | Add or update the SDK in a Unity or Godot project. |
neko skill install | Install SKILL.md for AI agents. --global or --dir. |
Per-game commands use the gameId in ./nekogame.json, or --game <gameId>. Environment variables: NEKO_TOKEN (e.g. a deploy-only token in CI), NEKO_API_URL, NEKO_CREDENTIALS, NEKO_NO_UPDATE_CHECK=1.
Troubleshooting
| Symptom | Fix |
|---|---|
TIMEOUT on init | The page isn't on the platform or localhost. Use neko dev or the preview URL. |
NOT_INITIALIZED | Await init before other calls. |
Authentication failed | Run neko login again. |
TWO_FACTOR_SETUP_REQUIRED | Turn on 2-step verification in your account security settings. |
| "This token can't do that" | The token is limited (deploy-only, read-only or one game). Use a token with the right scope. |
| Not in the game list after deploy | It's in review — neko status. Use the previewUrl meanwhile. |
| Upload interrupted | Run neko deploy again with the same build; only missing parts upload. |
| Products missing in the game | Add them to nekogame.json and deploy. getProducts() returns only products on sale. |
| Assets fail to load | Use relative paths (./assets/x.png). /__neko/ is the only absolute path that works. |
SDK error codes
Calls reject with NekoSdkError; check e.code.
| Code | Meaning |
|---|---|
NOT_INITIALIZED | Called before init() finished |
TIMEOUT | The platform didn't answer in time |
USER_CANCELLED | The player closed the dialog (purchase, login, friend picker) |
INSUFFICIENT_COINS | Not enough cubes, and the top-up screen was closed |
PRODUCT_NOT_FOUND | Unknown product (or purchase ID for consume) |
ALREADY_OWNED | The player already owns this non-consumable |
PURCHASE_PENDING | Payment still processing; check getPurchases() later |
PRICE_CHANGED | Price changed after the dialog was shown; refetch and retry |
INVALID_PRICE | Variable-price priceCoins missing or out of range (1..max) |
NOT_LOGGED_IN | Needs a member account and the player stayed a guest |
INVALID_ARGUMENT | Bad input (e.g. cloud save over 1MB, invite data over 500 characters) |
UNSUPPORTED | The platform doesn't support this call |
INTERNAL | Unexpected platform error |
Build checklist
- Fit any window size: draw at a fixed logical size, scale by
Math.min(innerWidth / W, innerHeight / H), center it, redraw onresize, and capdevicePixelRatioat 2. - Support touch, mouse and keyboard; set
touch-action: none. Start audio after user input. - No external ads, logins or payment methods.
Revenue & payouts
Players buy cubes from Nekoland and spend them on your products. 1 cube = $0.01 of sales. You receive 70% of sales, paid out once your unpaid sales reach $100. Developer-initiated refunds are deducted from revenue; test purchases in previews and local mode don't count.
Track sales in the developer console, Revenue or with neko stats. Terms: revenue policy.