Chapters Host mode and engine mode

Chapter 8 of 13

Host mode and engine mode

One reducer, two places to run it: the host's browser for free, or your engine when nobody may cheat. Also Game Settings, timers and signatures.

On this page

Every game runs in one of two authority modes, and a Play Option chooses by whether it declares an engine. There is no other switch, so the two can never disagree.

Aspect Host mode (no engine, the default) Engine mode (engine.url)
Who runs the rules The browser of the room host: the player who opened the room Your server, called by CamTable’s room
Other players can cheat No: they only send intents No
The host can cheat Yes: the host’s page decides No
Hidden information The host sees all of it; other players see only their view Hidden from everyone
Randomness The host’s (rng is reproducible, not fair) Yours: the host cannot influence it
Your cost Nothing: static files are free and unlimited on Cloudflare One engine call per move or timer (on Cloudflare’s free plan, 100,000 Worker invocations a day)
What you deploy Static files The pages and an engine (the template: one Worker for both)
Consent screen says “Rules run in the browser of the player who opened the room (they can see hidden information)” “Rules run on <your engine origin>”

Neither mode lets a client’s payload become state. The lowest level of trust on the platform is “the host decides”, never “anyone decides”. Pick host mode for casual games between friends; pick engine mode when players must not have to trust the host: a hidden hand, a secret role, a game with stakes.

One reducer

You write the rules once, as a pure reducer with the same contract in both modes, so moving from one mode to the other is a configuration change and the rules do not move. The reducer is a GameReducer from @camtable/sdk: an apply function, and optionally a timeout function.

import type { GameReducer } from "@camtable/sdk";

export const tapRace: GameReducer = {
  apply({ state, action, playerId, players }) {
    if (action.actionType !== "TAP") return { rejected: { code: "UNKNOWN_ACTION", message: "No such action" } };
    /* ... compute the next state ... */
    return { state: next };
  },
  timeout({ state, timerId }) { /* ... */ return { state }; },   // optional: only when you ask for timers
};

apply receives one request:

{
  "protocol": "1",
  "roomId": "...",
  "addonVersion": "1.0.0",
  "state": { ... },                 // what you returned last time; {} before the first action
  "action": { "actionType": "TAP", "payload": ... },
  "playerId": "mp_3f9a",            // the acting player (a Match Player Id)
  "players": ["mp_3f9a", "mp_81c2"], // everyone at the table now (Match Player Ids; spectators and players who left are not listed)
  "settings": { "target": 10 },     // the Match Settings, when the Play Option declares Game Settings
  "test": true                      // only from a Test Room (see "Test matches" below)
}

and timeout receives this one, when a timer you asked for fires (it carries no acting player: nobody did anything, time passed):

{
  "protocol": "1",
  "roomId": "...",
  "state": { ... },                 // the room's current state
  "timerId": "turn",                // the id you gave the timer that fired
  "settings": { "target": 10 },     // the Match Settings, as on `apply`
  "test": true                      // only from a Test Room, as on `apply`
}

Both answer with the same reply shape:

{
  "state": { ... },                     // required: the next state
  "views": { "mp_3f9a": {...}, ... },   // optional: each player's own view
  "timers": [{ "id": "turn", "at": 1767225600000 }],   // optional
  "gameOver": { "winners": ["mp_3f9a"] }               // optional
}
  • views mask hidden information by construction. When you return views, each player receives only their own view, in both snapshots and updates. Without views the full state is broadcast: you chose to hide nothing. In engine mode the full state never leaves the room; in host mode the host’s page always holds it (see “Host mode” below).
  • timers ask the room to call you back. Each is an id (up to 100 characters) and the time it should fire (at, in milliseconds since the Unix epoch). The room keeps them in its own alarm, so they survive restarts and cost you nothing while they wait. When one fires the room asks your timeout and applies the answer exactly as it applies an apply answer, with the same views masking.
    • Every answer replaces the whole set. The timers in an answer are the only ones the room keeps: return the ones you still want every time, and return none to clear them. A turn clock is “return the new deadline every time the turn changes”; a timer an earlier action replaced is never called. Requesting the same id twice keeps the last time.
    • At most 16 timers per answer. A time in the past or the present is held to one second from now, so a timer can never make the room spin.
    • Only a match in progress has timers. In a room that has not started, or whose match has ended, the room drops every timer instead of firing it.
    • Failures are retried, then given up. If your rules cannot answer a timeout (an engine that times out or answers a non-2xx status or an invalid answer; a host page that does not commit), the state stays as it was and the room tries the same timer again after 10 seconds, giving up after three failed attempts. If you answer with rejected, the timer is spent and dropped. Either way nobody is told: there is no acting player, so the room only logs it.
  • gameOver ends the match; winners are Match Player Ids.
  • Refusing an action. Rules that understood the request and refuse the action (out of turn, an occupied cell) answer { "rejected": { "code": "NOT_YOUR_TURN", "message": "Wait for your turn" } } (code up to 100 characters, message up to 500). The room keeps its state and the acting player receives action_rejected with your code and message. This is the rules working. CamTable shows the messages of common codes in the player’s language and keeps your raw text for you to read.
  • Failing. A reply that breaks the schema, or a state over 64 KB, is a failure, not a refusal: the room keeps its last committed state and only the acting player receives an ADDON_NOT_RESPONDING error. The room carries on.

