Skip to content

World of Wonder

A multiplayer 3D top-down RPG whose gold and items live on a chain instead of in the game's database. Fork it, rename it, and you have an MMO where a player's sword is theirs rather than a row you could delete.

It is a fork of orion3dgames/t5c — a real Babylon.js and Colyseus RPG with movement, combat, quests, loot, a navmesh, a vendor and a UI. All of that is upstream's work and still upstream's. What the fork replaces is the economy.

Play it, or read the source. This is the example to start from if you are building something rather than reading something.

World of Wonder gameplay view with character, village, combat hotbar, chat, and HUD

Gameplay screenshot from the maintained World of Wonder repository. It shows the world and HUD, not the Auction House.

One task, end to end

This page is the tour. If you came for one piece of it:

  • Auction house integration — put a player-to-player auction house in your own fork: the wallet calls that list, buy and cancel, the two routes the server answers, and the check that has to be there before an accept is signed.
  • Loot and drops — where a kill's gold and items actually go today, why they never reach the chain-backed bag, and what a commit-and-claim migration would have to prove first.
ClientBabylon.js, webpack
ServerNode 20.17+, Colyseus, SQLite or MySQL
DatabaseAccounts, characters, positions. Never money.
Default chainThe public M3 testnet
Every line of Kei in the clientsrc/client/Controllers/Wallet.ts

Run it

sh
git clone https://github.com/keicoin-org/world-of-wonder
cd world-of-wonder
npm ci
cp .env.example .env                            # optional — everything has a default
npm run server-build && npm run server-start    # http://localhost:3000
npm run client-dev                              # http://localhost:8080

The released kei-transaction SDK is a normal npm dependency. A clean clone does not need a sibling checkout or a link step.

It settles on the public testnet by default

That default is deliberate: a player's wallet is meant to outlive your server, and it cannot do that against a chain living inside it. The testnet is best-effort, has weak consensus, no uptime promise, and Kei that is worth nothing. KEI_NETWORK=mock gives you the in-process chain instead, which is right offline.

What changed from upstream

t5c kept gold as a uint32 on PlayerSchema and inventory in a character_inventory table. That is the ordinary way to build this, and it means the developer owns every player's belongings — "you own this item" is then a promise about your intentions, not a fact about the world.

UpstreamHere
GoldPlayerSchema.gold, saved to SQLiteA Kei token. balanceOf is the only source of truth.
Inventorycharacter_inventory rowsOne 0-decimal asset per item archetype; owning a sword is holding a unit of it.
BuyingServer decrements gold, adds a rowPlayer signs a transfer; the issuer mints after the chain confirms it
SellingServer increments goldPlayer signs the item away; the shop pays for what arrived
The vendor panelSends a room messageSigns with the player's wallet, and reads the purse off the chain
The bag panelReads PlayerSchema.inventoryRefreshes the player's on-chain item balances and purse

The database is still there, deliberately

It holds accounts, characters, and where they were standing. Colyseus is still authoritative over presence and position. Neither is authoritative over money, which is the whole point.

ConcernWhere it belongs
Position, presence, combat, roomsColyseus
Accounts, characters, where they logged outThe database
What a quest pays, what a sword costsThe server — this is design, not custody
Balances, ownership, transfersThe chain. Always.

A chain is not a low-latency datastore. Nothing on the critical path of the 60 Hz loop touches it.

The wallet is the browser's

Kei.start() generates the player's seed on first run and keeps it in localStorage, so there is no signup and no account to create — and clearing site data loses the wallet, which is the other half of owning it.

The game never holds that key. That is why the vendor panel signs its own payments and reads the purse off the chain instead of trusting a number the room sent it.

Buying takes two signatures

The game cannot sign for a player's wallet, so a purchase is always the player signing a transfer and the issuer signing a delivery. A transfer carries no memo, so the shop records the order first and matches the arrival to it — and delivers nothing until the chain says the gold landed.

The order is not the purchase. src/server/kei/Economy.test.ts holds the code to that: an unpaid order delivers nothing, and a player who cannot afford something is refused in a sentence they can act on.

Selling takes one, and there is no route for it

A sale is the player transferring the item to the shop, and the shop paying for what arrived.

There is deliberately no POST /kei/sell. The server can mint this world's currency, so any endpoint that paid on request would be a printing press for whoever found it. Reacting to an arrival costs the seller the item first, which is the only version of this a stranger cannot exploit. What the shop pays is in the catalogue, so a client can still quote a price without asking.

The general rule

Any server endpoint that pays out on request, rather than in reaction to something already settled on the chain, is a printing press. Make the player's side of the trade happen first, and react to it.

The auction house is a screen over player chains

