Add gasless play to any on-chain game
Foskaay Gasless Games Infrastructure (Foskaay GGI) lets a game open a session on-chain, do everything inside for free, and settle once. Players never pay gas and never see a wallet popup. The game pays a small fixed fee per session instead of a fee per action, so an on-chain game can be as cheap to run as a normal web2 backend.
Why use it
- Players pay nothing. No gas, no top-ups, no "insufficient funds" on a phone.
- No wallet popups during play. A session key signs every action silently.
- Cheap by design. The fee is per session, never per move, so a busy game costs the same as a quiet one.
- Any game type. A board game, an idle farm, a card game and a whole MMO are the same to the rail: participants, an opaque state, signed events, one settlement.
- Nothing to run. There is no validator, no RPC node and no rollup to operate. You deploy nothing extra.
- Provable results. Every action is signed, so a tampered action breaks the signature and a fake result cannot settle.
- True costs, up front. Fees are charged on testnet too, so you see real mainnet economics before you commit.
How it works
Think of a session as a room. You open the room, everything inside is free, and you settle it once at the end. Only the open and the settle touch the chain.
| Step | What happens | On-chain? |
|---|---|---|
| Open | Create the session: participants, an optional rules blob, a lifetime, and an optional committed random seed. | Yes, one transaction |
| Act | Record a signed action. The payload is opaque; the rail never reads it. As many actions as you like. | No, free |
| Settle | Close the session, reveal any committed seed, and seal the final digest. | Yes, one transaction |
Designing a game for Foskaay GGI (read this before you design your contract)
This is the most important page for a game dev (and for any AI agent helping one). Foskaay GGI is a transport, not a ledger and not a policy. It takes the accounts your game already owns, moves them off the base chain into the free room, lets everything happen there for nothing, and commits them back. It does not create your game, your players, your points or your rules. Your contracts own all of that, permanently, on the base chain.
Each account you delegate into a session is a separate lift and a separate commit. If you make one account per game, or one account per feature, that count grows forever. A real example of the trap: a player with a dice account, a points account, a global points account, a lives account, a result account, a premium account and a player account is six or seven lifts for one player. Add a second game and it becomes seven or eight. At 50 games it is unmaintainable and the fees multiply.
- One game account, not one per game. Every game is a module or a bucket inside a single account. Adding game number 50 costs nothing extra.
- One player account, not one per game or feature. Points, lives, records and per-game buckets live in ONE player account. 50 games still lift the same account once.
- Points live in the player account, which is lifted with the match. Then crediting a point is a free write inside the room, exactly like a move.
- The board is data, not graphics. Store the board as compact bytes (positions, counts, status). Never put images or SVGs on-chain. Graphics belong in the browser, which only displays what the chain already says.
I built on MagicBlock's ER as a beginner and learned this the hard way. I first created a separate account for the game, for points, for subscriptions, for lives and for the player. To play, I had to delegate six accounts at once, and I did not understand that as a beginner. When I added a second game (chess) it forced another game account, so it became seven. The cost went up every time, and I could feel it growing. At 50 games it would have been impossible to maintain. The fix was to consolidate: one player account that holds points for every game in buckets, and one game account that every game is a module inside. Adding a game became additive, not a new account. That is why this page asks you to think about account count before you write a line of Solidity.
- Your game contract holds the rules and the match: seats, board bytes, turn, turn timer, dice, computer logic, win condition.
- Your player contract holds everything permanent about a player: points (with a game tag or bucket), lifetime, wins. It is lifted with the match so points can be credited inside the room, at the moment a game ends, not when the session closes.
- The rail is our two contracts. You connect a session (the fee is paid there), play everything for free inside, and settle. Unbatched means one match per session; batched means one session covers many matches, so credits keep accumulating inside the room and everything commits back when the session closes.
- Nothing runs before the session is live. No move, dice, timer, computer turn or point credit happens until the connect is confirmed (the account is lifted and the fee paid).
- Nothing runs after the session is gone. Undelegating loses the free room, so the game waits for the next session rather than paying base-chain fees.
- Everything happens inside the room. Moves, dice, turn timers, computer moves, lives and points are all writes inside the free room. If you reach for the base chain to credit a point, you pay for it, and the sponsor starts losing the saving the whole design exists to create.
- The credit happens at game end, inside the room. A point is credited the moment that game finishes, not at session settlement, because a batched session stays open across many games.
- The frontend only displays. No game state in the browser, no local storage. The contract is the only source of truth.
Should your game contract be upgradeable?
Short answer: yes, for a game, almost always. A game is never finished on day one. You will want to lower or raise points, add a subscription, add lives, add in-game assets, fix a balance, or add a new mode. On a normal contract, every one of those means deploying a new contract at a new address and migrating players, which breaks every existing game, every saved match and every link. An upgradeable contract keeps the same address and lets you change the logic behind it, so existing games and addresses never break.
| Shape | What it means | Best for |
|---|---|---|
| Immutable contract | Deploy once, the code can never change. A fix means a new address. | One-off, finished, tiny contracts. A fair lottery. A proof of concept you will throw away. |
| Upgradeable (UUPS proxy) | The address is permanent. You can ship new logic behind it without moving the address or touching players' data. | Game contracts, points, player accounts, anything you will add to over time. This is what Foskaay GGI's own core uses. |
- Add features forever, at the same address. Add a subscription, lives, in-game assets, a new mode or a whole new game. Nothing you already shipped breaks.
- Fix mistakes without a migration. A wrong point value or a bad rule can be corrected in an upgrade instead of a new deploy and a player data move.
- Players keep their data. Points, records and balances live in the same storage behind the same address, so an upgrade does not touch them.
- Your links and integrations stay valid. Anything that points at your game address keeps working, which matters a lot once other contracts or a frontend depend on it.
- It is the standard for this kind of app. Foskaay GGI's own core is upgradeable, so building the same way keeps you consistent with the rail.
- There is an admin, and that is a trust question. Whoever can upgrade can change the rules. For a demo or a platform that is fine, but say so plainly, and for anything holding real money move the upgrade authority to a timelock or a multisig.
- You must follow the storage rules. Never reorder or remove a stored variable; add new ones in the reserved gap. Break this and live data reads as garbage. The rule is in the storage-safety note below.
- Initializers instead of constructors. Behind a proxy the constructor does not run, so a field default like
turnSeconds = 30silently stays zero. Set every default insideinitialize(). - A little extra gas and code. The proxy adds one hop and the implementation is a separate contract. It is small, and it is the price of never breaking an address.
- Append only. New variables go at the top of the reserved
__gap, and the gap shrinks by exactly the same number of slots. Never reorder, rename or remove an existing variable. - Initialize, never construct. All defaults and setup go in
initialize(), which runs once through the proxy. The implementation itself is locked with_disableInitializers()so it can never be used directly. - Guard the upgrade. Restrict
_authorizeUpgradeto your owner, and move it to a timelock or multisig before real value is involved. - Test the upgrade. Ship a test that upgrades and proves the address is kept, the storage reading is intact, and only the owner can upgrade.
FoskaayGGIDemoGames, which holds
Ludo today and any demo game tomorrow, and FoskaayGGIDemoPlayer, which holds
everything about a player across every demo game) are UUPS upgradeable, OpenZeppelin only, with
an append-only storage gap and an owner-only upgrade. You can add a game, a subscription, lives
or assets later without ever redeploying or breaking the address your players and your frontend
already use.
Built to fit your game, never the other way round
Foskaay GGI is deliberately unopinionated. It gives you primitives and makes no decision for your game. It does not care how many players you have, how your data is laid out, or how often you settle.
| Contract | What it does |
|---|---|
| FoskaayGGI | Connect a session (the fee is paid here and sent straight to your destination), settle the result, and give free pure randomness to the games that ask. The fee is built in, so there is no separate vault to deploy or wire. |
- Batching needs no contract. One connect and one settle can cover MANY games in a session: the settle carries a single Merkle root over them. That is the cheapest tier, and it is a choice on your side, never a separate thing to deploy.
- Randomness is free. The registry exposes pure random and randomN, read through eth_call at no cost, so dice and card games pay nothing extra and need no randomness contract.
Quickstart
Integrating is three things: install a package, write a small adapter for your game, and connect, sign and settle. There is no fork and no contract to copy.
npm install @foskaay/ggi-sdk- Install @foskaay/ggi-sdk (and @foskaay/ggi-contracts-sdk if your own contract calls the rail directly).
- Write a small adapter that turns your game's state into a hash. This is the only game-specific code, and it lives in your game.
- Connect, sign, settle: one connect transaction (the fee is paid there), free signed moves during play, one settle at the end.
// Pseudo-code: the shape of an integration
const ggi = new GgiClient({ network: 'testnet', walletClient });
// 1. connect a session and delegate your game + player accounts
// (one transaction; the 3-part fee is read from the chain and paid here)
const key = ggi.createSessionKey();
await ggi.handoverWithAccounts({
sessionId,
gameLogic: myGameAddress,
startHash: hashOf(myStartState),
seedCommit: optionalSeedCommit,
players: [p0, p1],
sessionKeys: [key0, key1],
randomCount: 1,
accounts: [myGameAddress, myPlayerAddress], // your delegated accounts
games: 1,
});
// 2. play for free: run your pure rules via eth_call, dice free via randomN
const state = await ggi.applyMove(stateBytes, kind, seat, token, value, seeds);
const finalHash = await ggi.hashState(state);
// 3. commit the match + credit the players (one transaction)
const sig = await ggi.signMove(key, sessionId, finalHash);
await ggi.settleGame(sessionId, [gameTuple], seatPlayers, gameTag);
// 4. close the session on the core (players sign the final hash)
await ggi.settle({
sessionId, finalHash, seedReveal,
sigs: [sig0, sig1], signers: [p0, p1],
});Full API reference and the adapter example ship with the SDK README. This page is the overview; the package docs go deeper.
Contracts
Foskaay GGI is one core contract (FoskaayGGI), deployed per network. Your own game and player contracts are yours to deploy and own; the core is the only rail address you call. The addresses below are public and safe to use in your app. FoskaayGGIGames and FoskaayGGIPlayers are the Ludo demo contracts: a working example of one game on the rail, used here to prove the SDK and the gasless promise end to end. They are not part of the core. Bring your own game and player contracts; use the demo only as a reference.
These addresses are permanent. The contracts are upgradeable behind UUPS proxies, so improving the rail never changes an address and never moves your data.
| Contract | Responsibility | Address |
|---|---|---|
| FoskaayGGI | Connect a session (paying the fee), settle the result, free randomness | 0x793785CE66992211B7c60dFCf0318869678D33a4 |
| FoskaayGGIGames (game: match + rules + settle) | Holds the match on-chain, runs the Ludo rules free in the Foskaay GGI Midchain, and settles N games in one tx | 0x24e38ac2e80958782a8Bc5CD479bbe2e5D81EcDF |
| FoskaayGGIPlayers (player account) | Per player, per game tag points/lives/records; credited by its game, pointsOf | 0x1614ebc72eA1cB3D31975b3976B5B474FAcE3b3C |
| FoskaayGGILudo (legacy demo game, pure rules only) | The earlier pure-rules demo; superseded by FoskaayGGIGames in Phase 5 | 0xa5040Ece5945a8551499ad1148fc3cD15b165987 |
Fee in native USDC, charged once at connect: a session base plus a per-delegated-account part plus a per-game part. Nothing inside the session is charged. The exact numbers live on the single pricing page. RPC https://rpc.testnet.arc.io | Explorer https://explorer.testnet.arc.io | USDC 0x3600000000000000000000000000000000000000
| Contract | Address |
|---|---|
| FoskaayGGI | 0xb406295b4F7E5B513b656122AfFF29AF720E9E23 |
| FoskaayGGIGames | 0xb2d5DfF81B076948f50dA2CcF01887f5ed6Ae2b2 |
| FoskaayGGIPlayers | 0x9425c1d6bA7923D5C804c5e549E08629AbBe3165 |
Deployed and live on Arc mainnet. The SDK defaults to mainnet and reads the addresses and the fee from the chain at runtime, never hardcoded. Testnet remains for development.
Networks
The whole Foskaay GGI site runs on Arc mainnet. The exact same contracts are also deployed on Arc testnet, so game developers can build, test and confirm their game flow against testnet first (the fees are paid there too, for a full experience), then point their game at mainnet the exact same way when it goes live.
import GgiClient from '@foskaay/ggi-sdk';
const live = new GgiClient({ network: 'mainnet' }); // production
const dev = new GgiClient({ network: 'testnet' }); // build + test, free to run- The default is mainnet. Passing network is how a developer chooses which network to talk to.
- The addresses are always read from @foskaay/ggi-contracts-sdk, never pasted into your code.
- This page is the site's single place for the current addresses. If they ever change, update nothing in your game, just reinstall the package.
| Contract | Address |
|---|---|
| FoskaayGGI | 0xb406295b4F7E5B513b656122AfFF29AF720E9E23 |
| FoskaayGGIGames | 0xb2d5DfF81B076948f50dA2CcF01887f5ed6Ae2b2 |
| FoskaayGGIPlayers | 0x9425c1d6bA7923D5C804c5e549E08629AbBe3165 |
| Contract | Address |
|---|---|
| FoskaayGGI | 0x793785CE66992211B7c60dFCf0318869678D33a4 |
| FoskaayGGIGames | 0x24e38ac2e80958782a8Bc5CD479bbe2e5D81EcDF |
| FoskaayGGIPlayers | 0x1614ebc72eA1cB3D31975b3976B5B474FAcE3b3C |
Same contracts, same fee model on both networks. Developers keep using testnet while building, then switch network: 'mainnet' when they ship.
Fees
- Players pay nothing. Always. The game pays a small fixed fee per session. There is no player-pay option anywhere in the rail: paying is not a mode a dev can switch on, so your players never hit the "top up your wallet first" wall that kills most web3 games.
- One fee per session, in USDC, charged once at connect. It is a session base plus a per-delegated-account part plus a per-game part. Nothing inside the session is charged.
- Never per action. A thousand moves and one move cost the same, so heavy games are not punished.
- Paid in USDC, a stablecoin, so the price does not swing with a volatile token.
- Set by the operator, changeable with one call and read at runtime by the SDK, never baked into your code.
The exact numbers (Foskaay GGI fee, Arc gas, total you pay, games per 1 USDC) live on the single pricing page, so there is one place to update and never a stale number here.
Starting is half. Ending is the other half
A session does not close itself. Your game decides when a game is over (it knows), and you close it with two calls:
- settleGame(sessionId, games[], seatPlayers, gameTag): writes the finished game (or many games) on-chain and credits the player account in the same transaction.
- settle(...): closes the session on the core, verifying the players' signatures over the final hash.
Unbatched (one game per session): connect with games: 1, play, then settleGame([one game]) and settle. The game is on-chain the moment it ends. On testnet that is 0.0014 USDC in the rail fee (0.0004 base + 0.0004 per account x2 + 0.0002 x1) plus Arc gas.
Batched (many games per session): connect once with games: N, play N matches for free, then ONE settleGame with all N games and ONE settle. Measured on Arc testnet: 3 games in one session, rail fee 0.0018 (only the per-game part grew), one settleGame wrote all 3 games and credited the player 3x, and the core settle stayed flat at about 72k gas. All transactions succeeded.
Persistent gameplay (survive refresh, logout, rejoin)
Foskaay GGI is the free room; your game and player contracts are the store. The moment a game settles, the game (with its board bytes) and the player's points are written on-chain immediately, even while the session is still open. We measured this live on testnet: 3 games in one open session; after each game, reading the contracts from outside the session showed the points and committed games already there, identical after the session closed.
During play, NOTHING is written to Arc. Each move is computed free by the contract (eth_call), hash-chained (prevHash -> newHash) and signed, and the signed log IS the Foskaay GGI midchain. The relay is only an untrusted cache: any device replays the log and verifies it client-side (chain continuity, every signature, and the on-chain anchors: the Handover's startHash and participants, and the settle's finalHash). Arc sees exactly two transactions per session: connect and settle. A per-move storage write on Arc (an on-chain recordLive) would be a THIRD transaction, the fee regression this design forbids.
- Never write the board per move. Moves are computed free, hash-chained and signed; the signed log is your midchain. Writing the live board to Arc on every move reintroduces a third fee and must not happen. Commit the finished game with settleGame, the single post-play write.
- Commit each game at game end. Call settleGame(sessionId, [game], seatPlayers, gameTag), which stores the finished board and credits the player account in one step.
- Index games per player. Keep a small per-player record of which committed games belong to whom (the reference is FoskaayGGIGames.playerGamesOf(sessionId, player) with gamesOf(sessionId)), so a frontend rebuilds a player's history straight from the chain.
- Gate rejoin with the on-chain participants. The core's Handover commits players[] and sessionKeys[]; only a participant's session-key signature may advance the session, so a stranger cannot hijack another player's game.
The SDK exposes this to you: verifyMoveLog(sessionId, log) for the client-side midchain verification, gamesOf(sessionId), playerGamesOf(sessionId, player), pointsOf(player, tag), gameCount(sessionId). State lives in the midchain (your contracts) and any device reads it, verifies it, and continues, refresh or rejoin. Nothing is invented in the browser.
The live session URL (?game=sessionId) is the only handle, no storage. On rejoin the device fetches the signed move log from the relay (untrusted) and VERIFIES it client-side against the on-chain anchors before drawing one token, via the SDK's verifyMoveLog. If the log is missing (a serverless relay restarted), the page shows the on-chain truth (paid session, and the committed result once settled) instead of fabricating a board. Mid-game sessions are midchain state, so they never left for Arc: resumable while the log is alive, permanently reviewable once settled.
Get started
Four steps from zero to a gasless game. If you already have a game, only steps 3 and 4 are new work.
npm install @foskaay/ggi-sdk viem@foskaay/ggi-sdk is the client. Add @foskaay/ggi-contracts-sdk only if your own contract calls the rail directly.
import { GgiClient } from '@foskaay/ggi-sdk';
const ggi = new GgiClient({
network: 'testnet', // 'testnet' | 'mainnet'
walletClient, // a viem WalletClient that signs
});Your adapter turns a move into an opaque payload. The rail never reads it, so any game fits.
function toPayload(move) {
// anything serialisable, and stable for the same move
return { from: move.from, to: move.to, die: move.die };
}// CONNECT once per session and delegate your game + player accounts
await ggi.handoverWithAccounts({
sessionId,
gameLogic: myGameAddress,
startHash: hashOf(myStartState),
seedCommit: mySeedCommit, // only if your game needs randomness
players: [p0, p1],
sessionKeys: [k0, k1],
randomCount: 1,
accounts: [myGameAddress, myPlayerAddress],
games: 1,
});
// PLAY for free: run your pure rules, hash the state, sign it silently
const state = await ggi.applyMove(stateBytes, kind, seat, token, value, seeds);
const finalHash = await ggi.hashState(state);
const sig = await ggi.signMove(key, sessionId, finalHash);
// SETTLE: commit the match and credit the player, then close the session
await ggi.settleGame(sessionId, [gameTuple], seatPlayers, gameTag);
await ggi.settle({ sessionId, finalHash, seedReveal, sigs, signers });The SDK README has the full API reference, every read helper, and the session-key examples.
The demo: a real game on the rail
The best way to see Foskaay GGI is to play a game that runs on it. The Ludo demo is a complete on-chain match: the contract owns the rules, the dice, the capture rules, the crown and the points; the page only displays. Every roll and move runs free through the contract (eth_call) and the relay hash-chains + signs them; only the connect and the settle are real transactions.
- Fully on-chain. The match, the board bytes, the finish order and the points live in the game contract. No game state in the browser.
- Free to play. Sign in with your email, an embedded EVM + Solana wallet is created, and every move is free. The site sponsor pays only the connect and settle fees.
- Provable dice. The dice come from the core's free pure randomN(seed, counter); the seed is committed at connect and revealed at settle, so any roll can be replayed and verified.
- Credits your player account. At settle, the game writes the match and credits FoskaayGGIPlayers in the same transaction. Points persist forever.
Open the live Ludo demo. It is an implementation example: a game developer can copy the shape (one game contract + one player account) and bring their own rules and economy.
FAQ and fixes
No. They sign in however you onboard them, and every action is signed silently by a session key. They never see a wallet popup and never hold gas.
No. Nothing is written to the chain during play. The fee is charged once, at connect, as a session base plus a per-delegated-account part plus a per-game part, and a thousand actions inside the session cost nothing more than one. The numbers live on the pricing page.
Only the open fee is paid. The settle fee is charged only when a session actually settles.
No. Every action is signed and folded into one digest. Changing an action breaks the signature, and a fake result cannot settle.
No. Layout, cadence and accounts are entirely your choice. One account per player, one per feature, or one per match all work unchanged.
Every session and its sealed digest is readable from the contracts using the SDK read helpers. A session reader UI is part of the roadmap so receipts are visible to everyone.
First check the network you passed to the client matches the contract addresses on this page. Then confirm the contracts are deployed on that network (mainnet and testnet are both live, with the addresses on the Networks page). Most issues are a network mismatch. If it persists, open an issue on the repository or contact us from the Support page and we will look with you.