Actions are applied strictly one at a time per room, so every call sees the state the previous action committed. The room never relays a raw client action payload to other players (it might hold a secret your views keep from them); they learn what your next state and views tell them.

Keep the reducer pure. The same request must give the same reply in the host’s browser, on your server and on replay, so it must not read the clock, the network or Math.random. For randomness use the SDK’s seeded generator and keep the seed in the state:

import { rng } from "@camtable/sdk";        // also exported from "@camtable/sdk/addon"

const dice = rng(`${state.seed}:${state.moves}`);   // advance `moves` so two rolls differ
const roll = dice.int(1, 6);                          // also next(), pick(items), shuffle(items)

rng(seed) is deterministic, and its sequence is part of the package’s contract. It makes randomness reproducible and easy, not fair: in host mode the seed sits in the host’s page, so the host can read it. A game that needs randomness the host cannot see must run in engine mode.

Game state conventions

The room keeps your state without reading it, except for a few keys. Following them is optional; each one gives you a behaviour, and ignoring it costs nothing else. Their readers are exported by @camtable/sdk and @camtable/sdk/addon, so your own tooling can apply the same rule the room does.

Convention What the room does Reader
currentTurnPlayerId, currentPsychicId, activePlayerId or turnPlayerId in your state, read in that order (TURN_OWNER_KEYS) sets the room’s room.currentTurnPlayerId to the first one that holds a player id; a state that names no one leaves the turn where it was turnOwnerNamedBy
whispers: { groups: [[...], ...] } in your state, lists of two to six Match Player Ids starts a Whisper for each group’s connected players and ends the ones no longer named; a declaration it cannot apply (a player twice, a group too small or too large) applies nothing and keeps the groups it had; ignored for a Play Option that does not declare whisper (the room’s own)
whispers: { visibility: "public" } in your state tells every player of every Whisper; absent or "secret", each Whisper is told to its own players only (the room’s own)
whispers: { requests: "open" } in your state lets pages make and answer Whisper Requests; anything else closes them, withdraws every waiting request and ends every Whisper made by request; ignored for a Play Option that does not declare whisper whisperRequestsNamedBy
callCover: { whispering, others, shows } in your state, each "open" or "covered", the picture "dark", "blur" or "still" while the match runs, covers the camera of each player in a Whisper (whispering) or in none (others) with that picture, telling each player only their own, and carries a covered Whisper’s cameras between its players’ slots; a value of another shape leaves the cover before it in place; ignored for a Play Option that does not declare call.cover (the room’s own)
a score table in your state, numbers keyed by Match Player Id at game over, saves the seated players’ scores in the match history scoreTableOf

The room reads these keys from your full state, before views mask it, so a view does not need to repeat them. Read whose turn it is from room.currentTurnPlayerId, never from the state: the room has already normalised it, and your view may not carry the key at all.

At game over the room also records the match’s outcome, the winner details and the scores above, in room.gameData.verifiedOutcome, and sends it to every player until the next match starts. Read it with verifiedOutcomeOf(room); its shape is verifiedOutcomeSchema.

Game Settings and the Lobby

A Play Option may declare Game Settings: the choices its room host makes before a match, such as how many werewolves are in play or how many taps win a race. You write only the declaration; CamTable draws the form. The values the host chose for one match are its Match Settings.

