@yingyeothon/lambda-gamebase
v2.2.0
Published
Serverless WebSocket game framework on AWS Lambda: actor-based game loop, connection handling, and broadcasting.
Readme
@yingyeothon/lambda-gamebase
Serverless WebSocket game framework on AWS Lambda: an actor-based game loop backed by Redis, API Gateway WebSocket connection handling ($connect, $disconnect, $default), broadcasting/replying to connections, and the base game context/user/observer models shared by game implementations.
Three keys are the whole interface to whatever terminates the sockets; note that the queue key gains no extra segment.
flowchart LR
A["your actor"] -->|"writes"| E["eventKeyPrefix + gameId<br/>the start event"]
G["the gateway"] -->|"reads"| E
G -->|"RPUSH, durable"| Q["queueKeyPrefix + gameId<br/>a Redis list, no queue: segment"]
A -->|"drains"| Q
A -->|"PUBLISH, lossy"| CH["channelPrefix + gameId<br/>pub/sub"]
G -->|"subscribes first"| CHInstall
npm install @yingyeothon/lambda-gamebaseAWS SDK v3 clients are peer dependencies:
npm install @aws-sdk/client-apigatewaymanagementapi @aws-sdk/client-lambdaLibrary code never reads process.env. Configuration is injected as a GamebaseOptions object; the gamebaseOptionsFromEnv() helper reads the documented variables (REDIS_HOST, REDIS_PORT, REDIS_USER (ACL user, optional), REDIS_PASSWORD (optional), REDIS_TLS (any non-empty value wraps the Redis connection in TLS; unset is cleartext), WS_ENDPOINT (API Gateway management endpoint), GAME_ACTOR_LAMBDA_NAME, and IS_OFFLINE for serverless-offline development) if you want to keep configuring via environment.
Usage
ESM:
import {
broadcast,
createGamebaseContext,
gamebaseOptionsFromEnv,
handleActor,
handleConnect,
handleDisconnect,
handleMessages,
setupBaseGameContext,
type BaseGameRequest,
type GameActorStartEvent,
} from "@yingyeothon/lambda-gamebase";
import type { APIGatewayProxyEvent } from "aws-lambda";
// One context per Lambda container: it owns the lazily created shared
// Redis connection and API Gateway management client.
const context = createGamebaseContext(gamebaseOptionsFromEnv());
// Game actor Lambda: runs the game loop as an actor.
export async function actor(event: GameActorStartEvent) {
await handleActor<BaseGameRequest>({
event,
context,
eventKeyPrefix: "game:event:",
awaiterKeyPrefix: "game:awaiter:",
queueKeyPrefix: "game:queue:",
lockKeyPrefix: "game:lock:",
lifetimeSeconds: 300,
gameMain: async ({ gameId, members, pollMessages }) => {
const gameContext = setupBaseGameContext(members);
const messages = await pollMessages();
await broadcast(
Object.keys(gameContext.connectedUsers),
{ type: "tick" },
{ context },
);
},
});
}
// WebSocket $connect handler. `resolveMemberId` and `selectSubprotocol`
// are what make this safe to expose — see Security below.
export const connect = (event: APIGatewayProxyEvent) =>
handleConnect({
event,
context,
connectionIdAndGameIdKeyPrefix: "game:conn:",
actorEventKeyPrefix: "game:event:",
actorQueueKeyPrefix: "game:queue:",
queueTtlSeconds: 900, // required: the queue key expires even if no one drains it
resolveMemberId: (connecting) => {
const memberId: unknown =
connecting.requestContext.authorizer?.["memberId"];
return typeof memberId === "string" ? memberId : undefined;
},
selectSubprotocol: (offered) =>
offered.includes("bearer") ? "bearer" : undefined,
});Without resolveMemberId the member id is whatever the client put in
x-member-id, which is not authentication. Read Security before
deploying a $connect handler.
CJS:
const {
createGamebaseContext,
dropConnection,
gamebaseOptionsFromEnv,
reply,
} = require("@yingyeothon/lambda-gamebase");
const context = createGamebaseContext(gamebaseOptionsFromEnv());
exports.hello = async (connectionId) => {
await reply(connectionId, { type: "hello" }, { context }); // false when undeliverable
await dropConnection(connectionId, { context });
};Public API
Actor loop
handleActor/HandleActorOptions— game actor Lambda entry point: persists the start event, acquires the actor lock, signals the lobby, runs the actor loop. The lock lease islockTimeoutSeconds(default 30) and is heartbeated while the game runs, so a crashed actor frees itsgameIdin seconds rather than for the game's whole lifetime. A live actor that outlives its own lease (a Redis failover, a network gap) re-acquires and keeps playing; only a successor actually holding the game stops itstartActorLoop/StartActorLoopOptions— runsgameMaininside the actor event loop and clears the start event at the end.redisConnectionis optional and only builds the defaultdeleteStartEvent; supply that instead and the loop needs no RediscreateActorSubsystem/ActorSubsystemOptions/ActorSubsystem— Redis-backed queue/lock/awaiter with per-component key prefixes;queueTtlSecondsis required (handleActordefaults its own tolifetimeSeconds + 10)saveActorStartEvent,loadActorStartEvent,clearActorStartEvent— start-event persistence helpersauthorizeGameConnection/AuthorizeGameConnectionOptions/GameConnectionAuthorization(types) — the "may this member speak for this game" checkhandleConnectmakes, exported so a custom gateway runs the same one instead of re-deriving itreadyCall— HTTP PUT ready signal to the lobby callback URLGameActorStartEvent(type)
API Gateway handlers
handleConnect/HandleConnectOptions— resolves the member id (resolveMemberId, defaultx-member-id), validates it againstx-game-id's start event, maps the connection, enqueuesenter, and optionally echoes aSec-WebSocket-Protocol(selectSubprotocol).queueTtlSecondsis required and re-applied to the queue key on every pushhandleDisconnect/HandleDisconnectOptions— enqueuesleaveand removes the mapping; takes the same requiredqueueTtlSecondshandleMessages/HandleMessagesOptions— validates the client message and enqueues it stamped with the connection id. Messages whosetypeis reserved are refused with400. Takes the same requiredqueueTtlSecondshandleDebugStart/HandleDebugStartOptions— serverless-offline only: breaks the actor lock and invokes the actor Lambda locallydefaultConnectionMappingTtlMillis— the defaultconnectionId -> gameIdmapping lifetime (900000).handleMessagesrefreshes it on every inbound message, so it bounds idle time rather than session length; pass the sameconnectionMappingTtlMillisto both handlers if you change it
Networking
reply,broadcast,dropConnection,fakeConnectionId,RespondResult(type) — the calls a game loop makes; each resolves aTransportfrom itsNetworkOptionsTransport(type) —{ send(connectionId, message), sendMany?(connectionIds, message), drop(connectionId) }. Encoding belongs to the implementation:reply/broadcastnever serialize, so a transport may use JSON, a binary codec, or an envelope of its own.broadcastpreferssendManywhen a transport offers it and falls back to onesendper connection otherwiseNetworkOptions(type) —{ transport?, client?, context?, logger?, sendTimeoutMillis? }; an explicittransportwins, otherwise the API Gateway transport is built fromclientorcontextresolveTransport(functionName, options)— the resolution above, exposed for custom network helperscreateApiGatewayTransport(options)/ApiGatewayTransportOptions(type) /isGoneException— the default transport: JSON overPostToConnection,DeleteConnectionto drop, andsendTimeoutMillisto abort a delivery so one unresponsive connection cannot stall a game tickcreateRedisPubSubTransport(options)/RedisPubSubTransportOptions(type) /GatewayCommand(type) — publishes{ op: "send" | "drop", ... }on{channelPrefix}{gameId}for deployments that terminate WebSockets in their own gateway process. Subscribe withcreateRedisSubscriberfrom@yingyeothon/naive-redis. It implementssendMany, so one broadcast is onePUBLISHcarryingconnectionIdsand the gateway does the fan-out — see the contract below. Its boolean means "a gateway was subscribed", not "the client received it", so do not pair it withgamebase-all-together'sdropUndeliveredConnections— a gateway restart would evict the whole party
Infrastructure
GamebaseOptions(type) /gamebaseOptionsFromEnv()— injected configuration and the explicit env readercreateGamebaseContext/GamebaseContext/GamebaseContextOptions— owns the lazily created shared Redis connection and API Gateway management client (both injectable for tests)useRedis(work, connectionOptions)— short-lived Redis connection helper
Models and requests (types)
BaseGameContext,BaseGameUser,BaseGameObserver,GameStartMember,GameMainOptionsBaseGameRequest,BaseGameEnterRequest,BaseGameLeaveRequest,BaseGameConnectionIdRequestreservedRequestTypes/isReservedRequestType(type)—enterandleaveare produced by the connection handlers and decide which member a connection speaks for, so a client may never send them
Support
setupBaseGameContext,sleep,createTicker/Ticker/TickerOptions,createTimeDelta/TimeDelta
Gateway integration contract
Replacing API Gateway with your own WebSocket gateway means using only the
actor half of this package: handleConnect / handleDisconnect /
handleMessages are bypassed, and the gateway takes over what they did.
A gateway written in another language cannot import UserMessage or call
enqueue(), so this section is the contract rather than a summary of one.
Keys
Three keys, each {prefix}{gameId}, with the prefixes coming from your
configuration on both sides:
| key | direction | who writes |
| -------------------------- | --------------------------- | ------------------------------------------- |
| {eventKeyPrefix}{gameId} | the game's start event | the actor writes, the gateway reads |
| {queueKeyPrefix}{gameId} | inbound, a Redis list | the gateway RPUSHes, the actor drains |
| {channelPrefix}{gameId} | outbound, a pub/sub channel | the actor publishes, the gateway subscribes |
The queue key has no queue: segment. createRedisSubsystem appends
one to its prefix, but this package does not: createActorSubsystem and
handleConnect pass queueKeyPrefix straight through. A gateway that
copies the subsystem layout pushes into a key nobody reads, and nothing
anywhere reports an error.
The inbound envelope
A pushed value is a JSON UserMessage<T>, not a bare payload:
{
"messageId": "6a1f…",
"awaitPolicy": 0,
"item": { "type": "move", "connectionId": "i-1:9f2…", "x": 3 },
"awaitTimeoutMillis": 0
}awaitPolicy is a numeric enum — AwaitPolicy.Forget === 0, which is
what a gateway wants. Push a bare payload and poll() returns an array of
undefined items with no error anywhere.
item is the game's own message, stamped with the connectionId it came
from. enter and leave are reserved: the actor decides which member a
connection speaks for from them, so a gateway must synthesise them itself
and refuse them from clients (isReservedRequestType).
The outbound envelope
The actor publishes JSON GatewayCommands on {channelPrefix}{gameId}. The
gateway unwraps them; message is the game's own payload and reaches the
client verbatim.
{ "op": "send", "connectionId": "i-1:9f2…", "message": { "type": "stage" } }
{ "op": "send", "connectionIds": ["i-1:9f2…", "i-1:3ab…"], "message": { … } }
{ "op": "drop", "connectionId": "i-1:9f2…" }There are two send shapes and a gateway must handle both. reply
sends to one connection; broadcast sends to many in a single command, so
the gateway does the fan-out it is already positioned to do — at 8 players
and a fixed tick that is the difference between one publish per tick and
eight. op alone does not tell them apart: branch on whether
connectionIds is present.
drop and the end-of-game frames are published once and pub/sub has no
redelivery, which is why gamebase-all-together's endRepeatCount exists.
Ordering
Inbound is a list, so it is durable — the gateway may push before the actor is running. Outbound is pub/sub, so a publish with no subscriber is simply lost. Nothing bridges that asymmetry except order:
Subscribe to
{channelPrefix}{gameId}before pushing the first inbound message for thatgameId, and unsubscribe when its last connection closes.
That is sufficient because the actor learns connection ids only from
enter, so it cannot publish before the first inbound message exists.
RPUSH is not a trigger: pushing does not start a Lambda. The actor is
invoked explicitly, and readyCall — fired only when the start event
carries a callbackUrl — is the handshake that says the loop is up. It
fires after the lock is acquired, so it means "this invocation owns the
game", and a duplicate invocation stays silent.
RPUSH replies with the list depth, so a gateway learns the queue depth on
every push for free. A depth that stops falling is how a gateway notices the
actor died; createRedisQueue's ttlSeconds is the backstop for the case
where the gateway is what died.
Authorization
The gateway owns it, because it replaced the only place that did it. A token
proves who the caller is, not which game they belong to, and gameId comes
from the client:
import { authorizeGameConnection } from "@yingyeothon/lambda-gamebase";
import { redisGet } from "@yingyeothon/naive-redis";
const result = await authorizeGameConnection({
gameId, // client-supplied
memberId, // from the verified token
eventKeyPrefix: "gamebase:event:",
get: (key) => redisGet(connection, key),
logger,
});
if (!result.authorized) {
// result.reason is "unknownGame" or "notAMember"
return refuse();
}Skipping it lets anyone push messages into any game's queue. Keep the
logging discipline too: the start event carries names and e-mail addresses,
so log memberCount, never members.
What this package does not do
- A game is capped at one Lambda invocation. The actor loop has no
shiftand no hand-off, so the game ends when the Lambda times out.createLambdaShiftin@yingyeothon/actor-system-lambdabelongs to thetryToProcesspath and is not reachable from here. - Message delivery to the actor is at-most-once. The loop flushes the queue before the game acts on the batch, so a crash in between loses it silently. A game that cannot lose input needs an ack of its own.
- Nothing snapshots actor state. Redis holds the queue, the lock, and the start event; game state lives in the actor's heap and is discarded when the invocation ends.
Security
By default handleConnect reads memberId from the client's x-member-id
header or query string and only checks that it appears in the game's start
event. That is not authentication: anyone who knows another member's id
can connect as that member — and gamebase-all-together broadcasts every
member id to every player by default, so ids are not secret.
Close it with resolveMemberId. Put a REQUEST authorizer on $connect
(see @yingyeothon/lambda-authorizer-jwt —
a WebSocket API supports no other Lambda authorizer type) and read the
verified identity from its context:
await handleConnect({
event,
...prefixes,
resolveMemberId: (event) => {
const memberId: unknown = event.requestContext.authorizer?.["memberId"];
return typeof memberId === "string" ? memberId : undefined;
},
selectSubprotocol: (offered) =>
offered.includes("bearer") ? "bearer" : undefined,
context,
});resolveMemberId returning undefined rejects the connection, so the
authenticated path fails closed. The x-member-id header is then ignored
entirely; only x-game-id still comes from the client, and membership is
still checked against the start event, so a verified member can only enter
a game it was actually invited to.
selectSubprotocol echoes the Sec-WebSocket-Protocol value the server
selected. A browser cannot set headers on a WebSocket handshake, so a token
usually travels as new WebSocket(url, ["bearer", token]); the browser
then aborts the handshake unless the server names the subprotocol it chose.
A selection the client did not offer is dropped and logged rather than
sent, because a browser would abort on that too.
Note what the callback receives: with the arrangement above the offered
array is ["bearer", "<the raw JWT>"]. It carries the credential — never
log it.
handleMessages refuses enter/leave from a client so that $default
cannot be used to rebind a connection to another member. It otherwise
routes purely by connectionId, and the member that connection speaks for
was decided at $connect.
Two things this does not close, because neither is an identity question:
- A member may hold several connections at once. Nothing caps them, and
each
$connectenqueues anenter, whose defaultonMemberEnteredbroadcasts to everyone. Rate-limit connections per member upstream. - A superseded connection keeps its mapping. When a member reconnects,
processEnterrebinds the game slot to the new connection id, but the old connection'sconnectionId→gameIdentry lives until it disconnects or the entry expires, so$defaultstill accepts messages from it. Close the old socket, or have the game ignore a message whose connection is no longer the member's current one.
Once a connection is established no authorizer can revoke it. Dropping a
player mid-game is the game loop's job, through Transport.drop.
Behavior changes
A start event that cannot be cleared no longer fails the invocation.
startActorLooplogs"cannot clear the actor start event"and returns: the game is over and the key carries its own TTL, so a slow store at that moment was reporting a successful run as anInvoke Error. Nothing from the game reaches the invocation either —gameMain's throw is logged as"unexpected error from game"— so a failed invocation points at the start event, the lock, or the readiness handshake.A Redis connection is required only when something would use it.
handleActorused to throwrequires either redisConnection or contextbefore doing anything, even whensubsystem,saveStartEventanddeleteStartEventwere all supplied — which is every use it has for one. It now demands a connection only for the defaults the caller left in place, so an in-memory subsystem runs the real entry point with no Redis at all, which is whatexamples/actor-gamedoes. Leave any of the three to its default and the refusal is unchanged — and it now happens before the start event is written, not merely before the lock is taken, so a misconfigured call no longer leaves a persisted start event holding itsgameIduntil the key expires. The connection is resolved lazily and only when a default actually needs one, so acontextbuilt withoutredisoptions — which is still whatreplyandbroadcastwant — does not defeat the escape hatch.StartActorLoopOptions'sredisConnectionbecame optional for the same reason: it only ever built the defaultdeleteStartEvent.A short, heartbeated actor lock. The lease was the game's whole lifetime (
lifetimeSeconds + 10, up to ~730 s), so a crash at t=30s left thegameIdunstartable for the remaining minutes. It is nowlockTimeoutSeconds(default 30), extended by a heartbeat while the game runs. A Redis outage longer than the lease does not end the game: the lease is a deadline for a successor, so a live actor whose lease lapsed re-acquires and carries on, and only a genuine hand-off stops it.readyCallfires after the lock is acquired. It used to fire before, so a duplicate invocation told the lobby a game was ready that it would never run.A broadcast is one call when the transport supports it.
Transportgained an optionalsendMany, whichbroadcastprefers; the per-connection loop is still the fallback and theRespondResultshape is unchanged.GatewayCommandgained a secondsendshape, and this is a wire change.broadcastovercreateRedisPubSubTransportnow publishes{ op: "send", connectionIds: [...], message }instead of one command per recipient. An existing gateway that readscommand.connectionIdgetsundefinedfor every broadcast and drops the frame, with no error anywhere — update it to branch onconnectionIdsfirst. See the gateway contract above.The actor's queue key TTL belongs to whoever pushes.
handleConnect,handleDisconnect, andhandleMessagestakequeueTtlSeconds; the actor only drains its queue, so the TTLhandleActorgives its own subsystem is inert unlessgameMainpushes through it.queueTtlSecondsis required onhandleConnect,handleDisconnect,handleMessages, andcreateActorSubsystem(handleActordefaults its own tolifetimeSeconds + 10). Every runtime key carries a TTL: a queue pushed by something other than the gateway must still expire, and on a sharedallkeys-lruRedis a key that never expires evicts someone else's first.broadcastlogs atdebug, and logs counts. It used to log the connection id list and the whole response body atinfo, which is a game payload in the logs several times a second at a fixed tick.The
connectionId -> gameIdmapping is refreshed.handleMessagesextends it on every inbound message, so a session longer than the mapping's TTL no longer loses its routing entry.Transportseam.reply/broadcast/dropConnectionnow go through aTransportinstead of calling the API Gateway SDK directly. Passingclientorcontextbehaves exactly as before;NetworkOptions.transportreplaces it, and the "requires either client or context" error became "requires either transport, client, or context".Reserved message types.
handleMessagesanswers400for a client message whosetypeisenterorleave. Those are produced byhandleConnect/handleDisconnectand decide which member a connection is bound to, so accepting one from a client let an authenticated member bind another member's game slot to its own connection.Authenticated identity.
handleConnecttakesresolveMemberId, which defaults to today'sx-member-idheader or query string read, so unset it behaves exactly as before. Pass a resolver reading the authorizer's context to close the identity gap described under Security.Subprotocol echo.
handleConnecttakesselectSubprotocol, and returns aSec-WebSocket-Protocolresponse header when it selects one. Unset, the response is unchanged.Case-insensitive headers.
x-game-idandx-member-idare now matched regardless of header casing; a client sendingX-GAME-IDused to be rejected. Query string lookup is still exact. A header present but empty now falls through to the query string, where it used to win and fail the request; andevent.headersbeing absent no longer throws. Two headers differing only in case resolve to the first one declared.Less in the logs. Neither
handleConnectnorhandleDebugStartlogs a whole start event any more — it carries every member's name and email.handleMessageslogs a parse failure'sError.namerather than the error, whose message quotes the body it choked on. The membership rejection still names the member and the connection, which underresolveMemberIdis a verified principal and the only way to attribute a probe.Distinct refusal messages. A missing member id (which is what a
resolveMemberIdfinding no identity produces) and a missing game id are now logged apart, instead of both as "invalid gameId".
Migrating from the legacy package
- All exports are named; there are no default exports. Interfaces (
BaseGameContext,GameActorStartEvent, ...) are type-only exports. - AWS SDK v2 → v3.
replyanddropConnectionnow useApiGatewayManagementApiClientwithPostToConnectionCommand/DeleteConnectionCommand, andhandleDebugStartusesLambdaClientwithInvokeCommand. A disconnected WebSocket client no longer surfaces as an error withstatusCode === 410; SDK v3 throwsGoneExceptioninstead.replystill returnsfalsefor a gone client so disconnect processing is unchanged, and the newisGoneException(error)helper detects this case (error instanceof GoneExceptionorerror.name === "GoneException").dropConnectionnow treats an already-gone connection as success. - No environment reads, no module singletons. The
envlazy getter,getRedisConnection/setRedisConnection, andgetApiGatewayManagementClient/setApiGatewayManagementClientwere removed. Build aGamebaseOptions(or callgamebaseOptionsFromEnv()), pass it tocreateGamebaseContext(options), and hand that context to the handlers (contextoption) and network functions (NetworkOptions.context). The context owns the lazily created shared Redis connection and management client; explicitredisConnection/clientoverrides still win. TheGamebaseEnvtype becameGamebaseOptionswith a nestedredis: RedisConnectionOptions. - Renames per the v2 API conventions:
newActorSubsys→createActorSubsystem,NewActorSubsysArgs→ActorSubsystemOptionsHandleActorArgs→HandleActorOptions,StartActorLoopArgs→StartActorLoopOptions,HandleConnectArgs→HandleConnectOptions,HandleDebugStartArgs→HandleDebugStartOptions,HandleDisconnectArgs→HandleDisconnectOptions,HandleMessagesArgs→HandleMessagesOptions- the
subsysoption ofhandleActor/startActorLoop→subsystem GameMainArguments→GameMainOptionsTickerclass →createTicker({ stage, aliveMillis })returning theTickerinterface;TimeDeltaclass →createTimeDelta()returning theTimeDeltainterface
- Logger defaults. Every optional
loggernow defaults tonullLoggerinstead of a console logger, and theSTAGE-based default severity inbroadcast/replyis gone — pass the logger (e.g.createConsoleLogger("info")) you want via options. useRedis(work, { host, password })with env fallbacks becameuseRedis(work, connectionOptions)with required, explicitRedisConnectionOptions.handleDebugStartnow requires acontext; it readsisOfflineandgameActorLambdaNamefromcontext.optionsinstead ofIS_OFFLINE/GAME_ACTOR_LAMBDA_NAME.- Handlers and the actor loop accept optional injection points (
redisConnection,subsystem,lambdaClient,logger,saveStartEvent,deleteStartEvent,NetworkOptions.client) for testing; defaults preserve the legacy behavior. createActorSubsystembuilds the queue/lock/awaiter with thecreateRedisQueue/createRedisLock/createRedisAwaiterfactories from@yingyeothon/actor-system-redis.- Bug fixes:
dropConnectionnow actually awaits the SDK call (the legacy version forgot to), andbroadcastreturns the documented{ [connectionId]: delivered }map (the legacy version accidentally merged{ connectionId, success }objects). handleDebugStartno longer lets a fire-and-forget invocation reject unhandled; the failure is logged.
