Nuvaka › Developer docs › Extension servers

Extension servers

An extension can ship server code in its package. The code starts as a server instance that runs continuously on a separate Nuvaka machine; users join these servers from inside the app. Since the server makes the decisions, you don't have to trust the client.

Version: arrives with Nuvaka 0.3.1 and SDK 1.3.0 in the developer kit; 1.3.1 is recommended (its mock server and the init --server template follow the join order below). Put "minAppVersion": "0.3.1" in your manifest.

Use cases:

  • a game world that's online 24/7;
  • a backend for a social app;
  • a cheat-proof game where the server decides;
  • a match server started by a user.

Code that hasn't passed review never runs. A server can't be a public website: players only come in with a Nuvaka account, from inside the app. For rooms without server code that only relay: Realtime rooms.

Quick start: a publisher (always-on) server

  1. Create the skeleton
    nuvaka-ext init game --username your-username --server
    # or: npm create @nuvaka/extension -- --server

    The template creates server/main.js (a server that counts clicks and stores them in SQLite), a UI that uses it and a test; it adds the server block (e.g. { "entry": "server/main.js", "tickHz": 5, "maxPlayers": 100 }) and the nuvaka.realtime permission to the manifest. The template requires SDK ^1.3.0. --server also sets up the UI; it can't be combined with a data addon (--kind data).

  2. Write the server code and try it locally

    Write nuvaka.on('start' | 'join' | 'message' | 'tick' | 'leave' | 'stop', …) handlers in server/main.js (Server code). Test with the SDK's mock server: nuvaka-ext test (Testing).

  3. Publish
    nuvaka-ext publish --changelog "Server"

    Every version with server goes to review and is published once approved. Servers always run the code of the extension's published version.

  4. Start the server

    After approval, define the server. In the app: Profile › Developer › Servers › pick your extension › New server (details). Or via the API, with a user token or an nvd_ developer token:

    POST https://generalappapi.nuvaka.com/api/ext/v1/dev/extensions/<id>/servers
    Authorization: Bearer nvd_xxxxxxxx
    Content-Type: application/json
    
    { "name": "Main world", "visibility": "public", "maxPlayers": 100 }
    
    → { "success": true, "id": "<serverId>", "started": true, "error": null }

    If you get started: false, the reason is in error; logs are behind the Logs button in the app or at GET …/servers/<serverId>/logs.

  5. The supervisor does the rest

    From then on the server stays up: if it crashes or a new version is approved, the supervisor restarts it within 20 s. See its state with GET …/servers (status, players, limits); change settings with PATCH …/servers/<id>, restart with POST …/<id>/restart, remove with DELETE (Publisher API).

  6. The app side

    nuvaka.servers.list() fetches the list; join with join(serverId, password?), write with send, listen with onMessage (Client API).

Manifest

"server": { "entry": "server/main.js", "tickHz": 5, "maxPlayers": 16, "memoryMB": 64, "userServers": true },
"permissions": [{ "nuvaka.realtime": true, "reason": { "tr": "Çok oyunculu", "en": "Multiplayer" } }]
FieldRule
entryrequired; a .js or .mjs file in the package (at most 200 characters). Can't be the UI file (entry.ui). Server code goes through the normal scan too (eval, new Function … are forbidden).
tickHzinteger 0–20; 0 = no tick
maxPlayersinteger 1–1000; default 16. No server's player limit can exceed it.
memoryMBinteger 16–128; default 64. Memory limit per instance.
userServerstrue/false. With true, users can also start servers from the app; otherwise only the publisher's always-on servers run.
  • The nuvaka.realtime permission is required because players connect to the server over the realtime channel.
  • Unknown fields are rejected. A data addon can't have server.
  • Every version with server goes to Nuvaka review (Review).

Server code

Server code runs as a classic script in isolated V8 (isolated-vm). There is no require, import/export, process, fetch, timers (setTimeout/setInterval), file system or network; your code must be a single file (bundle it if needed). It touches the world only through the global nuvaka object. nuvaka-ext lint warns if it sees any of these in server code.

