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.
  • playerId is 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:

bash
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)
  1. Install the CLI. When it says a newer version exists, run the same install command again.
  2. neko login opens the browser and prints an 8-letter code (like BCDF-GHJK). Sign in, check that the code matches, and click Allow. No browser on this machine? Use neko login --no-browser and open the URL elsewhere.
  3. neko init creates nekogame.json. Pick the category from neko categories. The game ID becomes the game's address — choose it carefully.
  4. Add the SDK (Installing the SDK) and call sdk.init().
  5. neko validate checks the config, build folder and images without uploading. Fix every error and warning.
  6. neko dev serves the build at http://localhost:8080 with a mock platform, so purchases and saves work locally.
  7. neko deploy uploads a test version to check at the preview URL; neko submit publishes 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:

  1. install the CLI and run neko login — it shows you the 8-letter code, and you approve in the browser;
  2. install the Nekoland skill with neko skill install and follow it — the skill (SKILL.md) is a compact version of this page written for agents;
  3. create nekogame.json (it asks you for the game ID), add the SDK, then build and run neko validate and neko dev;
  4. run neko deploy and give you the preview URL.
The agent never needs your password or a token in a file. For CI, create a deploy-only token in the developer console and pass it as the 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.

json
{
  "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/..." }
}
FieldDescription
gameIdRequired. 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.
titleRequired. 1–80 characters.
descriptionOne or two sentences for the game page (up to 2,000 characters). Expected in review.
categoryA category ID from neko categories (e.g. casual).
orientationany (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.
languagesLanguages 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.
buildFolder with index.html at its root. Up to 500MB and 1,500 files.
thumbnailSquare image, at least 256×256 (512×512 recommended), PNG/JPG/WebP, up to 2MB. Expected in review.
productsIn-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.
mediaOptional, 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.
linksOptional x, youtube, discord (https URLs on those sites).
threadsOptional. 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:

html
<script src="/__neko/sdk.js"></script>
<script>
  const { sdk, NekoSdkError } = window.Nekoland;
</script>

npm — with a bundler:

bash
npm i @launchercapital/nekoland-sdk
js
import { NekoSDK, NekoSdkError } from "@launchercapital/nekoland-sdk";

const sdk = new NekoSDK();

Unity and Godot have their own wrappers — see Unity & Godot.

Init & lifecycle

js
// 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) and extraHostOrigins (local development only).
  • pause fires when the tab is hidden, the app goes to the background or a purchase dialog opens — stop the loop and mute. resume fires when it's back.
  • player.changed fires when a guest signs in or the account changes. Reload the save; playerId may be different.
  • on() returns an unsubscribe function and is safe to call before init.

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:

js
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

js
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

js
// 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.changed fires 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). CSS env(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.
  • Esc opens the menu: the game gets menu.open and pause, then menu.close and resume when it closes. If your game needs Esc, pass init({ gameId, escapeMenu: false }) and call sdk.openMenu() from your own pause screen.

Player & login

js
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 in languages in nekogame.json.
  • sdk.sandbox is true in a pre-approval preview and in local mode (purchases are test purchases). sdk.isLocal is 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:

js
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. developerPayload comes 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 in getPurchases(). Don't consume non-consumables.
  • A purchase is { purchaseId, productId, state, purchasedAt, developerPayload?, signedReceipt }; state is completed, consumed or refunded.
  • 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:

js
const products = await sdk.payments.getProducts(); // or getProducts(["gems_500"])
// [{ productId, type, title, description, priceCoins, variablePrice?, iconUrl? }]
Error codeWhat to do
USER_CANCELLEDThe player closed the dialog. Ignore it.
NOT_LOGGED_INA guest declined to sign in.
INSUFFICIENT_COINSThe top-up screen was shown and closed without buying cubes.
ALREADY_OWNEDThe player already owns this non-consumable — unlock it.
PURCHASE_PENDINGPayment still processing. Check getPurchases() later.
PRICE_CHANGEDThe price changed after the dialog was shown. Refetch products and try again.
INVALID_PRICEVariable-price product without priceCoins, or not an integer from 1 to the max.
PRODUCT_NOT_FOUNDNot on sale (or unknown purchase ID for consume). Check nekogame.json and deploy.
TIMEOUTThe platform didn't answer in time.
If your game has a server, grant on the server from verified webhooks or receipts — never from the client's word alone. See Purchases on your server.

Cloud saves

js
// 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) returns null for missing keys; set(entries) merges with existing keys.
  • 1MB per game and player. Going over rejects with INVALID_ARGUMENT.
  • Use sdk.storage, not localStorage — 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).

