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 room | Extension server | |
|---|---|---|
| Code | None; clients make the decisions, Nuvaka only relays | Server code from your package runs on a Nuvaka machine and makes the decisions |
| Lifetime | In memory; closes when idle or after 24 hours at most | Publisher servers are always on; user servers run for at most 12 hours |
| Persistent data | None (the shared state is gone when the room closes) | SQLite per extension (nuvaka.db) |
| Review | Normal rules | Every version with server is reviewed |
| Good for | Small games with friends, a shared board, editing together | 24/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(ornuvaka.servers) without requesting the permission,nuvaka-ext lintand upload reject it. - Only the app itself can use rooms: running as another extension's private copy (a
usesdependency), calls return403 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
| Calls | UI | Background |
|---|---|---|
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
| Option | Rule |
|---|---|
maxMembers | 2–16, default 8 |
joinByCode | whether joining by code is allowed; default true. With false, only invited people can enter. |
meta | small 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:
| Field | Description |
|---|---|
roomId | the room's id |
extensionId | the extension the room belongs to |
maxMembers, meta | as given at creation |
code | 6-character join code (uppercase letters and digits); set only for the creator, null for everyone else |
isCreator | whether you created the room |
members | [{ username, displayName, online }] |
state, stateVersion | shared state and its version; null and 0 in a new room |
createdAt | creation 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_invitenotification in their own language (Turkish/English). Its link has the form/apps?id=<extension>&room=<roomId>: tapping it opens your app withctx.realtimeRoomin the context. - The invitee enters with
connectbefore becoming a member. If your app is already open when the invite is tapped,onInvitefires. - To show the friend list in your own UI you also need the
nuvaka.friendsread permission;invitedoesn'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
})
datacan 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);sendToto someone not in the room givesmember_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 }
})
ifVersionis required: without it you getif_version_required(428). A new room starts at version0; 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.stateevent:{ roomId, stateVersion, state, by }. Render your UI from this event; you don't need to render the write result separately. - The
updatefunction 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 areason(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.
| Event | Shortcut | Payload |
|---|---|---|
realtime.message | onMessage | { roomId, from: { username, displayName }, data, to, ts } |
realtime.member | onMember | { roomId, type: 'joined' | 'offline' | 'left', username, displayName? } |
realtime.state | onState | { roomId, stateVersion, state, by } |
realtime.closed | onClosed | { roomId, reason? } |
realtime.status | onStatus | { connected, reconnecting?, error?: { code, message } } |
realtime.invite | onInvite | { roomId } — an invite link was tapped while the app was open |
Calls
| Call | Returns |
|---|---|
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
| Code | Meaning |
|---|---|
permission_required | no nuvaka.realtime permission |
not_for_copies | (403) an extension running as a private copy can't use rooms |
secure_session | disabled 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_large | message is over 16 KB |
rate_limited | over 20 messages per second (burst 40) |
not_joined, not_connected | not connected to the room / the realtime connection |
member_not_found | the sendTo target isn't in the room |
ui_only | connect/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.
| Endpoint | SDK |
|---|---|
POST /api/ext/v1/x/realtime/rooms | create |
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}/leave | leave |
/api/ext/v1/x/realtime/hub (SignalR, JSON): Join, Send, SendTo, Leave; events member, message, state, closed | connect, 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.
| Tool | Description |
|---|---|
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 / leave | another 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.invites | messages 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.realtimetogether with read permissions, write a clear reason.
Nuvaka Apps API v1 · last updated 2026-09-29