Developers

Developer documentation

Everything you need to get a browser game running on the platform.

spiced.gg build helper
1 / 8

Developer guide · ~60 seconds

Publish your game on spiced.gg.

Four steps to package a web build that’s accepted on the first upload.

▶ Use the timeline to step, or press play
Intro

Build requirements

Games are uploaded as .zip archives.

The archive must contain an index.html file at its root.

That’s it.

Maximum ZIP size: 1 GB.

The build is validated when it is uploaded. If any validation check fails, the entire build is rejected, and the errors are shown on the Builds page.

Allowed file types

Only files with one of the following extensions are served:

html htm js mjs wasm data symbols mem
css json txt xml md toml png jpg
jpeg webp gif avif ico cur mp3 ogg
wav webm mp4 m4a woff woff2 ttf otf
pck unityweb bundle bin atlas glb gltf

Any other file is left out of the build. It does not cause a rejection, and the Builds page lists what was left out. If your game loads such a file at runtime, such as an .svg image, convert it to a supported type.

Archives inside the build are left out too, never unpacked: .zip, .gz, .tgz, .tar, .bz2, .xz, .7z, .rar, .jar, .war, .apk. Common leftovers are left out without being listed: .DS_Store, Thumbs.db, desktop.ini, _headers, _redirects, *.import, __MACOSX/.

Duplicate paths by case

The build cannot contain two paths that differ only by capitalization, such as assets/player.png and assets/Player.png. The game is served from a case-insensitive origin, so those paths would conflict.

Also rejected

  • Symlinks and hard links.
  • Windows reserved names as a file or folder name, such as con.js or aux.png, and names ending in a space or a dot.
  • Two entries that resolve to the same path.
  • A path over 1,024 characters, a single name over 255, or a name with a control character.
  • Absolute paths and paths that climb out of the archive with “..”.

Size limits

LimitMaximum
ZIP size1 GB
Files in the archive10,000
Unpacked size2 GiB
Any individual file1 GiB
Any HTML file8 MiB
Compression ratio, per file200:1

The compression-ratio limit can affect large files containing mostly empty or repetitive data.

What a big build costs you in bandwidth

A bigger build costs more bandwidth every time somebody plays it. Your account includes 2 GB of serving a month across all of your games, or 25 GB for a founding developer. At the ordinary grant, a build near the size limit is spent by its second new player, while a 40 MB one covers about fifty. Builds are cached after a player’s first load, so it is new players that cost you.

Ship the smallest build you can: compress textures and audio, drop unused assets, and stream long audio rather than bundling it. If your game is genuinely large, add bandwidth packs (25 GB each for $5.00/month) on the Subscriptions page before it goes out, rather than after discovering it de-ranked. Pricing explains what happens when the pool runs out.

HTML and scripts

The entry document must contain exactly one <head> element and must parse unambiguously. The platform injects the Spiced SDK into it, so malformed or incomplete HTML is rejected rather than repaired.

Scripts must be files in the build

Inline JavaScript never runs, and an upload containing an inline <script> block is rejected. Every script must be its own file in the archive:

<script src="./main.js"></script>

This is the default export from some engines: Godot’s stock web shell boots from an inline block, and single-file exports inline the whole game. Move that code into a .js file in the archive and load it with src.

The same applies to script written into an attribute, which is rejected for the same reason: an event-handler attribute such as onclick or <body onload="init()">, and a javascript: URL in an href or a form action. Attach these from your .js file with addEventListener instead.

External scripts

