Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Products

A product sells for the platform’s Spawnites, which a player buys with money, a hundred to the dollar. The wallet a game keeps with addCoins and spendCoins is the game’s own currency, earned in play, and buys no product.

A game declares the products it sells in one file of its source, src/products.ts, with defineProducts. A game with rooms also exports the list from its scene, since a room reads what it sells from the scene, as What the room’s scene exports lists. spawnite simulate --buy reads the simulated scene’s, and the file for a scene that exports none. A product has a key, a name, a kind, a price in Spawnites and an icon. The platform copies the declaration into its catalog each time a version of the game is published, and the purchase sheet reads its price from the catalog, never from the page.

spawnite build writes the declaration into the bundle’s manifest, which spawnite upload and spawnite publish send, and the MCP’s upload_version and publish_version too, as Keys, offers and the catalog says. The engine checks and types the declaration, a shop buys through useProducts, the player app draws the purchase sheet, and the backend sells both kinds, as Buying from the shop and Delivering a consumable say. A game reads what a player owns from the platform, as Ownership says.

Roblox, Unity, Unreal, Godot and PlayCanvas keep what a game sells outside its code: Roblox’s Developer Products and Game Passes live on the Creator Dashboard, and the others hand the job to Apple’s, Google’s or Steam’s console. Fortnite and Steam keep it in the game’s code. The engine keeps it in the game’s source, so the agent building the game edits one file beside the code that grants the product, and the game runs and tests with no account.

defineProducts takes the game’s products by key and returns them as they are, with each key typed:

packages/engine/test/outside/products.ts
// src/products.ts
import { defineProducts } from "@spawnite/engine";
import { addItem } from "@spawnite/engine/inventory";
import { z } from "@spawnite/schema";
export const products = defineProducts({
sword: {
name: "Sword of Dawn",
kind: "durable",
price: 400,
icon: "icons/sword.png",
},
potion: {
name: "Health potion",
kind: "consumable",
price: 100,
icon: "icons/potion.png",
// Declines, before it changes anything, when the bag is full.
grant: ({ writer, player }) =>
addItem(writer, player, { item: "potion", count: 1 }) === 1
? undefined
: "decline",
},
founderBanner: {
name: "Founders' banner",
kind: "consumable",
price: 900,
icon: "icons/banner.png",
stock: 500, // across every room and world of the game
perPlayerLimit: 1,
grant: ({ writer, player }) =>
addItem(writer, player, { item: "banner", count: 1 }) === 1
? undefined
: "decline",
},
seasonPass: {
name: "Season pass",
kind: "consumable",
price: 500,
icon: "icons/season.png",
// What the purchase is for: checked at the buy, handed to grant.
context: z.object({ season: z.string() }),
grant: ({ writer, player, context }) =>
addItem(writer, player, {
item: "season-pass",
count: 1,
data: { season: context.season },
}) === 1
? undefined
: "decline",
},
oldCape: {
name: "Founder's cape",
kind: "durable",
price: 300,
icon: "icons/cape.png",
retired: true, // no longer for sale; its owners keep it
},
});

Each product holds the following fields:

  • name: what the purchase sheet calls it, at most 50 characters. The name passes the same word filter as a game’s name.
  • kind: "durable" or "consumable", as the two kinds describe.
  • price: whole Spawnites, from 10 to 10,000. A hundred Spawnites are a dollar.
  • icon: a picture’s path under the game’s public/ folder, such as icons/sword.png: a .png, .jpg, .jpeg, .webp, .avif, .gif or .svg file.
  • stock: how many the game sells across all its rooms and worlds, a whole number from 1. Left out, the game sells without a limit.
  • perPlayerLimit: how many of a consumable one player buys, a whole number from 1.
  • context: a consumable’s schema of what a purchase is for, as a purchase’s context says.
  • retired: true stops the product selling. Its owners keep it.
  • grant: a consumable’s function that gives the player what they bought. A durable product has none.

