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

Coins and the wallet

A wallet is the engine’s WalletTrait trait, { coins }, on the entity that holds a player’s coins: their player entity, which the characters plugin gives a wallet as it spawns their character, so the coins outlive any character. In a game where the player is no character, such as a rider on a track, the wallet is on the entity they control. Any other entity has no wallet until its first coin. Coins are an outcome, so the room, or the game itself when it plays alone, changes them, and a client, the game running in one player’s browser, only reads them.

Coins reach a wallet from a Pickup, from an item effect of type coins, and from a system that calls addCoins. A pickup and an item pay findWalletHolder(world, entity): the player whose character earned the coins, or the entity itself where it is no player’s character. A system takes them out with spendCoins, and a game played alone can also spend from a button with useWallet().spend. A view shows them with useWallet().

These coins are the game’s own currency, which it pays in play. They are not the coins a player buys on the platform.

useWallet() returns the player’s coins and a spend function. Each frame, the engine copies into it the coins of the player’s own wallet:

  • Their player’s wallet, once it holds one.
  • Otherwise their character’s wallet.
  • In a game where the player has none, such as a rider on a track, the wallet of the first entity with one that their client has authority over.

It shows 0 until a frame first finds such a wallet. A frame with neither keeps the last coins shown, so a change of scene does not flash 0. The store resets to 0 when the game unmounts. The store only shows the coins: they live in the wallet on the player, and a new visit starts with none unless the save brings them back, as Keep coins across scenes and visits says.

The example game shows the wallet at game level, so it reads the same in every scene:

games/example/src/app/app.tsx (part)
/** The wallet, at game level, so it reads the same in every scene. */
function Readout() {
const { coins } = useWallet();
return (
<Hud>
<Panel slot={Slot.TopRight}>
<Text>Coins {coins}</Text>
</Panel>
</Hud>
);
}

useWallet is a zustand store, so a view reads the whole state, const { coins } = useWallet(), or one field, const coins = useWallet((state) => state.coins). Both work. Readout is mounted inside <Game>, beside the scenes, and useWallet, Hud, Panel, Slot and Text come from @spawnite/engine. Counter shows the coins with an icon.

A system pays and charges with the following functions, from @spawnite/engine/core and from @spawnite/engine:

  • addCoins(step, entity, amount) adds amount to the wallet of the entity it names, and no other, gives the entity a wallet where it has none, and adds amount to the coins the world has credited. A round in play scores the rise in that count.
  • spendCoins(step, entity, amount) takes amount off the wallet and returns true where the wallet holds all of it. Where it holds less, or the entity has no wallet, it takes nothing and returns false, so a shop sells whole or not at all. A spend never lowers a round’s score.
  • readCoins(entity) reads the coins in the entity’s wallet, 0 where it has none. It is a read, so it takes no step and runs on a page as well as on the room: a shop’s when checks readCoins(player) >= price, as the Dialog page’s shop does.
  • readCreditedCoins(world) reads every coin addCoins has credited on the world since it was made, to any entity. The count only rises: a spend never lowers it, and a wallet a save restores or a game sets by hand never raises it. It reads 0 on a client in a room, where the room credits every coin.

The first argument of addCoins and spendCoins is the step of a system that decides outcomes, an AuthoritativeStep, as dealDamage takes. Such a system runs in the room, or in the game itself when it plays alone, and a call from a client in a room throws. A dialog’s do runs there too, and requireAuthority(world) gives it the authority.

Both functions check the amount before they change anything:

  • An amount may be a fraction, as the wallet and its save hold one.
  • An amount of 0 changes nothing. spendCoins returns true for it.
  • An amount below 0, or one that is no finite number, throws and names the call, such as addCoins got an amount of -5: pass a finite number from 0, such as addCoins(step, character, 5).
  • A credit that would leave the wallet, or the world’s credited count, at no finite number throws.

A throw does what any throw in a system does, as When a system throws says: a hosted room skips the rest of that system’s tick and runs on, a development room closes, and a headless test or spawnite simulate throws out of the step.

The following plugin pays a coin a second for every player’s character, into the wallet findWalletHolder names, so the coins land where useWallet reads them. The room’s tests run it in a room with a round, and check that the player’s client reads the coins and that the round counts them:

