@flayerlabs/gamemode-gate
v0.9.1
Published
Server-authoritative rooms, economy, admission and registry for Flaunch Game Modes
Readme
@flayerlabs/gamemode-gate
Run authoritative Game Mode rooms, spending allowance, admission and game discovery. You can run SDK rules or accept point awards from an existing authoritative game server.
Flaunch runs this package as the platform gate, one process per chain for every game
(startPlatformGate, see the
platform gate guide).
A game developer does not need to deploy it. A game server that posts awards to the platform gate
may still import helpers from it, such as createScoreRateMonitor and verifyPlayerJoinTicket.
Everything below is for running a gate yourself.
Requirements
You need:
- Node.js 20 or later
- PostgreSQL
@flayerlabs/gamemode-specat the matching SDK version
Install the package and direct database dependency:
pnpm add @flayerlabs/gamemode-gate @flayerlabs/gamemode-spec pgStart a local gate without a chain
createDemoGate starts the real gate flow with a stand-in wallet and no chain access. It expects
PostgreSQL at postgres://postgres:[email protected]:55439/gamemode unless you set DATABASE_URL.
import { createDemoGate } from '@flayerlabs/gamemode-gate'
import { rules, type Config } from './game/rules.js'
const config: Config = {
rounds: 3,
ceiling: 10,
points: 500,
roundMs: 10_000,
}
const demo = await createDemoGate({
game: rules,
config,
gateOrigin: 'http://127.0.0.1:4000',
})
await demo.app.listen({ host: '127.0.0.1', port: 4000 })This path uses the real room, database, socket and signed spend authorisations. It records purchases
instead of submitting them to a chain. Do not use createDemoGate in production.
Build a production gate
Run migrate(pool) before the gate accepts traffic.
If you already ran the pre-release schema, run migrate(pool) again. An empty setup upgrades in
place. Delete any pre-release test rounds first because their game ID and point limit are unknown.
A createGate setup needs:
Sessionsconfigured with the page domain and the gate's exact public originClaims,LedgerandPayloadSignerfor spending authorisationspointsPerDollar,usdPerEth(the ETH pairing's price) and the allowed game origins
Add the chain services used by your deployment:
Discoveryverifies each launch before the gate adopts it. It resolves the coin's own PositionManager and adopts the coin when that manager is wired to your calculator (checked once per manager, from the chain), so one gate serves one calculator and every manager wired to it; the configured manager is the primary one, not the only oneSettlementupdates recorded spending from the chainRegistryPricesprices any pairing the manager's PairedTokenRegistry approves, from the calculator it was registered with; pass itspriceForand serveannounceSpendTokenso a new approval needs no gate change (startGatedoes both)
Since 0.7.0 the gate signs a cumulative spend ceiling rather than a per-purchase amount, so nothing
is held: Settlement reads walletSpentWei two blocks behind the head and records it, never
backwards, and a declined buy leaves the player's allowance where it was. See the production gate
guide's cumulative spend ceilings section for the wire shape and the deploy order.
Run one gate process for each game, or one platform gate process per chain. The current room writer does not support multiple replicas.
Set TURNSTILE_SITE_KEY and TURNSTILE_SECRET to require Cloudflare Turnstile at sign-in. The gate
announces the site key so the trusted parent can render the widget. Set TRUSTED_PROXY_HOPS so
admission sees the player's address, not your platform's router. The Turnstile policy checks session
creation. Game action and claim policy remain the gate operator's responsibility.
Set PLAYER_NOTE_SIGNERS to require a player note from the platform at sign-in. A note is the
platform's signed word that this wallet is one account's game wallet, bound to this gate and this
sign-in code, carrying a per-gate account pseudonym and an allocation share. On a game-server gate,
caps (maxPoints on a consumed ticket) and boots then follow the account, and boot cooldowns
escalate by themselves with an account's recent boots (BOOT_ESCALATION). The note format and the
JSON session evidence that carries it beside a Turnstile token live in
@flayerlabs/gamemode-spec/embed (playerNoteTypedData, encodePlayerNote,
encodeSessionEvidence), so the platform signs exactly what the gate checks.
Read the production gate guide before deploying a gate.
Connect an existing game server
Use createGameServerGate() when your server already owns gameplay and scoring:
import {
createGameServerGate,
verifyPlayerJoinTicket,
} from '@flayerlabs/gamemode-gate'
const awardToken = process.env.AWARD_TOKEN
if (!awardToken || awardToken.length < 32) {
throw new Error('AWARD_TOKEN must contain at least 32 characters')
}
const app = createGameServerGate({
pool,
sessions,
claims,
discovery,
settlement,
gameId: 'my-game',
maxPointsPerPlayer: 10_000,
awardToken,
pointsPerDollar: 500,
usdPerEth: 3_000,
allowedOrigins: ['https://game.example'],
})The launch page reads GET /config before it lets anyone launch a coin through your game, and
createGameServerGate() serves that route only when you pass announce — without it the form
reports your gate as not answering. Add it to the options:
const app = createGameServerGate({
// ...the options above...
announce: () => ({
chainId: 84532,
contracts: {
positionManager: '0x4E7cB1e6800a7B297B38BddcecAF9Ca5b6616FDC',
// The legacy stack's version 1 calculator. The paired stack's v2 calculator (0.7.0) is
// 0x8a36826253882c8b505D6D78a71B780Ac9c98eA9 on Base Sepolia.
spendGatedCalculator: '0x8cbbE6b4cFA5Ccf68399Dbf1429d91A21097ebA5',
},
signer: signerAddress,
settler: signerAddress,
walletCap: '25000000000000000',
roundDurationMs: 90_000,
minLobbyLeadMs: 60_000,
gateEndsAtGraceS: 10,
flaunchVariant: 'v1_2',
requiresEoa: false,
accepting: true,
publicLaunchesOpen: true,
privateLaunches: false,
}),
})The addresses are per chain (the pair above is Base Sepolia); announce a signer you actually hold —
launches write it into their pools as the trusted signer. Verify with curl https://<gate>/config.
The served response adds gateVersion, the version of @flayerlabs/gamemode-gate answering. The
route sets it; an announcement cannot override it. GET /stats carries the same field, and the
package exports GATE_VERSION for logs. The Flaunch dashboard reads it to confirm a gate runs a
current SDK.
Generate a random 256-bit award token, for example with openssl rand -hex 32. Keep it on the gate
and authoritative game server. Never send it to the browser or use a memorable value.
Use a stable, distinct gameId for each game. It scopes durable rounds and awards when games share
a PostgreSQL database.
The browser calls joinTicket() after it joins the economy and sends the opaque result as its first
game-server socket message. Verify it before accepting gameplay:
const playerJoin = verifyPlayerJoinTicket(message.ticket, {
awardToken,
gameId: 'my-game',
roundId,
audience: 'https://gate.example',
})
if (!playerJoin || usedTicketIds.has(playerJoin.jti)) {
socket.close(4401, 'invalid join ticket')
return
}
usedTicketIds.add(playerJoin.jti)The default audience is the gate origin. This works when a reverse proxy serves the game WebSocket
from a path on that origin. For a separate origin, pass the same reviewed gameServerOrigins list
to createGameServerGate() and the platform registration, then use that exact origin as the ticket
audience. A ticket is short lived; also reject replays and supersede an earlier connection for the
same playerJoin.player.
Your server adds points with POST /internal/rounds/:id/awards. The gate checks the token, event
ID, wallet, round window and per-player limit before it changes the ledger. It also makes identical
retries safe.
GET /internal/rounds/:id/players/:player reports what one wallet has earned and how much more it
may earn, so your server can refuse a seat before the player takes one. That read is advisory; the
award route still enforces the ceiling.
POST /internal/heartbeat, sent every 30 seconds by startGameServerHeartbeat, tells the gate your
server is up between rounds; the platform reads it back from GET /games/:gameId/health.
Every gate also serves two public reads the Flaunch coin page renders: GET /coins/:coin/rankings,
the round's standings once it is over (and the running order while it is live), and a roster on
GET /rounds/:id naming who holds a seat. Nothing to configure — see
read the standings.
Use the external game-server steps for the request format, responses and deployment checks. The runnable external-server example shows the complete browser, WebSocket and award flow. Do not call the ledger or signer directly.
Boot it from the environment
startGameServerGate({ maxPointsPerPlayer }) does for a game-server gate what startGate does for
a rules gate: it reads the environment in DEPLOY.md, checks the chain, migrates
Postgres and listens. It adds AWARD_TOKEN, GAME_SERVER_ORIGINS and GAME_ID.
Catch scoring that no player could manage
createScoreRateMonitor judges what each player earns in fixed windows (10 seconds by default)
against what players earned in the same window of recent rounds. Points are divided by your room's
enemy scale, compared on a log scale against a robust median and deviation, and a player is judged
on any of: a window over the supply the room could have offered, one extreme spike, two high windows
in three, a sustained excess (CUSUM), or a running total far ahead of everyone for several windows.
One great window is never enough.
const monitor = createScoreRateMonitor({
baseline: () => latestBaseline, // ScoreBaseline.fromJSON(await GET /internal/score-baseline)
enemyScale: (players) => 1 + 0.5 * (players - 1),
supplyCeiling: (window, players) => maxPointsTheRoomCouldSpawn(window, players),
})
const round = monitor.round(roundStartsAt)
round.award(player, points, Date.now()) // on every kill
for (const verdict of round.close(Date.now(), playersInRoom)) {
// POST /internal/rounds/:id/players/:player/boot, close their socket, drop what they had not banked
}
// at the end: POST /internal/rounds/:id/score-windows with round.samples(roundId)Run it in shadow mode first: log the verdicts, replay them against real rounds, then enforce.
The score is not the only thing a server can judge. If it measures how each player plays (reaction
times, aim speed, accuracy), it can keep those measurements on the gate too, so its baseline
survives a redeploy: POST /internal/rounds/:id/input-features with
{ gameVersion, samples: [{ player, feature, value, flagged? }] } at the end of a round, and
GET /internal/input-baseline?gameVersion= for the newest values per feature, leaving out players
flagged in their round or booted from it. Bump gameVersion when a gameplay change moves what
normal play looks like; the new version starts a baseline of its own.
Run the central game registry
createGameRegistry provides:
- public
GET /games/:chainId/:coinlookups for trusted Flaunch pages - authenticated internal routes for the Flaunch launch backend to pin or disable a registration
The registry is a central Flaunch service. Do not run it inside a creator gate. Its
writerToken must contain at least 32 characters and stay on the launch backend.
Read the current request and response behaviour in the game registry source.
For custom gate assembly, pass readiness: createGateReadiness({ origin, chainId,
positionManager, signer, pool, client }) using the gameplay dependencies. startGate wires this
automatically. See readiness setup.
Serve play analytics
Every gate serves GET /stats/plays for the Flaunch developer dashboard. It returns durable
per-game play counts that survive a room ending: { trackingSince, sessions, nextCursor }, where
each session carries id, coin and startedAt (Unix milliseconds).
The gate records one accepted session per wallet and round on every join path — the SDK-managed join, the external-server join and the external-server ticket. Reconnects and repeated joins keep the first timestamp. Session IDs are stable hashes; wallet addresses are never returned. Rounds without a coin (practice and mock rooms) are excluded.
The route returns at most 250 rows ordered by start time and ID. Pass nextCursor back as
?cursor= to continue; null ends the pass. A malformed cursor returns 400. Consumers should
deduplicate by id and validate coin against their own launch pins. trackingSince is the time
the migration first ran on that gate: earlier plays are not counted.
See what changed
This package ships CHANGELOG.md and MIGRATING.md, shared by all four Game Mode packages. Read
them on GitHub: changelog and
migration guide.
