Nuvaka › Developer docs › Realtime rooms

Realtime rooms: nuvaka.realtime

Live data between users: online games, a shared board, editing together. One user creates a room, others join with a 6-character code or a friend invite; people in the room send each other messages and share a versioned state. The server only relays; it doesn't store messages.

Version: arrives with Nuvaka 0.3.1 and SDK 1.2.0 in the developer kit. Put "minAppVersion": "0.3.1" in your manifest.

Room or extension server?

Realtime roomExtension server
CodeNone; clients make the decisions, Nuvaka only relaysServer code from your package runs on a Nuvaka machine and makes the decisions
LifetimeIn memory; closes when idle or after 24 hours at mostPublisher servers are always on; user servers run for at most 12 hours
Persistent dataNone (the shared state is gone when the room closes)SQLite per extension (nuvaka.db)
ReviewNormal rulesEvery version with server is reviewed
Good forSmall games with friends, a shared board, editing together24/7 worlds, cheat-proof games, multiplayer lobbies

Both use the same permission (nuvaka.realtime) and the same realtime connection in the app.

Permission

"permissions": [
  { "nuvaka.realtime": true, "reason": { "tr": "Arkadaşlarınla aynı odada oynamak için", "en": "To play with your friends in the same room" } }
]
  • The only valid value is true. Risk medium, enforced by the server. Users see: "Exchanges live data with people you choose (game rooms, collaboration)".
  • Requested together with a read permission (files.read, nuvaka.cloud.read, nuvaka.notes.read …) it shows a leak warning at install: "… and can send them to other people in a room".
  • If your code uses nuvaka.realtime (or nuvaka.servers) without requesting the permission, nuvaka-ext lint and upload reject it.
  • Only the app itself can use rooms: running as another extension's private copy (a uses dependency), calls return 403 not_for_copies. A room belongs to one extension; other extensions can't see it.
  • Disabled in a secure session (while a Secure Space file is open): secure_session.

Where it runs

CallsUIBackground
create, join, get, invite, setState, update, leave✓✓
connect, send, sendTo✓ui_only

The app shell holds the connection. In the UI, create and join connect to the room automatically and events start arriving. In the background you can create rooms and write state, but not send or receive messages.

Creating a room

const room = await nuvaka.realtime.create({ maxMembers: 2, joinByCode: true, meta: { game: 'tictactoe' } })
showCode(room.code)   // e.g. "K7Q2MX"; only the creator sees it
OptionRule
maxMembers2–16, default 8
joinByCodewhether joining by code is allowed; default true. With false, only invited people can enter.
metasmall info attached to the room, at most 1 KB of JSON

A person can be in at most 5 rooms at once; more gives too_many_rooms (429). Calls return a room view:

FieldDescription
roomIdthe room's id
extensionIdthe extension the room belongs to
maxMembers, metaas given at creation
code6-character join code (uppercase letters and digits); set only for the creator, null for everyone else
isCreatorwhether you created the room
members[{ username, displayName, online }]
state, stateVersionshared state and its version; null and 0 in a new room
createdAtcreation time (ISO)

nuvaka.realtime.get(roomId) reads the current view. Only members and invited people can see it (not_member); a missing room gives room_not_found.

Joining by code

const room = await nuvaka.realtime.join('k7q2mx')   // case-insensitive

A code only works for this extension's rooms. An invalid code or closed room gives room_not_found, a full room room_full (409). The code stops working when the room closes.

Inviting friends

await nuvaka.realtime.invite(room.roomId, 'veli')
  • Only friends can be invited (not_friend, 403). Your friend must have your app installed (not_installed, 409). The inviter must be a member or invitee of the room (not_member).
  • Invitees plus members in a room can't exceed twice maxMembers (too_many_invites).
  • The invitee gets an ext_realtime_invite notification in their own language (Turkish/English). Its link has the form /apps?id=<extension>&room=<roomId>: tapping it opens your app with ctx.realtimeRoom in the context.
  • The invitee enters with connect before becoming a member. If your app is already open when the invite is tapped, onInvite fires.
  • To show the friend list in your own UI you also need the nuvaka.friends read permission; invite doesn't need it.
nuvaka.ready.then(async () => {
  const id = nuvaka.realtime.invitedRoom()          // ctx.realtimeRoom; null if not opened from an invite
  if (id) enter(await nuvaka.realtime.connect(id))
})
nuvaka.realtime.onInvite(async ({ roomId }) => enter(await nuvaka.realtime.connect(roomId)))

