Skip to main content
This page is part of an AI coding agent skill and is written for agents, not humans. For the human-readable Base44 docs, see the developer documentation.

Creating Actors

Actors are Base44’s realtime primitive: stateful server rooms over WebSockets. There is exactly one live instance per room id, and every client connected to that id shares it. The actor is authoritative — clients send inputs/operations, the actor validates them, applies them to its own state, and broadcasts the result. Actors are defined locally in your project and deployed to the Base44 backend, just like backend functions. Actors use direct WebSocket connections to Cloudflare. The SDK handles connection-token minting automatically; actors receive verified connection identity and can use this.client.asServiceRole for validated, room-owned work.

When to Use an Actor

Actor Directory

All actor definitions live in the base44/actors/ folder. An actor is a folder containing an entry.ts file:

How to Create an Actor

  1. Create a directory in base44/actors/ named after the actor (PascalCase)
  2. Create entry.ts in that directory and default-export a class extending Actor
  3. Deploy it with npx base44 actors deploy

Actor Discovery and Naming

The CLI discovers actors from entry.ts (or entry.js) files, and the folder is the actor’s identity — the folder name becomes the actor name. The name becomes the Durable Object class and the WebSocket connect handler, so it must be a plain JavaScript identifier: Rules:
  • Must match [A-Za-z_][A-Za-z0-9_]*, max 128 characters — no -, ., /, or :
  • Must not be a JavaScript reserved word (class, default, new, static, …)
  • Must be a single folder levelbase44/actors/games/Arena/entry.ts is not a valid actor (unlike functions, actors cannot be nested)
  • Must not collide with a backend function name
  • Use PascalCase by convention (it reads as a class, and it is one)
All *.js, *.ts, *.json, and *.jsonc files under the actor folder are included when deploying. Never name a helper entry.ts. Every entry.ts/entry.js under base44/actors/ is treated as an actor entry, at any depth — so base44/actors/BoardRoom/lib/entry.ts resolves to the nested name BoardRoom/lib and fails the “actors cannot be nested” check. The CLI’s error names both causes (a genuinely nested actor, or a misnamed helper); rename the helper to anything else.

Entry Point File

The entry file default-exports a class extending Actor, imported from base44:runtime/actors — the only import that resolves the base class:
The class name is cosmetic — the deploy re-exports your default export under the folder name. export default class extends Actor { … } works too.

Lifecycle Handlers

Never override onStart or onAlarm — those are platform plumbing.

Instance API (this.*)

The Connection Object (conn)

Keep conn.id internal for seats and reconnect bookkeeping — never broadcast it or use it as proof of authorship or permissions. Publish a separate server-assigned seat or participant id for presence. For user attribution, check conn.identity?.type === "authenticated" and use conn.identity.userId; an anonymous visitor is not a signed-in user. Bind user-owned state and permissions to that verified identity, even when a reconnect reuses the same conn.id.

State, Hibernation, and Reconnects

Instance fields (this.users, this.items, …) live only as long as the room is awake. A quiet room hibernates after ~10 seconds even with clients still connected; this.storage is what survives.
  • Persist state you can’t lose when it changes; never write high-frequency churn (every pointer move, every keystroke) to storage.
  • Rehydrate in handleStart(), then reconcile against this.getConnections() — a hibernation wake keeps sockets attached and does not re-run handleConnect, so skipping this leaves every connected client unrecognized until it reconnects.
  • Let a returning conn.id reclaim its entry (seat, role, score) instead of minting a new one.
  • A reconnect that replaces a stale socket holding the same id closes the old one silently — handleClose does not fire for it, so the returning connection keeps its entry. Two live connections cannot share an id: the second is refused. That is why the client persists its connection id per tab (sessionStorage), never per browser.
  • In sessions where a drop shouldn’t instantly destroy state, give a missing id a short grace period; when the last client leaves mid-session, schedule the cleanup as a wake and cancel it if someone reconnects.
static options = { hibernate: false } only makes a room non-hibernatable, not resident — it is still evicted after a couple of minutes idle, so storage remains the only durable answer. It is rarely needed.

Scheduled Wakes

  • Fires even if the room is empty and asleep.
  • One-shot and coarse (±seconds); re-scheduling the same key overwrites it.
  • Good for turn/forfeit timers, delayed cleanup of abandoned rooms, and absolute-time events. In-session countdowns should stay timestamp-driven on the client.

The Managed Ticker (Opt-In)

schedule() handles one wake at an absolute time. When the room has to advance on its own, repeatedly — a game loop, a simulation step, a server-driven countdown — use the managed ticker instead. You opt in by overriding shouldTick(). While it returns true, the platform calls handleTick() every tickIntervalMs (default 100). When it returns false the ticker stops and the room is free to idle out as usual.
  • shouldTick() must be cheap and side-effect free. It is consulted on every tick; do the work in handleTick().
  • Don’t write to this.storage every tick — that is exactly the high-frequency churn to avoid. Persist at checkpoints (phase changes, round ends) and rehydrate in handleStart(); instance fields are still lost on a wake.
  • Don’t re-arm the ticker from handleTick() with schedule(). Flip the state that shouldTick() reads and let the platform manage the loop.
  • A tick is not a delivery guarantee — clients can miss frames. Broadcast enough state to resynchronize, not just deltas.
  • If the room only needs one future event, use Scheduled Wakes; the ticker is for continuous advancement.
