← Back to blog

Building gnofly: a multiplayer plane game on gno.land

gnoblockchaingamedevgo

What is gno.land?

gno.land is a layer 1 blockchain where smart contracts are written in Gno, an interpreted version of Go. Contracts are called realms. They are published as source code, not bytecode, so anyone can read the exact code running on the chain from a browser. A realm keeps its state in plain Go variables: no database, no storage API, the chain persists them.

gnofly

gnofly is a multiplayer plane game in the browser. You fly a small plane over a shared island, thread rings, pop balloons and shoot the other pilots. The game settings, the skins, the kill leaderboard and a collection of 777 plane NFTs live on gno.land.

It went from first commit to mainnet in five days: 147 commits, 31 pull requests, roughly 34,000 lines of Gno, Go and TypeScript, tests included. Here is what was worth learning.

gnofly: flying over the island toward the first ring of the Island Loop

Three parts, one rule each

  • The chain is the source of truth for what has lasting value: roles, game settings, skins, planes and kills.
  • The server is the referee. It relays positions (clients report 15 times per second, the world ticks 20 times per second), simulates bullets, scores runs and writes kills on-chain in batches with its own key.
  • The client never reads the chain. It gets everything from the server, and only writes through the player's wallet.

That last rule removed a whole class of bugs: one place talks to the RPC, one place decides what is true.

Tuning a live game with a transaction

Every game parameter sits in the realm with bounds set by the owner. Admins change them with a transaction while people are flying:

gnokey maketx call -func SetParam -args plane_speed -args 110 ...

The server polls the realm and pushes the change to every connected client. A few seconds later every plane cruises faster. Bot difficulty, boost capacity and recharge, all of it is tuned the same way, with no deploy.

Putting less on-chain

The first version recorded every scored run on-chain. On mainnet, that meant a transaction and a storage deposit per pilot for a score that only matters for a session.

So runs moved to the server, and the chain kept kills. The game realm went from 2,319 to 1,482 lines, and a whole 1,113-line package disappeared. The lesson: the chain is for what has value after the server restarts. Everything else is a cost.

Storage is paid by the byte

On gno.land mainnet, storage costs 100 ugnot per byte, taken as a deposit. That changes how you write code.

The first plane NFT stored a metadata record per token: about 13 KB and 1.3 GNOT of deposit per mint. But the traits of all 777 planes already sat in one table committed with the realm, one plane per line:

id|name|model|pilot|body|wing|accent|tier|laser

So the token now stores its owner and nothing else. TokenMetadata and TokenURI are computed from the table on every read. A single mint went from 12.9 KB to 2.1 to 2.5 KB (0.25 GNOT), a mint of seven from 76 KB to 16.8 KB.

Same logic for the random draw. The pool of planes left to mint is two bytes per id, 1,554 bytes at deploy, cheaper than 777 integers. Each draw picks an index from a SHA-256 of the block height, the block time, the minter and the previous draw, then swap-removes it: every id comes out once, in constant time. It is pseudo-random and documented as such: a validator could nudge the time. Mint only accepts direct user calls, so no contract can look at a draw and revert it.

Mainnet is not gnodev

Locally, gnodev lets you redeploy anything at any time. Mainnet does not:

  • A package is parked, then enabled. It is stored first, and an approver enables it a few blocks later if it type-checks in time. The publish script waits for each package to be live before sending the next.
  • Publishing is final. A live package cannot be replaced. A fix is a new path, /v1, with new state.
  • Deploying costs real money. Measured on a local chain, the game realm needs 9.7 GNOT of deposit, the planes realm 13.9, the skins realm 6.2. Tests are left out of the published packages for that reason.
  • There is no native burn. A realm cannot destroy ugnot, so the optional burn in paid arenas sends coins to an address nobody holds a key for.

Paid arenas: escrow on-chain, fights off-chain

A paid arena is a room pilots pay a stake to enter. The stakes sit in a realm, and the realm pays out when the round ends. The server only reports who eliminated whom.

Entering is a wallet transaction, flying is a WebSocket. To tie the two, the player sends the SHA-256 of a secret with the stake, and later proves the seat to the server with the secret. If the server never settles a round, anyone can trigger the refund on-chain once the round time limit and a refund delay have passed. The money cannot get stuck because the server died.

The honest part: movement is client-authoritative. The server rejects teleports and impossible speeds, but a modified client can still aim perfectly. The stakes are safe in the realm. The fights are not cheat-proof, and the README says so.

NFT Collection: 777 unique playable planes

The contracts are public on-chain. The planes collection is a realm anyone can read on gno.land/r/.../gnofly/nfts/planes: the mint, the random draw, the 5% royalty, the traits table of all 777 planes. What you read there is what runs.

The plane images are drawn by the game's own three.js models in headless Chrome. All 777 render in about three minutes, with byte-identical output on any machine. Change a row in the table, re-render, upload only what changed.

Plane #737, Chrome Harrier, rare twin with a gnome at the stick

Plane #001, Storm Sentinel, epic delta with a gnome at the stick

Plane #777, SuperGnome, the legendary 1/1

Built with Claude

114 of the 147 commits were co-authored with Claude. Every commit explains why, with numbers measured on a local chain: gas used, bytes stored, deposit paid. That habit is what made the storage optimizations possible: you cannot shrink what you never measured.

Fly a plane at gnofly.xyz.