Members

People in a room only see each other's username and display name, both supplied by the server. For someone with a private profile displayName is null. Email and user ids are never shared.

nuvaka.realtime.onMember(({ roomId, type, username, displayName }) => {
  // type: 'joined' | 'offline' (connection dropped, still a member) | 'left'
})

Messages

await nuvaka.realtime.send(roomId, { t: 'emoji', v: '👍' })        // to everyone else in the room
await nuvaka.realtime.sendTo(roomId, 'veli', { t: 'hint', v: 3 })  // to one person

nuvaka.realtime.onMessage(({ roomId, from, data, to, ts }) => {
  // from: { username, displayName }; to: the username for a direct message, otherwise null; ts: ms
})
  • data can be any JSON value, at most 16 KB (message_too_large).
  • Each person can send 20 messages per second per room, with short bursts up to 40; beyond that you get rate_limited. Batch frequent updates such as movement and send them less often.
  • You must be connected to the room to send (not_joined, not_connected); sendTo to someone not in the room gives member_not_found.
  • Message contents are neither stored nor logged on the server. Late joiners can't see earlier messages: put whatever they need to know into the shared state.

Shared state and ifVersion

Each room has one shared state (JSON, at most 64 KB; larger gives state_too_large). Like storage, it's written with MVCC: every write carries the stateVersion you read, and it's rejected if the server's version has moved on. That way, if two players move at the same time, neither overwrites the other.

// Directly: write with the version you read
const room = await nuvaka.realtime.get(roomId)
try {
  const { stateVersion } = await nuvaka.realtime.setState(roomId, next, { ifVersion: room.stateVersion })
} catch (e) {
  if (e instanceof VersionConflictError) {
    // e.current: the current stateVersion, e.value: the current state → recompute
  }
}

// The easy way: read → change → write; retries with the fresh state on conflict (5 tries by default)
await nuvaka.realtime.update(roomId, (state, room) => {
  if (!state || state.turn !== mine) return undefined   // undefined: write nothing
  return { ...state, turn: other }
})
  • ifVersion is required: without it you get if_version_required (428). A new room starts at version 0; each successful write increments it by one.
  • Only room members can write the state (not_member).
  • Every change goes to everyone connected to the room, including the writer, as a realtime.state event: { roomId, stateVersion, state, by }. Render your UI from this event; you don't need to render the write result separately.
  • The update function may run several times in one call; keep it free of side effects.
nuvaka.realtime.onState(({ roomId, stateVersion, state, by }) => render(state))

Leaving and room closing

  • nuvaka.realtime.leave(roomId) → { success, closed }. If the creator leaves or the room becomes empty, the room closes and its code stops working.
  • Rooms live in the server's memory. They close 30 minutes after nobody is connected, and after 24 hours at most in any case. Rooms also close if the server restarts.
  • When a room closes everyone connected gets realtime.closed { roomId }. If the room can't be found when the connection comes back, the event carries a reason (room_not_found, not_member, room_full).
  • On room_not_found, create a new room; don't try to bring the old one back.

Connection status

nuvaka.realtime.onStatus(({ connected, reconnecting, error }) => {
  banner.textContent = connected ? '' : reconnecting ? 'Reconnecting…' : 'Offline'
})

If the connection drops, the shell reconnects and re-enters your rooms automatically; meanwhile you get reconnecting: true. The same connection is also used for extension servers.

Events

The nuvaka.realtime.onX(fn) shortcuts return a function that removes the listener; you can also use nuvaka.on('realtime.message', fn). Events only reach the UI, and only for rooms you're connected to.

EventShortcutPayload
realtime.messageonMessage{ roomId, from: { username, displayName }, data, to, ts }
realtime.memberonMember{ roomId, type: 'joined' | 'offline' | 'left', username, displayName? }
realtime.stateonState{ roomId, stateVersion, state, by }
realtime.closedonClosed{ roomId, reason? }
realtime.statusonStatus{ connected, reconnecting?, error?: { code, message } }
realtime.inviteonInvite{ roomId } — an invite link was tapped while the app was open

Calls