The Auction House panel has three views: Browse for offers from other players, Sell for choosing an item, quantity and asking price, and Mine for taking back your own open offers. src/server/kei/Market.test.ts holds the proof underneath it: one player lists a sword bought from the shop, another buys it, and the gold and the sword move in one settlement with the game server taking no part in it. The test also pins the part that is easy to get wrong later — a listed item cannot also be handed to somebody, and the ledger is what refuses it.

It is market.offer(), not market.sell()

sell() prices things in Kei. Gold is not Kei — it is an asset this world issues — so a listing is an item on one side and gold on the other. Writing it the other way compiles and quietly denominates the auction house in a currency the game does not use.

Know the limit before designing around it — an offer lives on its author's chain and Kei ships no indexer, so there is no query for every listing in the world. This hall keeps a bounded, in-memory roster of accounts it has heard from and walks those chains. A restart empties the roster until wallets return; an unheard-of seller is invisible without ever losing custody of the listed item. That is bookkeeping about where to look rather than about who owns what. Carpet Markets does the same job in its registry.

Nothing the hall reports is trusted at signing time. The wallet re-reads the offer by hash and binds its seller, item asset, quantity, quote asset and price to what was displayed before accepting it. Names come from the client's local catalogue, so a dishonest hall can hide or advertise a dead offer, but cannot substitute another item.

A database-backed auction house instead would look identical to a player and mean the opposite thing, since this server can already mint gold.

Where things are

src/server/kei/Economy.ts                                the issuer: gold, items, the shop. Read this one.
src/server/kei/api.ts                                    the HTTP surface. Nothing here can move a player's money.
src/server/kei/node.ts                                   which chain, and which account issues the money
src/server/kei/Economy.test.ts                           the rules, against a chain in-process
src/server/kei/Hall.ts                                   the bounded roster and chain walk behind Browse
src/server/kei/Market.test.ts                            listing, acceptance, cancellation, history and trust-boundary checks
src/server/kei/endtoend.test.ts                          the same thing across a URL, the way a browser does it
src/client/Controllers/Wallet.ts                         the player's key, and the only thing that spends their gold
src/client/Controllers/UI/Panels/Panel_Auction.ts        Browse, Sell and Mine, as a player sees them
src/client/Controllers/UI/Panels/Dialog/VendorDialog.ts  the shop, as a player sees it
src/client/Utils/index.ts                                where the client looks for the server

Configuration

Copy .env.example to .env. A real environment variable always wins over a line in that file.

VariableDefaultEffect
KEI_GAME_SEEDgenerated per runThis is the economy. Whoever holds it can mint this world's currency without limit. Unset, a new issuer means new asset ids, so every balance and item from the previous run becomes unreachable.
KEI_NETWORKtestnettestnet, mainnet, or mock. Selecting mainnet without a KEI_NODE stops the server with an explanation rather than settling somewhere else.
KEI_NODEOverride the node URL for whichever network is selected.
KEI_EXCHANGEonoff disables paying Kei for gold. The game stays playable.
DATABASE_PATH./database.dbSQLite file. The engine itself is database in src/shared/Config.ts, not an environment variable.
NODE_ENVdevelopmentproduction closes /kei/grant, never loads the Colyseus monitor, and turns off the 250 ms latency simulation.
GAME_SERVERpage originWhere the built client looks for the game server. Read at build time and compiled into the bundle.
KEI_TEST_BASEhttp://localhost:3000Base URL for npm run test:e2e.

Generate a seed per deployment

sh
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Keep it out of the repository, out of logs, and out of the client. mainnet has no faucet, so this world's issuer address has to be funded by a person before the first run.

Tests

sh
npm run test:economy    # the rules, in-process
npm run server-start &
npm run test:e2e        # the same thing over HTTP, sharing no memory with the server

test:e2e is the one worth trusting. It signs its own transfers against /rpc and waits for the item to arrive, so passing it means a hosted client can work rather than suggesting it might.

Known limits

  • The hall is deliberately incomplete. It only reads the bounded set of player chains this server has heard from, and that roster is in memory. A restart empties it until wallets announce themselves again; Kei ships no global offer index.
  • Equipping, loot and quest rewards still use upstream inventory state. The bag and the vendor read the chain, so anything bought or sold is consistent in both. Gameplay rewards and equipped gear have not moved across, and are deliberately not merged into the bag — that would make database rows look like on-chain ownership. The trainer still spends player_data.gold, which is no longer money.
  • The hosted copy is live, not production-ready: it runs a process-local mock chain, so nothing on it survives a restart. The repository settles on the public testnet by default.
  • Consensus is weak until the validator set is distributed. Until then this is a testnet with branding, and not somewhere to put real value.
  • Per-instance mutable item state — durability ticking every second, live stack counts — is not what an asset is for. Model the archetype on-chain and keep that state local.
  • Upstream's nanoid advisory chain is documented in the repository README, including why the obvious overrides fix breaks the server.

Continue

The package is the source of truth for the API.