← Documentation

Networking and game servers

One optional, on-demand backend per game. Your code decides whether it is a persistent world, several rooms, a matchmaking queue, a turn-based game, or an occasional HTTP API. Rooms are application data, not separately provisioned servers. Inkwell supplies generic messages and actions, not a simulation or shooter-netcode framework.

Choose the delivery contract

Scroll the table horizontally to compare delivery types.

OperationResult meansSuitable for
sendReliable(name, payload)A Promise for the transport send, not acknowledgement that game logic committed a change.Ordered events where transport loss is unacceptable while connected.
sendUnreliable(name, payload)A boolean indicating local admission, not receipt. Data may be lost, reordered, or dropped.Replaceable positions, cursors and other transient updates.
action(name, input, options?)A Promise for the handler's returned output or an action error; request and response use reliable delivery.Joining a room, making a move, requesting an authoritative result.
backend.request(path, init?)A standard Response; inspect response.ok. No persistent game connection is needed.Account saves, world loading, configuration and occasional API work.

The host authenticates the player, starts the game server if needed, and supplies its endpoint and certificate fingerprints. With SDK 0.0.6 or newer, pooled game servers accept WebTransport directly: reliable messages use a QUIC stream and unreliable messages use QUIC datagrams. Gameplay does not pass through a shared application gateway. Certificates rotate automatically; creators do not manage them.

Inspect connection.capabilities.unreliable before relying on datagram semantics. Direct connections report nativeand require WebTransport support and a network that permits UDP. Legacy servers awaiting migration retain a TCP gateway hop and reportemulated; they can offer a WebSocket fallback. Certificate-pinned direct connections never downgrade to that fallback. Prediction, reconciliation and recovery of important game state remain part of the game's simulation.

Lifecycle, hosting and capacity

The supported hosted path bundles your JavaScript/TypeScript backend and dependencies into the Node.js runtime on a Fly Machine, isolated using a Firecracker microVM. This is not an arbitrary Docker-image upload API. An inactive game wakes on demand. Startup includes restoring durable world state; allow for a cold start in your UI.

start(context) initializes the process; connect(connection, context) and disconnect(connection, context) manage transport peers; messages receives named events; actions returns named request results; fetch handles HTTP; shutdown is best-effort cleanup. Async handlers can overlap. Use transactions or your own serialization for shared world mutations.

The platform keeps the backend alive while players remain connected to the game, including idle players and games that only occasionally call HTTP. After player/connection leases disappear, the platform applies a five-minute idle grace period before stopping. Browser suspension and network loss can expire leases; this is not an unlimited background-job service.

Configure backend.maxConnections and CPU/memory resources in inkwell.config.js. A connection limit is not a unique-account limit or a throughput guarantee. Several sockets may belong to one player; an HTTP-only game has a different load profile. There is no automatic room-per-Machine allocation or multi-server sharding in this API.

Use a Map or another in-memory structure for room membership and matchmaking. For a small persistent world, load records in start, save during ordinary operations, and reload after wake. One D1 database and game-scoped R2 namespace are shared across worlds; world IDs are your application keys. In-memory state and local files are not durable saves. Do not rely on shutdown running after a crash.

Deployment and resource configuration · All lifecycle/context types · D1 and R2 contracts

Complete typed client and server

This public-lobby example uses a reliable join action, replaceable cursor updates, and a D1 visit record. Both modules are compiler-checked against the reference SDK. For private rooms, authorize membership and send only to connections in that room: a broadcast without filtering reaches the whole game process.

Browser

import { Inkwell, BackendActionError } from '@silicon-jungle/inkwell-sdk'

type Protocol = {
  clientEvents: { 'cursor.move': { x: number; y: number } }
  serverEvents: { 'cursor.changed': { playerId: string; x: number; y: number } }
  actions: { 'world.join': { input: { worldId: string }; output: { joined: string } } }
}