CallReturns
create({ maxMembers?, joinByCode?, meta? })room view
join(code)room view
get(roomId)room view
invite(roomId, username){ success: true }
setState(roomId, state, { ifVersion }){ success: true, stateVersion }
update(roomId, fn, tries?){ stateVersion, state }
leave(roomId){ success: true, closed }
connect(roomId)room view; UI only
send(roomId, data), sendTo(roomId, username, data)null; UI only
invitedRoom()the room id if opened from an invite link, otherwise null

Error codes

CodeMeaning
permission_requiredno nuvaka.realtime permission
not_for_copies(403) an extension running as a private copy can't use rooms
secure_sessiondisabled in a secure session
room_not_found(404) no such room, it closed, or the code is invalid
not_member(403) you're not a member or invitee of the room
room_full(409) the room is at maxMembers
too_many_rooms(429) at most 5 rooms at once
not_friend(403) only friends can be invited
not_installed(409) your friend doesn't have the app installed
too_many_invites(409) invitees + members reached twice maxMembers
if_version_required(428) setState called without a version
version_conflict(409) the version you read is stale; VersionConflictError with e.current (current version) and e.value (current state)
state_too_large(413) state is over 64 KB
message_too_largemessage is over 16 KB
rate_limitedover 20 messages per second (burst 40)
not_joined, not_connectednot connected to the room / the realtime connection
member_not_foundthe sendTo target isn't in the room
ui_onlyconnect/send/sendTo called in the background

Server endpoints (reference)

SDK calls go to these endpoints with the app's extension token; you normally don't call them directly.

EndpointSDK
POST /api/ext/v1/x/realtime/roomscreate
GET /api/ext/v1/x/realtime/rooms/{id}get
POST /api/ext/v1/x/realtime/join { code }join
POST /api/ext/v1/x/realtime/rooms/{id}/invite { username }invite
PUT /api/ext/v1/x/realtime/rooms/{id}/state { state, ifVersion }setState, update
POST /api/ext/v1/x/realtime/rooms/{id}/leaveleave
/api/ext/v1/x/realtime/hub (SignalR, JSON): Join, Send, SendTo, Leave; events member, message, state, closedconnect, send, sendTo, events

Example: two-player tic-tac-toe

The creator plays X, the joiner O. The board lives in the room's shared state; moves are written with update and everyone renders from the realtime.state event. Transient things like emoji go as messages. UI functions such as render, showCode, showMenu and toast are yours.

{
  "id": "your-username.tictactoe",
  "name": "Tic-tac-toe",
  "version": "1.0.0",
  "apiVersion": 1,
  "minAppVersion": "0.3.1",
  "description": { "tr": "Arkadaşınla iki kişilik XOX", "en": "Two-player tic-tac-toe with a friend" },
  "storeCategory": "games",
  "icon": "icon.svg",
  "entry": { "ui": "index.html" },
  "pages": [{ "id": "main", "title": { "tr": "XOX", "en": "Tic-tac-toe" } }],
  "permissions": [
    { "nuvaka.realtime": true, "reason": { "tr": "Arkadaşınla aynı odada oynamak için", "en": "To play with your friend in the same room" } }
  ]
}
// app.js
const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]]
const result = (b) => LINES.map(([a, c, d]) => (b[a] && b[a] === b[c] && b[a] === b[d] ? b[a] : null)).find(Boolean)
  ?? (b.every(Boolean) ? 'draw' : null)
const fresh = () => ({ board: Array(9).fill(null), turn: 'X', result: null })

let roomId = null
let mine = null   // 'X' | 'O'

function enter(room) {
  roomId = room.roomId
  mine = room.isCreator ? 'X' : 'O'
  render(room.state)                 // null means "waiting for an opponent"
}

// New game: create a room, show the code, write an empty board (a new room is at stateVersion 0)
async function host() {
  const room = await nuvaka.realtime.create({ maxMembers: 2, meta: { game: 'tictactoe' } })
  enter(room)
  showCode(room.code)
  await nuvaka.realtime.setState(room.roomId, fresh(), { ifVersion: room.stateVersion })
}

async function joinByCode(code) {
  try { enter(await nuvaka.realtime.join(code)) }
  catch (e) { toast(e.code === 'room_full' ? 'The room is full' : 'Invalid code or the room has closed') }
}