Types are in the @nuvaka/extension-sdk/server module (types only; no runtime code):

/** @type {import('@nuvaka/extension-sdk/server').ServerNuvaka} */
const nv = globalThis.nuvaka

Events

Listen with nuvaka.on(event, fn). Each event has a single handler; a later on replaces the earlier one.

EventWhenTime limit
start()as the server starts2 s
join(player)as a player joins; returning false or a string (reason) rejects, anything else accepts200 ms
leave(player)as a player leaves or their connection drops200 ms
message(player, data)when a player sends a message50 ms
tick(dtMs)tickHz times per second; dtMs is the time since the previous tick50 ms
stop()as the server shuts down1 s

player = { username, displayName }. For a player with a private profile displayName is null; it may be missing in leave.

Join order

A player is added to the players() list before the join handler runs. So inside join, send(player.username, …) and broadcast also reach the joiner: send them the initial state right there. If the handler rejects, the player is removed from the list again.

nv.on('join', (player) => {
  if (isBanned(player.username)) return 'You are banned from this server'   // reject; the reason goes to the player
  nv.send(player.username, { t: 'hello', world: snapshot() })              // reaches the joiner
  nv.broadcast({ t: 'joined', username: player.username }, player.username)
  return true
})

Tools

ToolDescription
send(username, data)to one player; the player gets server.message. data ≤ 16 KB of JSON.
broadcast(data, exceptUsername?)to everyone (optionally except one); ≤ 16 KB
kick(username, reason?)kicks the player; the player gets server.kicked { reason }
players()connected players: [{ username, displayName }]
setInfo(obj)info shown in the server list (map, mode …); ≤ 1 KB of JSON
log(...args)goes to the publisher's log screen; last 200 lines, at most 2000 characters per line
instance{ id, name, maxPlayers, extensionId, version }
dbSQLite per extension (below)

Errors and crashes

  • A timeout or an error thrown in a handler doesn't bring the server down; the error is written to the log.
  • If the memory limit (memoryMB) is exceeded, or after 200 errors, the instance shuts down. A publisher server is restarted within 20 s.
  • Server code has no network access, but don't use data from players without validating it either: check every field in message.

nuvaka.db: SQLite per extension

Each extension has its own SQLite database on the server side; all of the extension's server instances use the same database. Calls are synchronous.

MethodReturns
exec(sql)null; multiple statements (schema, migrations)
run(sql, ...params){ changes, lastInsertRowid }
get(sql, ...params)the first row or null
all(sql, ...params)rows; at most 5000
  • Parameters are passed with ?. Objects and arrays are stored as JSON text; undefined becomes NULL.
  • ATTACH, DETACH, load_extension, VACUUM INTO and PRAGMAs are forbidden; only table_info, index_list, foreign_key_list and user_version can be read.
  • Quota: 50 MB + installs / 10 MB, at most 10 GB; an administrator can grant extra space (below). The database is for shared, small state; for large user data see below.
  • Deleting a server doesn't delete the database.
nv.on('start', () => {
  nv.db.exec('CREATE TABLE IF NOT EXISTS wins (username TEXT PRIMARY KEY, wins INTEGER NOT NULL DEFAULT 0)')
})
nv.db.run('INSERT INTO wins (username, wins) VALUES (?, 1) ON CONFLICT (username) DO UPDATE SET wins = wins + 1', player.username)
const top = nv.db.all('SELECT username, wins FROM wins ORDER BY wins DESC LIMIT 10')

Large or personal data: the user's storage, not the database