import type { GameSettings } from "@camtable/sdk/addon";

export const WEREWOLF_SETTINGS: GameSettings = {
  groups: [{ id: "roles", label: { en: "Roles", tr: "Roller" } }],
  fields: [
    { kind: "number", id: "wolves", group: "roles", label: { en: "Werewolves", tr: "Kurt sayısı" },
      min: 1, max: 4, step: 1, default: 1 },
    { kind: "toggle", id: "seer", group: "roles", label: { en: "Seer in play" }, default: true },
    { kind: "choice", id: "reveal", label: { en: "Reveal roles at death" },
      description: { en: "What the village learns when a player dies." },
      options: [{ value: "never", label: { en: "Never" } }, { value: "always", label: { en: "Always" } }],
      default: "always" },
    { kind: "multi-choice", id: "extras", label: { en: "Extra roles" },
      options: [{ value: "hunter", label: { en: "Hunter" } }, { value: "witch", label: { en: "Witch" } }],
      default: [] },
  ],
};

Put the declaration in the Play Option’s settings ({ groups?, fields }), and keep it next to your rules so the reducer reads the same object.

  • Four kinds of field. Every field has an id, a label and a default, and may have a description and a group.
    • number: min, max and step (1 unless given). The value is min plus a whole number of steps, no more than max.
    • toggle: a yes-or-no switch. The value is true or false.
    • choice: one pick from options, each { value, label }. The value is one option’s value.
    • multi-choice: any number of picks from options. The value is an array of option values, with no repeats.
  • Ids, labels and groups. An id (of a field or a group) is a letter followed by up to 63 letters, digits, _ or -. An option value is 1 to 64 characters. A label or description maps language tags to text ({ "en": "...", "tr": "..." }, at least one language). A field names its group by id, and the form shows each group’s fields under the group’s label. There are no conditional fields and no formulas.
  • At most 24 fields per Play Option (GAME_SETTINGS_MAX_FIELDS).
  • Check it before you serve it. validateManifest(manifest, { plays }) from @camtable/sdk/addon checks your Play Options as CamTable does, Game Settings included, and lists every problem as plays.N.settings...: message. It refuses more than 24 fields, an id used twice, a group that is not declared, min above max, an empty or repeated option list, and a default that its own field would refuse. The scaffold’s buildSite runs this check on every build.
  • Pinned with the room. The Game Settings are part of the room’s Addon Reference, so every match of a room, rematches included, uses the declaration the room was created with. A new declaration reaches new rooms only.

The Lobby. A room whose Play Option declares Game Settings waits in its Lobby (lifecycle lobby) while players join, instead of starting at once. The host chooses the Match Settings in the Dock’s Oda tab and starts the match there, once the Play Option’s minPlayers are seated (connected, spectators aside). Every other player sees the Match Settings there, read-only. A game without Game Settings starts at once, as before.

Shape is CamTable’s, meaning is yours. The room holds the Match Settings on its state (room.matchSettings). A new room starts with every field at its default; only the host’s updates in the Lobby change them, and the start fixes them for the match. The room checks every update against the pinned declaration (with checkMatchSettings, the same check the SDK exports) and refuses one that does not fit it. Whether the choices make a playable match (enough players for the roles) is your rules’ call. Refuse START_GAME the way you refuse any action:

const { wolves } = fillMatchSettingsDefaults(WEREWOLF_SETTINGS, settings);
if (action.actionType === "START_GAME" && Number(wolves) * 2 >= players.length) {
  return { rejected: { code: "TOO_MANY_WOLVES", message: "Pick fewer werewolves than half the table" } };
}

The host sees your message in the Lobby, and the room stays there with the Match Settings as they were.

Your rules receive them. Every apply and timeout request carries the Match Settings in its own settings field, in both modes (settings in the requests above). The START_GAME payload stays your own. A request may come without settings, for example from a room an older extension started, which dropped the declaration. Such a match is played on the defaults, so a game must be playable on its defaults. The SDK fills them in for you:

  • createEngineHandler({ ...rules, engineUrl, settings: WEREWOLF_SETTINGS }) hands your reducer every request with its Match Settings complete: each field the request leaves out holds its default. Without settings, the request’s settings reach you as sent.
  • createHostAuthority(rules, { sdk, settings: WEREWOLF_SETTINGS }) does the same in host mode. Without the option it uses the declaration the room pinned, and a game that declares none gets no settings.
  • In your reducer, fillMatchSettingsDefaults(WEREWOLF_SETTINGS, settings) (from @camtable/sdk/addon) returns complete Match Settings from whatever arrived: the values the declaration allows, and the default for every other field.