async function startGame() {
  Inkwell.loading.progress(null)
  const player = await Inkwell.player.get()
  const connection = await Inkwell.backend.connect<Protocol>()
  const unsubscribe = connection.on('cursor.changed', update => {
    // Validate received data as needed, then update your game renderer.
    console.log(update.playerId, update.x, update.y)
  })
  try {
    const result = await connection.action('world.join', { worldId: 'lobby' })
    console.log(player.displayName, result.joined)
  } catch (error) {
    unsubscribe()
    connection.close()
    if (error instanceof BackendActionError) console.warn(error.code)
    throw error
  }
  // Inspect capabilities; fallback may be emulated rather than a datagram.
  console.log(connection.transportKind, connection.capabilities.unreliable)
  connection.sendUnreliable('cursor.move', { x: 10, y: 20 })
  Inkwell.ready()
  return () => { unsubscribe(); connection.close() }
}

// Call the returned cleanup function when leaving your scene.
startGame().then(cleanup => {
  window.addEventListener('pagehide', cleanup, { once: true })
}).catch(() => Inkwell.loading.fail('Could not join the game. Please try again.'))

Server

import { defineBackend, BackendActionFailure } from '@silicon-jungle/inkwell-sdk/server'

type Protocol = {
  clientEvents: { 'cursor.move': { x: number; y: number } }
  serverEvents: { 'cursor.changed': { playerId: string; x: number; y: number } }
  actions: { 'world.join': { input: { worldId: string }; output: { joined: string } } }
}

// A single public lobby in one process. This is not a match-per-server API.
const joined = new Set<string>()
export default defineBackend<Protocol>({
  async start({ database }) {
    await database.query(
      'CREATE TABLE IF NOT EXISTS visits (player_id TEXT PRIMARY KEY, last_seen TEXT NOT NULL)'
    )
  },
  actions: {
    async 'world.join'(input, connection, { database }) {
      // TS types do not validate incoming JSON. Authorize private worlds here.
      if (!input || input.worldId !== 'lobby')
        throw new BackendActionFailure('invalid_world', 'Choose the public lobby.')
      await database.query(
        'INSERT INTO visits (player_id, last_seen) VALUES (?, ?) ON CONFLICT(player_id) DO UPDATE SET last_seen = excluded.last_seen',
        [connection.identity.playerId, new Date().toISOString()]
      )
      joined.add(connection.id)
      return { joined: 'lobby' }
    },
  },
  messages: {
    'cursor.move'(input, connection, context) {
      if (!joined.has(connection.id) || !input ||
          !Number.isFinite(input.x) || !Number.isFinite(input.y) ||
          Math.abs(input.x) > 10000 || Math.abs(input.y) > 10000) return
      context.broadcastUnreliable('cursor.changed', {
        playerId: connection.identity.playerId, x: input.x, y: input.y,
      }, { except: connection.id })
    },
  },
  disconnect(connection) { joined.delete(connection.id) },
  async fetch(request, { database }, identity) {
    if (request.method !== 'GET' || new URL(request.url).pathname !== '/visit')
      return new Response('Not found', { status: 404 })
    const result = await database.query(
      'SELECT last_seen FROM visits WHERE player_id = ?', [identity.playerId]
    )
    return Response.json(result.results[0] ?? null)
  },
})

HTTP without a persistent connection

import { Inkwell } from '@silicon-jungle/inkwell-sdk'

async function loadVisit(signal: AbortSignal) {
  const response = await Inkwell.backend.request('/visit', {
    method: 'GET', timeoutMs: 30000, signal,
  })
  if (!response.ok) throw new Error('The visit could not be loaded.')
  return response.json() as Promise<{ last_seen: string } | null>
}
const controller = new AbortController()
window.addEventListener('pagehide', () => controller.abort(), { once: true })
loadVisit(controller.signal).then(console.log).catch(console.error)

Paths are relative to your game backend, not arbitrary URLs. GET/HEAD cannot carry bodies. The bridge supplies the authenticated game identity and strips reserved headers; callers cannot override the platform credential. HTTP requests and responses are bounded, not an unbounded streaming/file-transfer API. Network errors reject; application 4xx/5xx responses still need an explicit response.ok check.

Authority, retries and recovery

connection.identity.playerId is game-scoped and stable for signed-in accounts across sessions/builds. connection.id is a transport connection, not an account. Check isGuest before account-only operations. Never authorize a write from a username or player ID supplied in the message payload.