A key is a letter, then letters, digits, _ or -, at most 64 characters, such as sword or gems_500. A game declares at most 200 products. A product is one unit: a game that sells five potions at once declares “5 potions” as a product of its own, as a Roblox game does.

defineProducts throws, naming the first product it refuses and what it breaks, for a price out of bounds, a kind other than the two, a consumable with no grant, and more than 200 products. It also throws for a stock or perPlayerLimit that is not a whole number from 1, and for a durable product with a grant, a perPlayerLimit or a context. The upload checks the rest of each product: the key’s shape, the name’s length and its words, the context’s schema, and that the bundle holds the icon.

The kinds take Apple’s, Google’s and Unity’s names, and are named by where the thing lives:

  • A durable product is bought once and kept, like a Roblox Game Pass or an Apple non-consumable. The platform records that the player owns it, so a save wipe loses nothing, and buying it again is refused.
  • A consumable can be bought again and again, like a Roblox Developer Product or an Apple consumable. The game gives the player what they bought in grant, and keeps it in the save. What it granted is the game’s from then on, whether the player uses it up or keeps it.

grant runs on the game’s authority: the room, or the page in a game with no rooms. It receives the following, as a player save’s restore does:

  • world: the world the buyer plays in.
  • player: the buyer’s player entity.
  • account: the platform’s id for the buyer in this game.
  • writer: what grant writes with, as every write verb takes it first.
  • context: the value the purchase was for, or undefined for a product with no context.

grant returns nothing once it granted, or "decline" when it cannot, which gives the Spawnites back. A grant that throws gives the Spawnites back too. A grant checks first and declines before it changes anything.

A consumable sold for one place or one time, such as an upgrade to a plot or a pass for a season, declares a context: a schema built with z from @spawnite/schema. Each purchase passes a value of it. The platform checks the value against the schema before it holds any Spawnite, keeps it with the purchase, and hands it to grant as it was. The platform reads none of it: what a plot or a season means is the grant’s to decide.

The schema types the context that grant receives, so context.season above is a string. A value is at most 1 KiB of JSON, and a larger one is refused before any Spawnite is held. The build writes the schema into the manifest as JSON Schema, at most 2,048 bytes of it. A context schema refers to no other schema, so the build refuses one built with z.lazy, and the upload does too.

A game registers its products once, beside defineProducts:

// Excerpt of src/products.ts
declare module "@spawnite/engine" {
interface Register {
products: typeof products;
}
}

The block works as it does for levels. From then on the ProductKey type holds only the game’s keys, so a misspelled key such as "swrod" fails the typecheck where the game declares "sword". Without the block, ProductKey is any string.

The platform knows a product by its game and its key, for good. The catalog keeps one row per game and key, and never deletes one:

Change What the catalog does
A name, an icon or a price changes Takes the new values when the version is published
A key changes Keeps the old key as a product of its own, and adds the new one
A kind changes under one key Nothing: the upload and the publish refuse it, naming the key
A product is retired, or dropped from the file Stops selling it; its owners keep it
A dropped key comes back Sells the same product again, with its owners
An earlier version is published again Follows that version’s declaration

A price change is a new version: it takes effect when that version is published, for every page at once, because every page reads the catalog.

Each product’s row holds an offer revision, which starts at 1. A publish that changes what the product offers raises it: a field of the declaration, or the code of its grant, which the build stamps with a revision of its own. That revision reads the grant’s code and each module of the game’s own that the declaration imports, so an edit to any of them makes a new offer. The catalog keeps every revision. The purchase sheet sends the revision it showed, and the platform refuses a purchase whose revision is no longer the catalog’s, so a player always gets the offer the player saw.

The build refuses a declaration the catalog would refuse before it builds anything, and names the line that declares the product:

src/products.ts declares products the platform's catalog refuses:
- src/products.ts:4, "sword": its icon is public/icons/sword.png, which the game does not hold: put the picture there, or name one it holds.