Do not send START_GAME yourself. In a room with Game Settings only the host’s start in the Lobby starts a match: the room then sends START_GAME to your rules itself, as the host’s action. A game page’s own START_GAME is refused with MATCH_STARTS_FROM_LOBBY in any lifecycle, and its RESET_GAME is refused the same way while the room is in the Lobby. Draw a “waiting for the host” state in the Lobby, not a start button.

Back to the Lobby. A match of a room with Game Settings ends in the Lobby, so the host can choose again before a rematch. These all take the room back to the Lobby (lifecycle lobby): a reply with gameOver, from apply or from timeout; the host’s accepted FINISH_MATCH; and the host’s RESET_GAME during a match or after one has ended. That RESET_GAME is not sent to your rules. The room keeps the Match Settings and the last room.gameData.gameState, and marks room.gameData.betweenMatches: true until the next match starts. Its timers end. So a game page sends RESET_GAME only while the match is in_progress. When the host starts the next match, your START_GAME receives the previous match’s state and the new settings. Carrying a score over is your call.

Reading them in the page. The Match Settings are public: every player’s page can read them. sdk.getMatchSettings() returns the room’s Match Settings as the room keeps them, or null for a game without Game Settings (or before the page holds a room). The SDK fills nothing in: the room starts them at your declared defaults when it is created and keeps every field after each update, so each field you declared has a value. sdk.onMatchSettingsChange(callback) calls back with the new values each time the host changes them in the Lobby, and for no other change to the room. Custom tabs (createCustomTabClient()) have both calls too. Match Settings are not secret, are not shown on the Join Consent screen, and are not kept in match history: they end with the room.

The room’s refusals in the Lobby use these codes, the values of LOBBY_REFUSAL_CODES (type LobbyRefusalCode) from @camtable/sdk/addon:

Code When
MATCH_SETTINGS_HOST_ONLY a player who is not the host changes the Match Settings
MATCH_SETTINGS_INVALID an update the pinned Game Settings do not allow (an unknown field, a value out of range or off its step, an option not offered)
MATCH_SETTINGS_LOCKED an update once the match has started
NO_GAME_SETTINGS an update in a room whose Play Option declares none
MATCH_START_HOST_ONLY a start from a player who is not the host
NOT_ENOUGH_PLAYERS a start with fewer than minPlayers seated
MATCH_NOT_IN_LOBBY a start once the room has left its Lobby
MATCH_STARTS_FROM_LOBBY a game page’s START_GAME, or its RESET_GAME in the Lobby

The first four reach the host’s extension only. The start refusals, and your own refusal of START_GAME, reach the host as an action_rejected for START_GAME. A game page sees MATCH_STARTS_FROM_LOBBY as the refusal of its own action.

The Inspector (section 11: Testing locally) plays all of this without Discord: its Test Room has a real Lobby.

Host mode

The rules run in the room host’s game page. Every player downloads the same static page, but only the host’s copy decides. In your page call createHostAuthority once:

import { CamTableSDK, createHostAuthority } from "@camtable/sdk";

const sdk = new CamTableSDK();
createHostAuthority(tapRace, { sdk, onError: (error) => showMessage(error.message) });

What happens to a move:

  1. A player’s page sends an intent (sdk.sendAction). The room hands it, with the full current state, to the host’s connection only, one at a time, through its ordinary action queue. The host’s own moves take the same path, with no shortcut.
  2. The host’s page runs your apply and answers with a commit, the reply. The room accepts a commit only from the current host and only for the intent it is waiting on, checks it against the same schemas and limits as an engine’s answer (64 KB of state), stores it and sends each player their view, exactly as in engine mode.
  3. The host has 3 seconds. If nothing valid comes back, the intent is rejected to its sender (ADDON_NOT_RESPONDING) and the state is unchanged: a stuck host never corrupts the match.