The server database is for shared, small state: lobbies, leaderboards, matchmaking, room and list info. Large data that belongs to a user (files, photos, long history) doesn't go into the server database; it lives in the user's own Nuvaka storage:

  • App space (nuvaka.storage): the space reserved for your extension in each user's account; manifest storage.quotaMB at most 100. No permission needed; syncs across devices.
  • My Files (nuvaka.cloud): the folders the user opened to your extension, on the user's own quota. This is where large data goes. Writing needs nuvaka.cloud: write; this critical permission is confirmed on every use and every version requesting it goes to review.
  • Encryption: encrypt sensitive data on the client (WebCrypto, e.g. AES-GCM; the key comes from the user or your extension's local space) and upload with encrypted: true. Nuvaka only sees encrypted bytes; files marked encrypted get no preview and the assistant doesn't read them.
  • Keep only references in the server database: which user's which file (fileId). The large content doesn't live there.
// UI: encrypt with AES-GCM on the client, upload encrypted to My Files, send only the fileId to the server
async function saveEncrypted(folderId, name, bytes, key) {    // key: a WebCrypto AES-GCM CryptoKey
  const iv = crypto.getRandomValues(new Uint8Array(12))
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes))
  const blob = new Uint8Array(iv.length + ct.length)
  blob.set(iv)
  blob.set(ct, iv.length)
  const { file } = await nuvaka.cloud.upload(folderId, name, blob, 'application/octet-stream', { encrypted: true })
  await nuvaka.servers.send(serverId, { t: 'attach', fileId: file.fileId })
  return file.fileId
}

async function loadEncrypted(fileId, key) {
  const blob = await nuvaka.cloud.read(fileId)                  // Uint8Array
  const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: blob.slice(0, 12) }, key, blob.slice(12))
  return new Uint8Array(plain)
}
// server/main.js: store only the reference
nv.on('message', (player, data) => {
  if (data?.t === 'attach' && typeof data.fileId === 'string' && data.fileId.length <= 100)
    nv.db.run('INSERT INTO attachments (username, file_id) VALUES (?, ?)', player.username, data.fileId)
})

Server kinds

Publisher server (always on)User server
Started byThe publisher: Profile › Developer › Servers in the app, or the APIA user, from inside the app (nuvaka.servers.create); needs userServers: true in the manifest
LifetimeA supervisor keeps it up and restarts it after a crash; when a new approved version comes out it restarts it on the new versionCloses 30 min after nobody is connected, after 12 hours at most
Limit3 + installs/100, at most 202 at once per person; 100 + installs/2 at once per extension, at most 2000
In the listfirst; also shown while down (stopped/failed)only while running

Status (status)

ValueMeaning
runningrunning
stoppedstopped; the supervisor restarts publisher servers
failedcouldn't start or crashed; the reason is in lastError
disabledstopped by the publisher; the supervisor doesn't start it

Show any other value as "pending".

Visibility

ValueWho can join
publiceveryone using the app (default)
friendsthe server owner's friends
passwordeveryone sees it; joining needs a password (4–100 characters; stored with bcrypt)
inviteonly friends the owner invited

The server owner joins without being asked for a password. Changing the password: if the visibility is already password, PATCH { password } changes only the password; PATCH { visibility: "password", password } makes the server password-protected; switching to another visibility deletes the password.