TypeScript protocols describe inputs but do not validate network data. Validate finite numbers, shapes, bounds and room membership in every handler. Throw BackendActionFailure(code, publicMessage) for intentional public errors; unexpected action exceptions become internal_error. Do not include secrets in public messages.

Actions default to a ten-second wait. Abort or timeout stops the caller waiting; it does not undo server work. New calls receive new transport request IDs. Include your own durable idempotency key for operations that must not happen twice. The server's active-request duplicate guard is not a durable receipt ledger.

The generic backend client does not automatically reconnect, replay inputs, or restore world state. Recreate a connection after failure, reauthenticate through the host, rejoin and request authoritative state. Remove listeners and close old connections when leaving a scene. Do not blindly replay a timed-out purchase or inventory change. Chat has a separate reconnect/history contract.

Compact binary events

SDK 0.0.7 supports opt-in raw byte events alongside JSON. Enable binaryEvents: true in your backend definition and await connection.negotiateBinaryEvents() before choosing your binary game protocol. Older or opted-out servers return false; the default two-second negotiation timeout also returns false. Connection failures reject. Reconnect to negotiate a new session.

Use binaryMessages on the server and onBinaryon the client. Both connections provide sendBinaryReliableand sendBinaryUnreliable. Their delivery contracts match JSON events. Subtract binaryEventOverhead(name) from the transport frame limit before packing: five bytes plus the ASCII name are reserved. Do not JSON-serialize a Uint8Array to send binary data.

Validate your fields and versions. A compact format does not recover missing delta baselines or identifier dictionaries automatically. Acknowledge those dependencies or send independent absolute updates; keep critical state changes on reliable delivery. The action inkwell.binary.negotiate is reserved by the SDK.

import { connectBackend } from '@silicon-jungle/inkwell-sdk/backend'
import { binaryEventOverhead } from '@silicon-jungle/inkwell-sdk/wire'
import { defineBackend } from '@silicon-jungle/inkwell-sdk/server'

export const server = defineBackend({
  binaryEvents: true,
  messages: { echo(payload, connection) { return connection.sendReliable('echo', payload) } },
  binaryMessages: {
    echo(bytes, connection, _context, delivery) {
      if (bytes.byteLength > 512) return // Validate your schema and bounds here.
      if (delivery === 'reliable') return connection.sendBinaryReliable('echo', bytes)
      connection.sendBinaryUnreliable('echo', bytes)
    },
  },
})

export async function connectBinary() {
  const connection = await connectBackend()
  const unsubscribeJson = connection.on('echo', payload => console.log(payload))
  const unsubscribe = connection.onBinary('echo', (bytes, delivery) => {
    console.log(bytes.byteLength, delivery)
  })
  if (await connection.negotiateBinaryEvents()) {
    const budget = connection.capabilities.maxUnreliableFrameBytes - binaryEventOverhead('echo')
    if (budget >= 3) connection.sendBinaryUnreliable('echo', Uint8Array.of(0, 128, 255))
  } else {
    connection.sendReliable('echo', { values: [0, 128, 255] })
  }
  return () => { unsubscribe(); unsubscribeJson(); connection.close() }
}

Wire and request limits

  • JSON-serializable payloads or negotiated binary events. Reliable encoded frames: 64 KiB; unreliable encoded frames: at most 1,200 bytes, possibly lower for the negotiated transport. Limits include the protocol envelope.
  • At most 128 pending client actions and 32 active server actions per connection. Public server errors include unknown_action, duplicate_action and too_many_actions.
  • Connection timeout defaults to 60 seconds per connection stage; it is not a total cold-start SLA. Action timeout defaults to 10 seconds and can be overridden.
  • HTTP request and response bodies: 8 MiB each. HTTP wait defaults to 110 seconds; an explicit timeout must be finite and between 1 and 120,000 milliseconds.
  • Malformed protocol frames close the connection. Oversized or invalid local messages can throw before any send. Unreliable send returning false means the caller must tolerate dropping that update.

Exact browser types and all exported methods · Wire utilities · Platform quotas