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 usethis.client.asServiceRole for validated, room-owned work.
When to Use an Actor
Actor Directory
All actor definitions live in thebase44/actors/ folder. An actor is a folder containing an entry.ts file:
How to Create an Actor
- Create a directory in
base44/actors/named after the actor (PascalCase) - Create
entry.tsin that directory and default-export a class extendingActor - Deploy it with
npx base44 actors deploy
Actor Discovery and Naming
The CLI discovers actors fromentry.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 level —
base44/actors/games/Arena/entry.tsis 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 extendingActor, imported from base44:runtime/actors — the only import that resolves the base class:
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 againstthis.getConnections()— a hibernation wake keeps sockets attached and does not re-runhandleConnect, so skipping this leaves every connected client unrecognized until it reconnects. - Let a returning
conn.idreclaim 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 —
handleClosedoes 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 inhandleTick().- Don’t write to
this.storageevery tick — that is exactly the high-frequency churn to avoid. Persist at checkpoints (phase changes, round ends) and rehydrate inhandleStart(); instance fields are still lost on a wake. - Don’t re-arm the ticker from
handleTick()withschedule(). Flip the state thatshouldTick()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.
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):- The actor writes its validated result to
this.storageand persists the canonical entity record throughthis.client.asServiceRole. For user attribution, store an explicit field from authenticatedconn.identity.userId. - 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.
Calling Base44 from an Actor
Every actor hasthis.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;
asServiceRolebypasses 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, definefetch_market_price to use MARKET_API_KEY and return the price, then call it from the actor:
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 toentry.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:
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
handleConnectandconn.reject(...)past the limit — the actor is the only place a cap can be enforced. - Browsable rooms: keep a registry entity (e.g.
Roomwithstatus,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
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 devdoes 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.