js
// [{ 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_IN if they decline); USER_CANCELLED if 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_ARGUMENT for the player's own playerId or a player who can't receive requests, USER_CANCELLED if the dialog is closed.
  • sdk.invite is { fromPlayerId, fromName, fromAvatarUrl?, data } when this session was opened from an invite, otherwise null.
  • 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.
Only add friend features if the game benefits (co-op, rooms, "friends playing now"). The platform already shows friends and chat. Your server can read the same list with getFriends.

Analytics

js
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 (-f to 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:

js
// 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).

bash
npm i @launchercapital/nekoland-server

Verifying players

ts
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 }
  • verifyPlayerToken checks the signature, issuer, game, kind and expiry. Use playerId as 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:

ts
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

bash
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
ts
// 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 (with reason), 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 (and purchaseId).
  • Without the package: the header is Nekoland-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>; the /v1 API takes Authorization: Bearer <game API key>; tokens and receipts are ES256 JWTs (keys at https://nekoland.net/.well-known/jwks.json, iss = nekoland, aud = gameId, sub = playerId).

Friends on the server

ts
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.codeMeaning
INVALID_TOKENBad signature or format
TOKEN_EXPIREDGet a new one in the game with sdk.getPlayerToken()
WRONG_GAMEToken, receipt or webhook belongs to another game
WRONG_KINDE.g. a receipt passed where a player token was expected
INVALID_SIGNATUREWebhook signature mismatch (check the secret and that the body is unmodified)
STALE_WEBHOOKWebhook timestamp too old (possible replay)
CONFIGMissing gameId, apiKey or webhookSecret
API_ERRORPlatform 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 pressing Esc sends menu.open and pause like the platform, closing sends menu.close and resume. With manualReady, the console warns if gameReady() 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:

js
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.

bash
npm i -g @launchercapital/nekoland-cli
neko sdk add   # run in the Unity / Godot project folder
The Unity and Godot wrappers cover init, lifecycle, player and login, payments, cloud saves and analytics. Friends and invites are JS SDK only for now.

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.

csharp
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 }.

gdscript
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.

CommandWhat it does
neko loginSign in through the browser. --no-browser prints the URL only.
neko logoutRevoke the token and delete it locally.
neko whoamiThe signed-in account and what the token can do.
neko gamesYour games.
neko categoriesCategory IDs for nekogame.json.
neko initCreate nekogame.json: --id --title --category --orientation --build --force.
neko devLocal test server with the mock platform. --port (default 8080).
neko validateCheck the config, build and images without uploading.
neko deployUpload a test version (not public; only you can preview it). --release --checked uploads and publishes (first release: review).
neko statusReview status, rejection reasons and the live version.
neko submit [versionId] --checkedPublish 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 productsProducts on sale and off sale.
neko purchasesPurchases. --player, --sandbox.
neko refund <purchaseId> --reason "…"Refund a purchase.
neko eventsRecent events. -f follows, --names summarizes, --event filters.
neko statsPlayers, retention, revenue, funnel, load time, errors. --days 7|30|90.
neko openOpen the game page. --play, --console, or a version ID for its preview.
neko webhook …Game server webhook: set, test, secret [--rotate], remove.
neko sdk addAdd or update the SDK in a Unity or Godot project.
neko skill installInstall 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

SymptomFix
TIMEOUT on initThe page isn't on the platform or localhost. Use neko dev or the preview URL.
NOT_INITIALIZEDAwait init before other calls.
Authentication failedRun neko login again.
TWO_FACTOR_SETUP_REQUIREDTurn 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 deployIt's in review — neko status. Use the previewUrl meanwhile.
Upload interruptedRun neko deploy again with the same build; only missing parts upload.
Products missing in the gameAdd them to nekogame.json and deploy. getProducts() returns only products on sale.
Assets fail to loadUse relative paths (./assets/x.png). /__neko/ is the only absolute path that works.

SDK error codes

Calls reject with NekoSdkError; check e.code.

CodeMeaning
NOT_INITIALIZEDCalled before init() finished
TIMEOUTThe platform didn't answer in time
USER_CANCELLEDThe player closed the dialog (purchase, login, friend picker)
INSUFFICIENT_COINSNot enough cubes, and the top-up screen was closed
PRODUCT_NOT_FOUNDUnknown product (or purchase ID for consume)
ALREADY_OWNEDThe player already owns this non-consumable
PURCHASE_PENDINGPayment still processing; check getPurchases() later
PRICE_CHANGEDPrice changed after the dialog was shown; refetch and retry
INVALID_PRICEVariable-price priceCoins missing or out of range (1..max)
NOT_LOGGED_INNeeds a member account and the player stayed a guest
INVALID_ARGUMENTBad input (e.g. cloud save over 1MB, invite data over 500 characters)
UNSUPPORTEDThe platform doesn't support this call
INTERNALUnexpected 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 on resize, and cap devicePixelRatio at 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.