handleTick() is an abstract member of Actor, so a TypeScript actor must declare it even when it never opts in — handleTick() {} is enough. Plain-JavaScript actors can omit it.

Broadcasting vs Per-Client Messages

  • this.broadcast(data)room-wide state everyone should see.
  • conn.send(data) — events about one client (your seat, your hand, your error). Broadcasting these leaks private state and makes every client react.
Messages are JSON in both directions. type values beginning with __ are reserved by the platform.

Durable Results

When a session produces something that must outlive the room (the finished drawing, a chat transcript, an exported document):
  1. The actor writes its validated result to this.storage and persists the canonical entity record through this.client.asServiceRole. For user attribution, store an explicit field from authenticated conn.identity.userId.
  2. The actor broadcasts the result and re-conn.sends it to (re)connecting clients — a frontend cannot read actor storage. The frontend displays this result and writes only user-owned records, not the room’s canonical result.
Make entity persistence idempotent using a stable key such as the room’s instance id. Keep a pending result in actor storage until the entity write succeeds, and retry failed writes with a scheduled wake so persistence does not depend on a connected browser. Restrict canonical-result entity writes to the server; do not grant anonymous or frontend clients write access to make persistence work.

Calling Base44 from an Actor

Every actor has this.client, a ready-made @base44/sdk client whose ordinary calls use the app’s anonymous role. Backend functions can be invoked anonymously, so use this.client.functions.invoke(...). Actors also have this.client.asServiceRole for admin-level entity access and integrations:
  • Ordinary entity access is RLS-gated like a logged-out visitor; asServiceRole bypasses RLS. Validate client input and permissions before privileged calls, and use them for work the room owns: canonical results, registry rows, or private configuration.
  • Both clients use production entity data and call the function version pinned to the actor’s deployment.
  • Neither client impersonates a signed-in user. Attribute service-role records with an explicit field from authenticated conn.identity.userId; never take that field from the message payload.
  • The runtime exchanges and caches the service token automatically. The first privileged call after a wake incurs an exchange, so keep privileged calls on persistence paths rather than on every message or tick.

Using Secrets

Actors do not receive app secrets or private data-source bindings. Put secret-dependent operations in a backend function that reads the secret and performs the operation there. For example, define fetch_market_price to use MARKET_API_KEY and return the price, then call it from the actor:
The platform provisions ACTOR_TOKEN_SECRET automatically on the first actor deploy. Do not ask the user to create it or overwrite it during setup: changing it rotates the actors’ keys. It is not exposed to actor or function code. Actor runtime configuration and derived signing keys are platform-managed.

Multi-File Actors

An actor is not limited to entry.ts. Any .js, .ts, .json, or .jsonc file inside the actor’s folder is uploaded on deploy and can be imported with a relative path:
The actor’s own folder is the whole upload. base44 actors deploy sends exactly the files under base44/actors/<Name>/ — unlike functions deploy, which also uploads the base44/shared/ tree alongside every function. Keep the code an actor imports inside the actor’s folder (copy it, or expose it through a backend function the actor calls with this.client).

Rooms and Discovery

One actor instance = one session (one board, one match, one auction). Never funnel every user into a single global room.
  • Cap capacity in handleConnect and conn.reject(...) past the limit — the actor is the only place a cap can be enforced.
  • Browsable rooms: keep a registry entity (e.g. Room with status, user_count) whose record id is the actor instance id. The registry is advertising; the actor is truth. List rooms by subscribing to the entity first, then fetching and reconciling by id, and filter to recently-updated rows (crashed rooms leave stale ones behind).
  • Private rooms: there is no room-level auth. The instance id is the admission control, so mint it with crypto.randomUUID() (record ids are enumerable), keep it out of any readable registry, and share it only as an invite link or code.
  • Instance ids are printable ASCII, 1–256 characters, and may not contain /.

Deploying Actors

Actors are also deployed as part of npx base44 deploy. For details, see actors-deploy.md. Deleting the folder does not remove a deployed actor — the next deploy simply stops including it while the old one keeps serving. Run actors delete to tear it down.

Notes

  • Actors run on the Cloudflare backend; deploying one activates it if needed.
  • Actors serve only the realtime WebSocket path — automations are not supported on an actor. Use a backend function if you need scheduled or entity-triggered work.
  • base44 dev does not run actors locally; verify against a deployed actor.
  • Use npm: specifiers for npm packages (e.g. npm:zod), same as in backend functions.
  • Connecting from the frontend is base44.actors.<Name>(instanceId).connect() — see the base44-sdk skill’s actors.md.

Common Mistakes