A script tag cannot load from anywhere but the archive. That rules out absolute URLs, protocol-relative URLs (//...), data: URLs and blob: URLs in a <script> tag, which are rejected at upload. Bundle every dependency into the build. At play time a blob: URL your code creates is allowed by the policy.

Frames

An <iframe> can load a page from your own build, by relative path. A frame onto any other site never loads, so an upload with a page that frames one is rejected. Upload the game files themselves, not a page that embeds a game hosted elsewhere.

Reference: the Content Security Policy behind these rules

The game runs under this policy, which is served with every build. The one per-game exception is the backend allowlist under Your own backend, which admits network calls to named hosts and nothing else:

script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' blob:

'self' means a file inside your archive, and there is no 'unsafe-inline'. Both kinds of script are therefore rejected at upload rather than at runtime, because the alternative is a build that passes every check and then shows a black screen.

Data blocks are fine. A <script type="application/json"> element is not executable, so the policy does not apply to it. That is how the platform’s own Godot shell passes its engine configuration to its boot file.

The HTML scan also rejects unterminated comments, unterminated tags and unterminated script elements, for the same reason: the SDK has to be spliced into a document whose structure is unambiguous.

platform.json

platform.json is optional. A ZIP containing index.html at its root is a valid build without one.

Add one when you need engine metadata, viewport hints or remote configuration:

{
  "engine": "godot",
  "name": "My Game"
}

It must sit at the root of the archive and may be up to 16 KB. Unknown keys and values with the wrong type cause the entire manifest to be rejected.

Reference: every platform.json key
KeyDescription
engineName of the engine used to build the game. Maximum 40 characters. Compared with the engine on your listing; a mismatch is shown to moderators.
namePreferred display name for the build. Maximum 200 characters. Validated but not used: players see the title on your listing.
min_widthMinimum viewport width the game is designed to support. Integer, maximum 7680. Shown to moderators and on the store page; never enforced.
min_heightMinimum viewport height the game is designed to support. Integer, maximum 4320. Shown to moderators and on the store page; never enforced.
requires_cross_origin_isolationSet to true when the build requires SharedArrayBuffer, such as threaded Godot or Unity Web builds. Validated but not used: the play surface sends the isolation headers to every game.
configThe settings getConfig() may return, each with a default value that fills the Remote Config editor. getConfig() never returns these defaults, so your game keeps its own. Up to 64 settings, with names of 1 to 64 characters. Required to use remote config at all: a config can only set settings declared here, and a build that declares none cannot be configured. A live config is a JSON object of at most 64 KB.

Phones and small screens

The play page is one 44 pixel bar and then your game. The frame fills everything under it, at whatever size and shape the device is. Nothing scales, letterboxes or rotates your build for you, so a game that only lays out at one size gets cropped rather than fitted.

Two declarations describe a build's mobile support and neither of them enforces anything, so both are promises you keep in the build itself:

  • min_width and min_height in platform.json are shown to moderators and on the store page, as “Best at 1280 × 720 or larger”. Nothing stops a phone opening a game that declares 1280×720. Declare the smallest page you actually lay out for, not the desktop one you designed first.
  • This game plays on phones and tablets on the listing is your own claim, and it is what players filter the catalogue on. Leave it unticked until the game is playable on a phone you have held.
Every action needs a control that is not a key. A phone has no keyboard, no Escape and no Tab, and a game whose only way to commit a move is Enter is a game a phone player cannot finish. Drive the whole loop with taps once, on a real page, before ticking the box.
Reference: tuning a build for a phone
  • Lay out from the smallest page up. Reserve the controls and the status you cannot lose, then give what is left to the playfield. Sizing the playfield as a fixed fraction first is what leaves a short phone with a board nobody can hit.
  • Handle both shapes, or commit to one. The frame is whatever aspect the device gives it, and the platform does not lock orientation. A portrait arrangement is usually a different arrangement, not the landscape one scaled down: a panel that sits beside the playfield on a desktop generally has to go under it, or behind a button.
  • Give touch targets room. The platform's own play bar is 44 pixels tall, which is a fair floor for anything a thumb has to hit. Two controls that are comfortable with a mouse can be one target on a phone.
  • Nothing may depend on hover. A tooltip that only appears on hover does not exist on a touch device, so anything it says has to be readable some other way.
  • Check the text at the smallest size you declare. Copy that fits a desktop panel is the first thing to run off the edge of a phone one. Measuring the strings is cheaper than looking at them: a string's width in the font at the size it is drawn is a number your build can assert on.
  • Test at a real page size, not a scaled desktop one. A browser window narrowed to 400 pixels is not a phone; use the device emulation in your browser's dev tools, or a phone. 360×640 and 390×844 cover most of what players arrive on.
  • The frame is sandboxed. It runs with allow-scripts, allow-same-origin and allow-pointer-lock, and is allowed fullscreen, gamepad, autoplay and cross-origin isolation. Pointer lock is not something a touch device offers, so a build that needs it needs a second way to aim.

Spiced SDK

The SDK is optional. A game that never calls window.spiced is a complete game on the platform. It adds player identity, saved games, leaderboards, analytics events, remote configuration and in-game items.

await window.spiced.ready();

if (await window.spiced.isSignedIn()) {
  const user = await window.spiced.getUser();
  console.log(user.displayName);
}

The platform injects the SDK into the entry document when a build is uploaded. Do not add a <script> tag for the SDK to your game. The version is pinned to the build when it is injected, so upload the build again to receive a newer one.

The SDK is not a security boundary. It runs in the same origin as your game and can be modified or replaced by game code. Security-sensitive validation is performed on the server.

Reference: every window.spiced member
MemberDescription
versionSDK version. Currently 1.
ready()Returns a promise that resolves to true after the SDK handshake completes, regardless of sign-in state.
isSignedIn()Returns a promise resolving to true when the current session has a signed-in player.
getUser()Returns { id, displayName, avatarUrl? }. Rejects when the player is signed out.
track(name, attributes)Records an analytics event. Fire-and-forget. See Events below.
load()Returns the player’s saved data for this game as a string, or null when no save exists. Data is returned exactly as stored and never parsed, so JSON you saved comes back as text.
save(data)Saves game data, up to 256 KB: a string, or any JSON-serialisable value, which is stored as its JSON text. An empty save is refused. The most recent write wins. Returns { version }, which increments with every write. At most 120 writes per session per hour; more fail with HTTP 429.
reportEvent(action, value?)Records an action for Verified leaderboard replay checks. The action is included with the next submitScore call.
submitScore(score)Submits a whole-number score and any actions recorded since the previous submission. Returns { accepted, score, submittedAt }; the platform never reports what it made of the submission beyond taking it.
getScores(options?)Returns { entries }, sorted highest-first, one row per player as { rank, score, playerId, verified, submittedAt }. Player IDs are scoped to the game. Pass { period: 'daily' } for today’s board instead of the all-time one.
getConfig()Returns { version, config } for the game’s remote configuration. Returns {} with version 0 when no configuration has been set; the defaults in platform.json are never returned. A change takes up to a minute to reach new sessions.
getItems()Returns { items } for this game’s in-game items, each carrying owned for the current player and a status that is always 'active', since a draft, in-review or retired item is never returned. Only approved items on a published listing appear, so your own drafts will not.
purchaseItem(itemId, currency?)Asks the platform to sell the player an item, with currency of 'cash' (default) or 'spice'. The platform shows the price and asks the player; your game cannot complete a purchase on its own. Resolves when the request is handed over, not when the purchase finishes. A cash purchase leaves the page for Stripe: your game is unloaded and starts from scratch when the player returns, and onItemsChanged does not fire. Save before calling it, and read getItems() at startup.
onItemsChanged(fn)Registers a callback for “this player’s items may have changed”, after a Spice purchase. It carries no detail: call getItems() again when it fires. Returns a function that removes the listener.
signalReady()Tells the platform shell that the game has finished loading and allows it to remove the loading spinner. Optional.
getIdentityToken()A short-lived signed token naming this player to the game’s own backend. See Your own backend.
openExternal(url)Requests that the platform shell open an external URL. The request is routed through the parent frame because the game iframe does not have popup permission.
Reference: errors

A call that fails rejects with an Error carrying status, the HTTP status (0 when no response came back), and code:

codestatusWhen
rate_limited429Too many calls; try again later.
request_failedThe HTTP statusThe platform refused the call, such as 403 for a score from your own account.
network0The request could not be completed.
no_session0Nobody is signed in.
save_too_large0A save over 256 KB, refused before it is sent.
config_unavailable0A signed-out getConfig() got no answer.
spiced.save(data).catch((error) => {
  if (error.code === 'rate_limited') retryLater();
});
Reference: player IDs and the playtime heartbeat

The id returned by getUser() is scoped to your game. It is not a global Spiced account ID: the same player receives a different ID in another game, and the ID cannot be used to retrieve an email address or other account information.

For signed-in players, the SDK sends a heartbeat to /sdk/heartbeat approximately once per minute while the game is open and visible. It is not exposed through window.spiced and does not contain data supplied by the game. Playtime is calculated from the server’s clock, so calling, modifying or suppressing SDK methods does not change the recorded time. Signed-out sessions do not send heartbeats.

Signed-out players

Games can be played without an account: a free listing, and the demo of any listing, let a player skip sign-in entirely. Check isSignedIn() before calling account-dependent methods. When no player is signed in:

  • track() does nothing.
  • getUser(), getIdentityToken(), save(), load(), submitScore(), getScores() and getItems() reject.
  • getConfig() works as usual.
  • purchaseItem() resolves, and the platform shows the player a notice with links to create an account or sign in (in a demo, a notice that items cannot be bought there). No purchase starts and onItemsChanged does not fire.
  • ready(), isSignedIn(), reportEvent(), signalReady() and openExternal() work as usual.

A signed-in demo session is different: the player has an account, but the session cannot write. getUser() and getScores() still succeed, load() resolves null as if there were no save, and only submitScore() and save() reject.

A playtest (beta) build session has its own saves: save() and load() work, but never reach the released game’s. submitScore() rejects, purchaseItem() starts no purchase, and track() records events but earns the player no Spice.

Events

Use track() to record analytics events. Names are lowercase words separated by dots, and an event may carry up to 10 attributes. A session may send at most 600 events per hour; events past that limit are dropped the same way an invalid event is.

window.spiced.track('level.complete', {
  level: '3',
  time_ms: '42000',
});

If an event fails validation, the entire event is dropped. track() still resolves, so invalid events appear as missing analytics rather than runtime errors.

Reference: event name and attribute validation

Event names use lowercase letters, contain words separated by ., are no longer than 64 characters, and must match:

^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$

Attribute keys start with a lowercase letter, contain lowercase letters, numbers or underscores, are no longer than 32 characters, and must match:

^[a-z][a-z0-9_]*$

Attribute values may be strings, numbers or booleans, and are converted to strings before being recorded. A value longer than 200 characters is rejected. Objects and arrays are not supported as attribute values.

Leaderboards

submitScore() and getScores() are available to every game, and every game automatically gets a leaderboard on its store page once 3 players have scored. No additional setup is required.

spiced.submitScore(4200);

The store leaderboard:

  • shows the top 25 players
  • uses Spiced display names
  • shows each player’s best score
  • adds a Today tab beside the all-time board once a score has been submitted today, covering one UTC day and resetting at midnight UTC

You do not need to build a leaderboard UI yourself, or track personal bests. Use getScores() when you want a leaderboard inside the game: it resolves to { entries } holding the same top 25, one row per player at their best score, with game-scoped player IDs rather than display names.

const { entries } = await spiced.getScores();
for (const row of entries) {
  draw(row.rank, row.score, row.playerId);
}

// Today's board instead, resetting at midnight UTC.
const today = await spiced.getScores({ period: 'daily' });

Both boards hold the same scores; the daily one is narrowed to the current UTC day. Nothing needs turning on, and a game that never asks for the daily board is unaffected by it.

Submit a score when the game enters its end state, not on every frame while the end state is active. Scores must be whole numbers, and submitScore() rejects when nobody is signed in. On a free listing’s full build that score still goes on the board at once as “Anon #NNNN”, and an account the player then makes puts their name on it. A signed-out score in a demo is dropped.

A stored score is not necessarily a visible score. A resolved submitScore() promise means the score was stored, not that it passed every leaderboard check. The SDK does not expose which server-side check affected a submission. A board is also not a roster of named players: a player can take their name off leaderboards in their profile settings, and their rows then appear under an anonymous label, and a score set while nobody was signed in appears the same way.

Worked example: submitting once per run

Incorrect:

// Posts every frame while the game-over screen is displayed.
if (world.phase === 'over') {
  spiced.submitScore(world.score);
}

Correct:

// Submit once when the run transitions from playing to over.
const wasPlaying = world.phase === 'playing';

update(world, step);

if (wasPlaying && world.phase === 'over') {
  spiced.submitScore(world.score);
}

Because submitScore() can reject, attach a .catch() handler if you do not otherwise handle the returned promise.

spiced.submitScore(score).catch(() => {
  // Handle or ignore submission failure.
});
Reference: server-side scoring rules
  • Fractional scores are rejected rather than rounded. Use Math.round(score) if necessary.
  • A player may submit at most 60 scores per hour to one game, counted across all their sessions, and refused scores count too. Submissions above that limit fail with HTTP 429.
  • Scores submitted from sessions less than 10 seconds old are flagged and excluded from the public leaderboard. If your rounds are genuinely shorter than that, your board looks empty while every submission returns success.
  • The account that publishes the game cannot compete on it. submitScore() from that account fails with 403, and it never appears on the board. Check the board from a second account.
  • Every submitted score is associated with its play session and its verification state.
Reference: score direction and negative scores

Scores are always sorted highest-first. For games where lower scores are better, such as lap times or stroke counts, submit a negative score. The supported negative range extends to:

-1,000,000,000,000

The store leaderboard displays the integer you submit, including negative values.

Verified leaderboards

Verified is an optional anti-cheat system that validates score submissions against a declared set of game rules. Configure it from the Anti-cheat tab on your game’s page in the developer dashboard, where you declare a maximum reachable score and, for each action, its maxCount, maxValue and minIntervalMs.

During gameplay, call reportEvent() for each declared action. The action log is attached to the next score submission, and the server replays it against the manifest before confirming the score.

spiced.reportEvent('coin.collect', 10);
spiced.reportEvent('level.finish');

// Later:
spiced.submitScore(4200);
Reference: action naming and log limits

A manifest can contain up to 50 rules. Action names use lowercase dotted notation, contain letters, numbers and underscores, are no longer than 64 characters, and must match an action declared in the manifest. An undeclared action fails replay validation.

The action log is limited to:

  • 2,000 entries per round
  • 64 KB per round

If either limit is exceeded, the SDK discards the action log for that round. The score is still submitted, but it remains unverified.

Action timestamps are generated by the SDK, not by the game, and submitting a score consumes the current action log.

Your own backend

A build cannot reach the network beyond the platform API by default. To call a server you run, list its origins on the listing page under Backend origins: plain https:// origins, up to 4. A moderator admits them at review, and once admitted the game’s policy allows network calls to them, so fetch, WebSocket (wss:// on the same host is covered) and EventSource to those hosts work. Nothing else in the policy changes. Your server has to answer CORS for the game’s origin, which is the Origin header on each request.

Third-party ad networks are not allowed on Spiced, so an ad network’s servers are not admitted.

Knowing who is calling

Send getIdentityToken() as a bearer token and verify it on the server. It is a JWT signed with ES256, valid for 5 minutes, with keys published at /sdk/jwks on the API origin (). Check iss is the API origin, aud is your game id, and alg is ES256; sub is then the same game-scoped player id getUser() returns. The token grants nothing on the platform API.

// In the game
const token = await window.spiced.getIdentityToken();
await fetch('https://api.example.com/score', {
  method: 'POST',
  headers: { Authorization: 'Bearer ' + token },
  body: JSON.stringify({ score }),
});

// On your server (Node, jose)
import { createRemoteJWKSet, jwtVerify } from 'jose';
const keys = createRemoteJWKSet(new URL('/sdk/jwks', ''));
const { payload } = await jwtVerify(token, keys, {
  issuer: '',
  audience: GAME_ID,
  algorithms: ['ES256'],
});
const playerId = payload.sub;

Seeing your own traffic

You already get traffic without any of this: your game’s Analytics tab shows plays, sessions and unique players on its own, and track() records any custom events you add. Reach for a beacon only when you want that data in an analytics service you already run.

To do that, add that service’s origin under Backend origins above, then send it a small fire-and-forget beacon from the game. Send counts and events, not players: they have not agreed to a third party profiling them, so keep their identity and what they do in the game out of the payload.

// In the game: a fire-and-forget beacon to your own service
const body = JSON.stringify({ event: 'play_start', at: Date.now() });
navigator.sendBeacon('https://analytics.example.com/collect', body);

// Or fetch, when you need the response
await fetch('https://analytics.example.com/collect', {
  method: 'POST',
  keepalive: true,
  headers: { 'content-type': 'application/json' },
  body,
});

This sends data out to a host you named. Loading a third party’s hosted analytics tag is the opposite direction and stays blocked.

Do not load code from your backend. The game must run only the code in the reviewed build. Fetching script from your own server and running it is not allowed, even though the policy does not block it.

Where to go next