packages/room/test/outside/wage.ts
import type { World } from "koota";
import {
addCoins,
ControlledTrait,
createQuery,
definePlugin,
findWalletHolder,
readEach,
type AuthoritativeStep,
} from "@spawnite/engine/core";
// A wage the room pays, built as a creator's plugin is, from the engine's
// public entry alone: a coin a second for every player's character, paid
// into the wallet of the player she belongs to, which the player's client
// reads from the room's updates and a round in play scores.
const characters = createQuery(ControlledTrait);
/** The room's steps between two wages: one second. */
const wageTicks = 60;
export const wage = definePlugin({
name: "outside-wage",
systems: {
rules: {
// No `runsOn`, so it runs on the room alone, whose step is the
// authority addCoins takes.
payWages: (world: World, step: AuthoritativeStep) => {
if (step.tick % wageTicks !== 0) return;
readEach(world, characters, (_, character) => {
addCoins(step, findWalletHolder(world, character), 1);
});
},
},
},
});

A system with no runsOn runs in the room, and in the game itself when it plays alone, so the same plugin pays in both. The query counts every entity that carries ControlledTrait, so it also pays a kart a player has left, into the kart’s own wallet. The plugin imports from @spawnite/engine/core, the simulation alone with no React, and @spawnite/engine exports each of the same names too.

Charge only once the thing has a place to go

Section titled “Charge only once the thing has a place to go”

Spend coins only after what the player buys has somewhere to go, so a purchase never takes coins for nothing. The Dialog page’s shop puts the potion in the bag first, then spends, and takes the potion back out where spendCoins returns false.

In a room, a purchase from a button is a command to the room. The command names the item and never its price: the system that answers the command reads the price and the wallet from the room’s own world, and charges with spendCoins. Like the wage above, that system has no runsOn and takes the AuthoritativeStep as its second argument, (world, step), which it passes to spendCoins.

In a room, spend answers from the coins shown, as it does alone, but changes no wallet: the room decides the wallet, so the next frame shows the room’s coins again. A purchase in a room is a command and spendCoins, as above.

In a game played alone, useWallet().spend(amount) spends from a button:

  • It answers at once from the coins last shown. Where they cover amount, it returns true, the coins shown drop at once, and the next frame takes them off the wallet. Where they do not, it returns false and changes nothing.
  • The amount must be above 0. spend(0) throws Cannot spend 0: not a positive amount.
  • Where a system spent those coins between the button and the next frame, the wallet keeps what it holds, and a development build says so in the console.
  • Where no entity holds the player’s wallet at the next frame, such as no character alive and no entity they control, or where that entity was replaced since the spend, the spend is dropped and the wallet keeps its coins. The coins shown stay lowered until an entity with a wallet is there to show again.

Use spend alone only where nothing else depends on the purchase. A purchase that hands something over, such as an item, belongs in a system with spendCoins, which answers false before anything is handed over.

The wallet lives on the player, not on her character, so the next character she plays, after a respawn or in the next scene, such as a lobby shop after a run, finds the coins the last one earned. The coins reach the next visit only where the game’s save lists wallet in include, which keeps the wallet on her player. { name: "wallet", scope: "entity" } in include keeps a wallet on each saved entity instead, which a pickup and an item then pay into.

The example game’s save keeps the wallet, and the runs won on the player beside it:

games/example/src/save.ts
import { defineSave } from "@spawnite/engine/core";
import { z } from "@spawnite/schema";
import { readRunsWon, setRunsWon } from "./runs";
/** What the player's save keeps: the engine's save of where the player's
* character stands and its health, the wallet on the player, so the coins
* of a run reach the lobby, and the runs the player has won, on the player
* too, since they outlive any character. `<Game save>` takes it, and each
* scene's file exports it for the room. Raise `version` and add a step to
* `migrations` when the shape of `schema` changes. */
export const save = defineSave({
include: ["entity", "wallet"],
version: 1,
schema: z.object({ runsWon: z.int().check(z.nonnegative()) }),
read: ({ player }) => ({ runsWon: readRunsWon(player) }),
restore: ({ player }, saved) => setRunsWon(player, saved.runsWon),
});

A restored wallet adds nothing to a round’s score. A game with a room also exports save from the room’s scene file, as Saves in a room says.

  • A wallet holds one currency. A second, such as gems, is a trait of the game’s own, which the game’s save keeps through its schema.
  • A round counts coins credited while it plays, not coins held, so spending never lowers a score.