// Move: write only if it's my turn and the square is empty. If the opponent got in first, update retries with the fresh state.
async function play(i) {
  await nuvaka.realtime.update(roomId, (s) => {
    if (!s || s.result || s.turn !== mine || s.board[i]) return undefined
    const board = s.board.slice()
    board[i] = mine
    return { board, turn: mine === 'X' ? 'O' : 'X', result: result(board) }
  })
}
const rematch = () => nuvaka.realtime.update(roomId, (s) => (s?.result ? fresh() : undefined))
const sendEmoji = (v) => nuvaka.realtime.send(roomId, { t: 'emoji', v })
const inviteFriend = (username) => nuvaka.realtime.invite(roomId, username)

nuvaka.realtime.onState(({ roomId: id, state }) => { if (id === roomId) render(state) })
nuvaka.realtime.onMessage(({ roomId: id, from, data }) => {
  if (id === roomId && data?.t === 'emoji') toast(`${from.displayName ?? from.username}: ${data.v}`)
})
nuvaka.realtime.onMember(({ roomId: id, type, username }) => {
  if (id === roomId && type === 'left') toast(`${username} left`)
})
nuvaka.realtime.onClosed(({ roomId: id }) => { if (id === roomId) { roomId = null; showMenu('The room closed') } })
nuvaka.realtime.onInvite(async ({ roomId: id }) => enter(await nuvaka.realtime.connect(id)))

nuvaka.ready.then(async () => {
  const invited = nuvaka.realtime.invitedRoom()     // opened from a notification: go straight in
  if (!invited) return showMenu()
  try { enter(await nuvaka.realtime.connect(invited)) }
  catch (e) { showMenu(e.code === 'room_not_found' ? 'The room has closed' : e.code) }
})

Since clients make the decisions, this setup suits games between friends. For games that must be cheat-proof, leave the decisions to an extension server.

Testing: the mock nuvaka

@nuvaka/extension-sdk/testing simulates rooms in memory: join codes, the member limit, 5 rooms, invites to friends only, MVCC, size limits and the send rate. You play the other side with m.peer.

ToolDescription
realtime: { username, displayName, friends, friendsWithoutApp, invitedRoom }option: this user (default ben), friends who can be invited, friends without the app (not_installed), as if opened from an invite link
m.peer.createRoom(username, opts?)a room someone else created: { roomId, code }
m.peer.join / send / setState / offline / leaveanother member joins, sends a message, writes the state, drops their connection, leaves
m.peer.invite(roomId, { open? })as if this user was invited; open: true sends realtime.invite to the open page
m.peer.close(roomId), m.peer.room(roomId)the room closes / the room's current view
m.log.realtime, m.log.invitesmessages sent (to: null = everyone) and invites
import { it, expect, afterEach } from 'vitest'
import { installMockNuvaka, uninstallMockNuvaka } from '@nuvaka/extension-sdk/testing'
import { VersionConflictError } from '@nuvaka/extension-sdk'

afterEach(() => uninstallMockNuvaka())

it('opponent move, version conflict and invite', async () => {
  const m = installMockNuvaka({ grants: ['nuvaka.realtime'], realtime: { username: 'ayse', friends: ['veli'] } })
  const room = await nuvaka.realtime.create({ maxMembers: 2 })
  expect(room.code).toMatch(/^[A-Z0-9]{6}$/)
  await nuvaka.realtime.setState(room.roomId, { board: Array(9).fill(null), turn: 'X', result: null }, { ifVersion: 0 })

  const by = []
  nuvaka.realtime.onState((e) => by.push(e.by))
  await m.peer.join(room.roomId, 'veli')
  const v = await m.peer.setState(room.roomId, 'veli', { board: ['O', null, null, null, null, null, null, null, null], turn: 'X', result: null })
  expect(v).toBe(2)
  expect(by).toEqual(['veli'])

  // the version I read (1) is stale
  await expect(nuvaka.realtime.setState(room.roomId, {}, { ifVersion: 1 })).rejects.toBeInstanceOf(VersionConflictError)

  await expect(nuvaka.realtime.invite(room.roomId, 'ali')).rejects.toMatchObject({ code: 'not_friend' })
  await nuvaka.realtime.send(room.roomId, { t: 'emoji', v: '👍' })
  expect(m.log.realtime[0]).toMatchObject({ to: null, data: { t: 'emoji', v: '👍' } })
})

Privacy

  • People in a room only see each other's username and display name; email and ids aren't shared.
  • Message contents are neither stored nor logged. The shared state is kept in memory only while the room is open.
  • Show users clearly what is shared in a room; if you request nuvaka.realtime together with read permissions, write a clear reason.

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