It refuses a field outside the bounds above, a context schema the platform cannot check whole, such as one with a refinement, and an icon the game’s public/ folder does not hold, spelled exactly. In a game with rooms, it refuses a scene that does not export the declaration src/products.ts makes. The upload checks again, whatever built the bundle, and runs the word filter on each name.

The game draws its own shop, and the player app draws the confirmation, which no game can alter. Hand the declaration to the Game with <Game products={products}>, then call buy from the useProducts hook, such as buy("potion"), or buy("plotUpgrade", { context, label }) for a product with a context. The sheet shows the following:

  • The product’s icon, name and price, from the platform’s catalog.
  • The player’s Spawnites.
  • The refund rule of its kind: a durable product is the player’s to keep, and a consumable is final once it arrives, its Spawnites back on their own if it never does.
  • For a product with a context, the label the game passed, up to 60 characters, marked as the game’s words.

buy resolves with { outcome, reason }. outcome is a PurchaseOutcome:

  • bought: the platform took the Spawnites. For a consumable, grant ran and a committed save holds what it gave.
  • queued: a consumable bought while the player has no seat in a game with rooms. It arrives the next time the player plays.
  • cancelled: the player closed the sheet.
  • unavailable: nothing was bought, with a PurchaseReason, such as sold-out, limit-reached or invalid-context. A context the product’s schema refuses resolves so before the sheet opens, and so does every buy on a page outside the app.
  • returned: the Spawnites went back, with the reason, such as expired or declined.

A second buy of a key while one is pending answers the same promise.

A consumable’s Spawnites are held when the player confirms, and taken only once a committed save holds what grant gave. The purchase travels to the holder of the player’s save: the room that seats the player, or in a game with no rooms the player’s page. The holder runs grant once, inside transact, lists the purchase in the player’s save, and saves within seconds. The commit that stores that save is the one that takes the Spawnites, so nothing is ever granted twice or paid for without a grant.

  • A grant that returns "decline", or throws, applies nothing and gives the Spawnites back at once.
  • A holder that crashes before its save is committed loses the grant with the crash, and the next holder grants it again.
  • A purchase no holder takes within 5 minutes, or within 7 days for one queued for the player’s next seat, gives its Spawnites back. So does one a room did not commit within 10 minutes of taking it, or a page within two.
  • A page whose late save names a purchase that went back reloads the player’s last committed save, and the app tells the player the purchase did not go through.

The platform records who owns each durable product, and a game asks it rather than its save, so a save wipe loses nothing and an edited save gives nothing. Roblox keeps a Game Pass’s ownership the same way, and a game asks UserOwnsGamePassAsync.

  • A buy takes the offer revision and the price the purchase sheet showed. It is refused when the catalog’s offer has changed since, when the player owns the product already, when every unit of its stock is sold, or when the player is short of Spawnites. A retry of the same purchase buys once.
  • A product with a stock counts the units it sold. A return or a reversed pack gives the unit back.
  • A product the player owns stays theirs when it is retired or dropped from the file.
  • A refund or a chargeback of the Spawnite pack that paid for a product takes the product back, and returns any Spawnites the purchase took from the player’s other packs. Support can also take a product back and return its Spawnites.
  • Each answer about what a player owns carries a revision that rises on every change, so a room drops an answer older than the one it holds.

A game reads ownership in two places:

  • The shop on the page. owns(key) from the useProducts hook answers whether the player owns a durable product, and renders again when a purchase lands or a product is taken back. The app sends the answer, so it reads false outside the app.
  • A system. ownsProduct(world, player, key) answers from the platform’s newest answer for that player. In a room, the player’s seat’s confirmation carries it, and a buy or a take-back asks the room to heartbeat at once, so the change arrives within seconds. In a game with no rooms, the page takes the app’s answer. A room never takes a page’s word. A playtest gives a player a durable product as Deliveries says.

What a product does once it is taken back is the game’s to decide: a game that reads ownership where it applies the effect needs no extra code.