createHostAuthority does the host’s half for you. In a page that is not the host’s it ignores intents and commits nothing, so calling it unconditionally is right. It answers each intent exactly once, in order, and turns a reducer that throws, or returns something that is not a reply, into a refusal (REDUCER_ERROR, INVALID_REPLY) instead of a silent hang; one that has not answered within 2.5 seconds is refused too (REDUCER_TIMEOUT), so the next intent is not held up. A timeout intent for a reducer with no timeout is refused (NO_TIMEOUT_HANDLER). Pass the sdk your page calls init on: the page is the host only once init has named its local player.

Timers work as in engine mode: the timers your reply requests are kept by the room, and when one fires the room sends the host a timeout intent, which your timeout answers like any commit.

When the host leaves. The host’s page keeps authority for the whole match: your game never runs on another player’s page, and its full state is never handed to anyone else. If the host’s connection drops during a match, the match pauses for the 15-minute Host Grace Period, in host mode and engine mode alike: the room’s lifecycle is paused, every action is rejected with HOST_AWAY, and pending timers are held. If the host is back in time, the match resumes where it stopped (lifecycle in_progress again), the host’s page receives the full state, and each timer fires with the time it had left when the pause began. If not, the match ends with no outcome (lifecycle finished), and the host can start a new one on return. A host who leaves the room on purpose closes it for everyone.

What host mode does not give you, and the consent screen says so:

  • The host can cheat. Their page decides, and they can edit it.
  • The host sees hidden information. Their page holds the full state, whatever you draw. Other players receive only their views. A game that must hide information from the host must use engine mode.
  • Randomness is the host’s. See rng above.

Engine mode

Declare an engine when players must not have to trust the host. The engine is your reducer behind signed HTTP: the room sends it the current state and one action, and it answers with the next state. The room is the only thing that stores state; your engine needs no database and scales to zero. The engine is the only writer of game state, and a client’s payload is only ever an argument to your rules, never state.

POST {engine.url}/apply     the `apply` request above, as the JSON body
POST {engine.url}/timeout   the `timeout` request above

Both are answered with the reply shape above. engine.url is a base such as https://my-game.example/engine/tap-race, and the room appends /apply or /timeout. In the template the engine lives in the same Worker as the pages, so its URL is at the addon’s own origin.

A refusal is told apart from a failure: an engine that understood the request and refuses the action answers 2xx with rejected. A timeout (3 seconds), a non-2xx status, invalid JSON, a response that breaks the schema or a state over the limit is an engine failure: the room keeps its last committed state, only the acting player receives an ADDON_NOT_RESPONDING error, and the room carries on.

Writing an engine

import { createEngineHandler } from "@camtable/sdk/addon";
import { tapRace } from "../game";

export default {
  fetch: createEngineHandler({
    ...tapRace,   // the same reducer object that host mode runs in the page
    engineUrl: "https://my-game.example/engine/tap-race",   // your Play Option's engine.url
  }),
};

createEngineHandler is a standard fetch handler, so it is the default export of a Cloudflare Worker and works anywhere that speaks Request/Response (a Node server: Hosting outside Cloudflare). It takes the reducer, engineUrl (your engine’s public URL, as your Play Option declares it) and, optionally, keys (where CamTable’s public keys come from). Give engineUrl from your configuration, not from the request: the template builds it from the ADDON_ORIGIN Worker variable that pnpm dev and pnpm run deploy set to the origin the manifest was built for. It answers POST <any path>/apply and POST <any path>/timeout (so it can live under a path such as /engine/tap-race; /timeout is answered only when the reducer has a timeout, otherwise with 404), and it:

  • refuses a body over 256 KB (ENGINE_MAX_REQUEST_BYTES) with 413 before reading it, by its declared Content-Length or as soon as it streams past the limit;
  • refuses every request CamTable did not sign for your engine with 401, before your code runs;
  • refuses a body that is not JSON or does not match the contract with 400;
  • answers 503 when CamTable’s keys cannot be read, rather than trust the request.

Test matches

A request from a Test Room, the room the Inspector opens for an Addon’s author to try a game, carries "test": true; a request from any other room carries no test field. The field is inside the signed body, so nobody can add or remove it without the signature failing. An engine that keeps records (statistics, ratings, a leaderboard) leaves such a match out. createEngineHandler hands your reducer test as a boolean on every request, false when the field is absent:

createEngineHandler({
  ...tapRace,
  engineUrl,
  async apply(request) {
    const reply = await tapRace.apply(request);
    if (!request.test && "state" in reply && reply.gameOver) await recordWin(reply.gameOver.winners);
    return reply;
  },
});

In a Test Room an engine failure also says what went wrong: the ADDON_NOT_RESPONDING error ends with the cause in parentheses, such as (engine answered 500) or (engine did not answer within 3000 ms), and the Inspector’s event log shows it under the seat whose action it was, beside your refusals (action_rejected, with your code and message) and every bridge call each seat’s page made. Any other room keeps the cause in CamTable’s own log.

The template makes it one Worker. dist/ (the game page, the manifest and the JSON routes) is served as Workers Static Assets without running any script, and only /engine/* reaches your code (assets.run_worker_first: ["/engine/*"] in wrangler.json, with not_found_handling: "404-page" and html_handling: "none"). One wrangler deploy ships the pages and the engine together, at one origin, so there is no fixed order to deploy in.

Signatures

Every request is signed by CamTable with an Ed25519 key. CamTable publishes its public keys at https://api.camtable.app/.well-known/camtable-engine-keys.json:

{ "keys": [{ "kid": "2026-08", "alg": "Ed25519", "publicKey": "<base64url raw key>" }] }

There is no registration and no shared secret: a request is genuine when one of these public keys signed it. The request carries three headers:

Header Value
x-camtable-signature The base64url signature.
x-camtable-timestamp When it was signed, in milliseconds.
x-camtable-key-id The kid of the key that signed it.

The signed bytes are, in UTF-8, camtable-engine/2\n<origin>\n<path>\n<timestamp>.<body>: the signing version, the origin of the URL CamTable called (https://my-game.example), its path (/engine/tap-race/apply), the timestamp and the exact body. Binding the origin means a request CamTable signed for someone else’s engine (anyone can register a room that calls their own server) fails at yours. Binding the path means a body signed for /apply fails at /timeout or at another game’s engine. The query is not signed. The origin is checked against the engineUrl you configure, not against the Host the request arrived with, so an engine behind a proxy or tunnel that rewrites the Host still verifies (an engine behind a path-rewriting proxy must verify with the path CamTable called). Requests older or newer than five minutes are refused. Version 1 (camtable-engine/1, no origin line) is no longer accepted.

createEngineHandler does all of that. If you need your own routing, its parts are exported:

import { createEngineKeySource, readEngineRequestBody, verifyCamTableRequest } from "@camtable/sdk/addon";

const keys = createEngineKeySource();  // fetches the published keys, keeps them five minutes,
                                       // and looks once more when it sees a key it does not know
const body = await readEngineRequestBody(request);  // undefined once it is over 256 KB
if (body === undefined) return new Response("too large", { status: 413 });
const verification = await verifyCamTableRequest(
  { body, url: request.url, headers: request.headers },
  keys,
  "https://my-game.example/engine/tap-race",  // your engine's public URL, from your configuration
);
if (!verification.ok) return new Response("unauthorized", { status: 401 });

They wrap verifyEngineRequest, also on @camtable/sdk/addon, which takes the keys directly. To try an engine against a local CamTable server, point the key source at its keys: createEngineKeySource({ url: "http://localhost:8787/.well-known/camtable-engine-keys.json" }).

Engine URLs

The engine URL must be https (plain http only for localhost). Redirects are not followed. CamTable’s server calls it, so it must be reachable from the internet. A localhost engine is only reachable by a CamTable server running on the same machine (the deployed one cannot see it), and the store extension will not open a plain-http game page at all: to try a work in progress in the Inspector or in a real call, put it at an https address (pnpm dev’s temporary tunnel, or a deploy).

Engine limits

Limit Value
Response time 3 seconds, then the action fails.
Engine state, serialized 64 KB.
Views, serialized, all players together 64 KB.
Request body (what an engine reads) 256 KB.
Response body 1 MB.
rejected code / message 100 / 500 characters.
Actions Applied one at a time per room.
Timers per answer 16. A retried /timeout waits 10 seconds and is given up after 3 failures.

Waiting on your engine does not count against CamTable’s CPU budget, but request volume is bounded by the platform’s plan, so do not design a game that sends an action per animation frame. Host mode has the same 64 KB state and views limits, and a 3-second deadline for the host’s commit.