Managing in the app: Profile › Developer › Servers

  • Pick your extension; publisher servers and servers started by users are listed together (kind, status, player count, start time).
  • New server: name (1–60 characters), visibility, password, max players (can't exceed server.maxPlayers). It starts once created.
  • Edit: the same fields plus "Stopped" (the supervisor won't start it). Leave the password empty to keep it unchanged.
  • Restart, Delete (connected players are removed; the database isn't deleted) and Logs (log(...) output and caught errors, last 200 lines, kept in memory).
  • Quotas are shown at the top: always-on servers, open user servers, the per-person limit, the database quota. If the runner can't be reached, servers can't be started; try again a bit later.

Publisher API

With a user token or an nvd_ developer token, at /api/ext/v1/dev/extensions/{id}/servers:

EndpointDescription
GET{ items: [{ id, kind, name, visibility, maxPlayers, version, status, lastError, info, players, createdAt, startedAt }], limits, runnerAvailable }
POST { name, visibility?, password?, maxPlayers? }{ id, started, error }. Quota full: 429 quota; no server in the published version: 400 no_server_code.
PATCH /{sid} { name?, visibility?, password?, maxPlayers?, disabled? }edit; password rules above
POST /{sid}/restartrestart
DELETE /{sid}delete
GET /{sid}/logslast 200 log lines

Client API: nuvaka.servers

Requires the nuvaka.realtime permission. Private copies can't use it (not_for_copies); it's disabled in a secure session.

CallWhereReturns
list()bothservers you can join; the publisher's first, then by player count
create({ name, visibility?, password?, maxPlayers? })both{ success, serverId }; starts a user server. Call join afterwards to enter.
delete(serverId)bothcloses only a server you started; everyone connected gets server.closed (closed_by_owner)
invite(serverId, username)bothowner only, friends only
join(serverId, password?)UI{ joined, serverId, info, username }; username is your username
send(serverId, data)UInull; the server receives it in message(player, data)
leave(serverId)UInull; no effect if you haven't joined
invited()UIthe server id if opened from an invite link (ctx.server), otherwise null

A list() item: { serverId, kind: 'publisher' | 'user', name, visibility, needsPassword, owner, mine, status, players, maxPlayers, info, createdAt }. needsPassword is true for a password-protected server that isn't yours; owner is null for publisher servers; info is what the server code set with setInfo. The list only contains servers you can join: a friends server is visible to the owner's friends, an invite server to invitees (the owner always sees it).

create rules: name 1–60 characters; visibility defaults to public; password only for password visibility, 4–100 characters; maxPlayers is clamped to the manifest's server.maxPlayers.

Events

EventShortcutPayload
server.messageonMessage{ serverId, data } — from the server code's send/broadcast
server.kickedonKicked{ serverId, reason }
server.closedonClosed{ serverId, reason } — the owner closed it (closed_by_owner), the publisher deleted/stopped/restarted it, it crashed or sat idle, or rejoining failed after the connection came back (reason = the error code)
server.inviteonInvite{ serverId } — a server invite was tapped while the app was open
realtime.statusonStatus{ connected, reconnecting?, error? } — the connection shared with realtime rooms

If the connection drops, the shell reconnects and rejoins the server automatically; if that fails you get server.closed { reason: <error code> }.

Invites and links

The server owner invites a friend with invite(serverId, username). The invitee gets an ext_server_invite notification in their own language; its link has the form /apps?id=<extension>&server=<serverId>. Tapping it opens your app with ctx.server in the context (nuvaka.servers.invited()); if the app is already open, onInvite fires. Only people invited this way can join an invite server.

Lobby pattern

Nuvaka doesn't show a built-in server picker: info (map, mode …) is specific to your app, and the lobby's look is part of your design. Draw the list, dim servers that aren't running, and ask for passwords in your own dialog.

Error codes

CodeMeaning
server_not_foundno such server
server_not_runningthe server isn't running
friends_onlyonly the owner's friends can join
bad_passwordwrong or missing password (in create: password isn't 4–100 characters)
invite_onlyonly invitees can join
server_fullthe server is full
rejectedthe server code's join handler rejected you; reason in e.data.reason
server_unavailablethe server runner can't be reached
not_joinedsend without joining
message_too_largemessage is over 16 KB
rate_limitedover 20 messages per second
user_servers_disabled(403) no userServers in the manifest
too_many_servers(429) at most 2 user servers at once per person
extension_quota(429) the extension's open user server quota is full
start_failed(502) the server couldn't start
not_owneronly the server's owner can close it or invite
not_friendonly friends can be invited
bad_name, bad_visibilityname isn't 1–60 characters / invalid visibility
not_for_copies, secure_session, ui_onlyprivate copy / secure session / join, send, leave in the background

Limits

Limit
tickHz0–20
maxPlayers (players per server)1–1000 (default 16)
memoryMB16–128 (default 64)
Message (both directions)≤ 16 KB of JSON
Player sends20 per second
setInfo≤ 1 KB
Loglast 200 lines
Handler timestart 2 s, join/leave 200 ms, message/tick 50 ms, stop 1 s
Publisher servers3 + installs/100, at most 20
User servers (per extension, at once)100 + installs/2, at most 2000
User servers per person2
Database50 MB + installs/10, at most 10 GB; all ≤ 5000 rows

There are no small fixed caps; quotas grow with the install count. The real limit is the machine's resources, which the supervisor watches. Current values come back in the limits field of the publisher API's GET …/servers response (including the allowed maxPlayers).

More database space: a Nuvaka administrator can grant an extension extra server database space; running servers pick it up when they restart. If you need it, write to [email protected] with the extension id and your reason. Read the large data section below first: user data shouldn't go into the database in the first place.

Review

  • Every version with server goes to Nuvaka review, even for verified publishers and non-first versions. Because server code runs on Nuvaka's machine, this rule isn't relaxed.
  • Until approval, servers stay on the old version. Once a new version is approved, the supervisor restarts publisher servers on it; connected players get server.closed, and your client should go back to the lobby and rejoin.

Security and privacy

  • Server code runs on a separate Nuvaka machine in an unprivileged service; each instance starts in its own isolate with its own memory limit.
  • Traffic between the Nuvaka API and the runner is TLS-encrypted and every request is signed. The package is uploaded to the runner with digest verification.
  • Players reach the server code with only their username and display name; email and user ids aren't given.

Testing: the mock nuvaka

@nuvaka/extension-sdk/testing simulates the Nuvaka API and the runner in memory; your server code really runs in-process. Visibility, the 2-per-person limit, capacity, rejected and send-rate rules apply.

ToolDescription
servers: { script, userServers, maxPlayers, tickHz, db, invitedServer }option. script: (nv) => { … } is the server code; each instance runs it at start with a mock ServerNuvaka. db: a nuvaka.db implementation (without it calls throw unsupported). Username and friends come from the realtime option.
m.serverHost.create({ name, kind?, owner?, visibility?, password?, maxPlayers?, script? })starts a server (default: a public publisher server); returns its id
m.serverHost.join(id, username)another player joins: true when accepted, the reason or false when rejected
m.serverHost.send / leave / tick / stop / inviteanother player's message, their leaving, one tick (default 1000 / tickHz ms), the server shutting down, inviting this user
m.serverHost.server(id)internals: players, info, logs, errors, outbox
m.log.servers, m.log.serverInviteswhat was sent to servers and invites

Provide your own nuvaka.db implementation, e.g. a small wrapper over node:sqlite. Since server/main.js is a classic script that reads globalThis.nuvaka as it loads, collect its handlers through a proxy and attach them to each mock instance, as the init --server template does (see the example).

Full example: number guess

The server picks a number between 1 and 100; players guess and the server says "up/down". The first to guess it wins, the win is written to SQLite, and a round ends after 60 seconds. Only the server knows the number, so clients can't cheat.

nuvaka.json

{
  "id": "your-username.guess",
  "name": "Number guess",
  "version": "1.0.0",
  "apiVersion": 1,
  "minAppVersion": "0.3.1",
  "description": { "tr": "1–100 arası sayıyı ilk bilen kazanır", "en": "First to guess the number between 1 and 100 wins" },
  "storeCategory": "games",
  "icon": "icon.svg",
  "entry": { "ui": "index.html" },
  "pages": [{ "id": "main", "title": { "tr": "Sayı tahmini", "en": "Number guess" } }],
  "permissions": [
    { "nuvaka.realtime": true, "reason": { "tr": "Oyun sunucusuna bağlanmak için", "en": "To connect to the game server" } }
  ],
  "server": { "entry": "server/main.js", "tickHz": 1, "maxPlayers": 32, "memoryMB": 32, "userServers": true }
}

server/main.js

// Number guess: the server decides. Classic script; no import/require, fetch or timers.
/** @type {import('@nuvaka/extension-sdk/server').ServerNuvaka} */
const nv = globalThis.nuvaka
const ROUND_MS = 60_000
let secret = 0
let left = ROUND_MS
let round = 0

function newRound() {
  secret = 1 + Math.floor(Math.random() * 100)
  left = ROUND_MS
  round++
  nv.setInfo({ round })                                   // shown in the server list
  nv.broadcast({ t: 'round', round, seconds: ROUND_MS / 1000 })
}
const top = () => nv.db.all('SELECT username, wins FROM wins ORDER BY wins DESC LIMIT 10')

nv.on('start', () => {
  nv.db.exec(`
    CREATE TABLE IF NOT EXISTS wins (username TEXT PRIMARY KEY, wins INTEGER NOT NULL DEFAULT 0);
    CREATE TABLE IF NOT EXISTS bans (username TEXT PRIMARY KEY);
  `)
  newRound()
  nv.log('started:', nv.instance.name, nv.instance.version)
})

nv.on('join', (player) => {
  if (nv.db.get('SELECT 1 AS x FROM bans WHERE username = ?', player.username)) return 'You are banned from this server'
  // The player is in the list before this handler runs: send the initial state straight to them
  nv.send(player.username, { t: 'hello', round, secondsLeft: Math.ceil(left / 1000), top: top() })
  nv.broadcast({ t: 'joined', username: player.username }, player.username)
  return true
})

nv.on('message', (player, data) => {
  if (!data || data.t !== 'guess' || !Number.isInteger(data.n) || data.n < 1 || data.n > 100) return
  if (data.n !== secret) return nv.send(player.username, { t: 'hint', n: data.n, hint: data.n < secret ? 'up' : 'down' })
  nv.db.run('INSERT INTO wins (username, wins) VALUES (?, 1) ON CONFLICT (username) DO UPDATE SET wins = wins + 1', player.username)
  nv.broadcast({ t: 'won', username: player.username, n: secret, top: top() })
  newRound()
})

nv.on('tick', (dtMs) => {
  left -= dtMs
  if (left > 0) return
  nv.broadcast({ t: 'timeout', n: secret })
  newRound()
})

nv.on('leave', (player) => nv.broadcast({ t: 'left', username: player.username }))
nv.on('stop', () => nv.log('stopping; round', round))

app.js (lobby and game)

UI functions such as showLobby, showGame, showHint, showTop, askPassword and toast are yours.

let joined = null

async function lobby(message) {
  joined = null
  const servers = await nuvaka.servers.list()          // the publisher's first
  showLobby(message, servers.map((s) => ({
    id: s.serverId,
    label: `${s.name} · ${s.players}/${s.maxPlayers} · round ${s.info?.round ?? '-'}`,
    locked: s.needsPassword,
    dim: s.status !== 'running',
  })))
}

async function enter(serverId, password = null) {
  try {
    const { info } = await nuvaka.servers.join(serverId, password)
    joined = serverId
    showGame(info)
  } catch (e) {
    if (e.code === 'bad_password') return askPassword(serverId)          // your own password dialog, then enter(id, password)
    if (e.code === 'rejected') return lobby(e.data?.reason ?? 'Rejected')
    lobby(e.code)                                                         // server_full, invite_only, friends_only …
  }
}

nuvaka.servers.onMessage(({ serverId, data }) => {
  if (serverId !== joined) return
  if (data.t === 'hello') { showGame({ round: data.round }); showTop(data.top) }
  else if (data.t === 'hint') showHint(data.n, data.hint)
  else if (data.t === 'won') { toast(`${data.username} got it: ${data.n}`); showTop(data.top) }
  else if (data.t === 'timeout') toast(`Time's up, the number was ${data.n}`)
})
nuvaka.servers.onKicked(({ reason }) => lobby(reason || 'You were removed from the server'))
nuvaka.servers.onClosed(({ reason }) => lobby(reason === 'closed_by_owner' ? 'The owner closed the server' : 'The server closed'))
nuvaka.servers.onInvite(({ serverId }) => enter(serverId))
nuvaka.servers.onStatus(({ connected, reconnecting }) => setBanner(connected ? '' : reconnecting ? 'Reconnecting…' : 'Offline'))

const guess = (n) => nuvaka.servers.send(joined, { t: 'guess', n })
const back = async () => { await nuvaka.servers.leave(joined); lobby() }

// userServers: start your own server, invite a friend, close it
async function hostOwn(name) {
  const { serverId } = await nuvaka.servers.create({ name, visibility: 'invite', maxPlayers: 8 })
  await enter(serverId)
}
const inviteFriend = (username) => nuvaka.servers.invite(joined, username)
const closeOwn = () => nuvaka.servers.delete(joined)

nuvaka.ready.then(() => {
  const invited = nuvaka.servers.invited()             // opened from a notification: go straight in
  return invited ? enter(invited) : lobby()
})

test/server.test.js

import { it, expect } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { createMockNuvaka, installMockNuvaka } from '@nuvaka/extension-sdk/testing'

// node:sqlite in place of nuvaka.db
function memoryDb() {
  const sql = new DatabaseSync(':memory:')
  return {
    exec: (s) => { sql.exec(s); return null },
    run: (s, ...p) => { const r = sql.prepare(s).run(...p); return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) } },
    get: (s, ...p) => sql.prepare(s).get(...p) ?? null,
    all: (s, ...p) => sql.prepare(s).all(...p),
  }
}

// server/main.js is a classic script that reads globalThis.nuvaka as it loads → collect the handlers, attach them to each mock instance
async function loadServer() {
  let srv
  const handlers = {}
  const prev = globalThis.nuvaka
  globalThis.nuvaka = new Proxy({}, { get: (_, k) => (k === 'on' ? (ev, fn) => { handlers[ev] = fn } : srv[k]) })
  await import('../server/main.js')
  globalThis.nuvaka = prev
  return (nv) => { srv = nv; for (const [ev, fn] of Object.entries(handlers)) nv.on(ev, fn) }
}

it('guesses, leaderboard, rejection and timeout', async () => {
  const db = memoryDb()
  const m = installMockNuvaka(createMockNuvaka({
    grants: ['nuvaka.realtime'], realtime: { username: 'ayse' },
    servers: { db, tickHz: 1, script: await loadServer() },
  }))
  const sid = m.serverHost.create({ name: 'Europe 1' })
  const got = []
  m.servers.onMessage(({ data }) => { got.push(data) })

  expect((await m.servers.join(sid)).info).toEqual({ round: 1 })
  expect(got[0].t).toBe('hello')                         // the send inside join reached the joiner

  // binary search with "up/down": at most 7 guesses
  let lo = 1, hi = 100, won = null
  while (!won) {
    const n = Math.floor((lo + hi) / 2)
    await m.servers.send(sid, { t: 'guess', n })
    won = got.find((d) => d.t === 'won')
    const hint = got.at(-1)
    if (hint.t === 'hint') hint.hint === 'up' ? (lo = n + 1) : (hi = n - 1)
  }
  expect(won.top).toEqual([{ username: 'ayse', wins: 1 }])
  expect(m.serverHost.server(sid).info).toEqual({ round: 2 })

  db.run('INSERT INTO bans (username) VALUES (?)', 'veli')
  expect(await m.serverHost.join(sid, 'veli')).toBe('You are banned from this server')

  for (let i = 0; i < 60; i++) await m.serverHost.tick(sid)  // 60 × 1000 ms
  expect(got.some((d) => d.t === 'timeout')).toBe(true)
})

Nuvaka Apps API v1 · last updated 2026-09-29