@yingyeothon/gamebase-all-together
v2.0.1
Published
Wait/running stage game loop plugin for @yingyeothon/lambda-gamebase (formerly do-game-all-together).
Readme
@yingyeothon/gamebase-all-together
A ready-made gameMain for @yingyeothon/lambda-gamebase for games everyone plays together: a wait stage that processes enter/leave messages until enough users are connected (or the waiting time runs out), a running stage that drives your game controller until the game is over (or the running time runs out), and an end stage that reports the outcome and drops every remaining connection.
This package decides when things happen — a tick, a snapshot, a stage change, the end of the game. What reaches the clients, and in what shape, is up to you: every outbound message goes through a hook. Leave the hooks unset and it keeps sending the messages it always has ({ type: "stage", payload: { stage, age } } once per second, and { type: "enter", payload: { memberId } } on entrance); set one and it is replaced entirely.
This package was formerly published as @yingyeothon/do-game-all-together.
Install
npm install @yingyeothon/gamebase-all-together @yingyeothon/lambda-gamebaseUsage
ESM:
import { runGameAllTogether } from "@yingyeothon/gamebase-all-together";
import {
broadcast,
createGamebaseContext,
gamebaseOptionsFromEnv,
handleActor,
reply,
type BaseGameRequest,
type GameActorStartEvent,
} from "@yingyeothon/lambda-gamebase";
type MoveMessage = { type: "move"; connectionId: string; x: number };
type GameMessage = BaseGameRequest | MoveMessage;
// One context per Lambda container: owns the Redis connection and the
// API Gateway management client.
const context = createGamebaseContext(gamebaseOptionsFromEnv());
export async function actor(event: GameActorStartEvent) {
await handleActor<GameMessage>({
event,
context,
eventKeyPrefix: "game:event:",
awaiterKeyPrefix: "game:awaiter:",
queueKeyPrefix: "game:queue:",
lockKeyPrefix: "game:lock:",
lifetimeSeconds: 300,
gameMain: (options) =>
runGameAllTogether<GameMessage>({
...options,
network: { context },
gameWaitingSeconds: 30,
gameRunningSeconds: 180,
pollIntervalMillis: 100,
// Real time: the simulation advances whether or not anyone acts.
tick: { mode: "fixed", intervalMillis: 50 },
snapshotIntervalMillis: 100,
minPlayers: 3,
isGameOver: ({ context }) =>
Object.keys(context.connectedUsers).length === 0,
processMessage: async ({ context, message }) => {
// Apply a game message to your own state.
},
updateTimeDelta: async ({ context, delta }) => {
// Advance monsters, damage over time, cooldowns.
},
onSnapshot: async ({ context }) => {
// Your own protocol; this package defines no snapshot format.
await broadcast(
Object.keys(context.connectedUsers),
buildSnapshot(),
{
context,
},
);
},
onMemberEntered: async ({ connectionId }) => {
// Also fires on a reconnect: the place to resynchronize.
await reply(connectionId, buildSnapshot(), { context });
},
onGameEnd: async ({ context, reason }) => {
// Connections are still open here, so the result still gets out.
await broadcast(
Object.keys(context.connectedUsers),
{ type: "result", payload: { reason } },
{ context },
);
},
}),
});
}CJS:
const {
GameStage,
runGameAllTogether,
} = require("@yingyeothon/gamebase-all-together");
console.log(GameStage.Wait, GameStage.Running, GameStage.End);Tick policy
The running stage advances the simulation one of two ways.
tick: { mode: "perMessage" } // default
tick: { mode: "fixed", intervalMillis: 50, maxCatchUpSteps: 5 }perMessagecallsupdateTimeDeltaonce per processed message with the wall-clock time since the previous call. Turn-based games want this: nothing moves unless somebody acts.fixedaccumulates elapsed time and runs wholeintervalMillissteps, whether or not messages arrived, with a constantdelta. Real-time games need it — otherwise monster AI, damage over time, and cooldowns freeze while the party stands still and then jump by seconds at once. The constant delta also makes the simulation deterministic and replayable. Leftover time stays owed instead of drifting; a backlog beyondmaxCatchUpSteps(default 5) is dropped with awarn, so a loop that cannot keep up stays responsive rather than falling further behind.
pollIntervalMillis paces the queue polling; the fixed policy ignores it
and paces itself by intervalMillis.
Public API
runGameAllTogether/RunGameAllTogetherOptions(type) — the wait → running → end game loop; a drop-ingameMainforhandleActor. Passnetwork: { context }(or an explicitclientortransport) so broadcasts and connection drops reach the clients;loggerdefaults tonullLogger.endRepeatCountrepeats the end stage and the drops, which a gateway transport needs — see belowGameStage— enumWait = "wait",Running = "running",End = "end"GameTickPolicy(type) —{ mode: "perMessage" }or{ mode: "fixed", intervalMillis, maxCatchUpSteps? }GameEndReason(type) —"cleared" | "timeout" | "notEnoughPlayers" | "error", decided by this package and handed toonGameEndGameHooks(type) —onStageChanged,onMemberEntered,onSnapshot,onGameEnd, withStageChangedOptions,MemberEnteredOptions,SnapshotOptions,GameEndOptions(types).onSnapshotis a client-facing broadcast scheduler, not durable state: it is rate-limited bysnapshotIntervalMillisand nothing it produces is persisted. Game state lives in the actor's heap and is gone when the invocation endsGameController(type) —isGameOver,processMessage, optionalupdateTimeDeltaGameMessageBase(type) —{ type: string; connectionId: string }, the constraint for the message type parameterMdoInStageWait/DoInStageWaitOptions(type) — the wait stage; it ends early only when every user is connected, and otherwise runs the fullgameWaitingSecondsand then resolvestrueif at leastminPlayersmade it (default: every user)doInStageRunning/DoInStageRunningOptions(type) — the running stage loopbroadcastStage— the defaultonStageChanged; broadcasts{ type: "stage", payload: { stage, age } }and reports per-connection deliverybroadcastMemberEntered— the defaultonMemberEntered; broadcasts{ type: "enter", payload: { memberId } }createStageAnnouncer/StageAnnouncerOptions(type) — resolves a stage announcement to the caller's hook or the default broadcastpruneUndeliveredUsers/PruneUndeliveredUsersOptions(type) — unbinds the connections abroadcastcould not reach, so a client that vanished without a$disconnectstops holding the game open. Enabled for the default announcement bydropUndeliveredConnections; call it yourself from a custom hookprocessEnterLeave,processEnter,processLeave(+ProcessEnterLeaveOptions,ProcessEnterOptions,ProcessLeaveOptionstypes) — enter/leave bookkeeping on the base game context
Behavior changes
Connections are dropped
endDropDelayMillis(default 1 s) after the end stage. API Gateway can lose a frame posted immediately beforeDeleteConnection, so dropping right after the result broadcast sometimes swallowed the result; passendDropDelayMillis: 0for the old behavior.A superseded connection is closed, not merely unbound. A member entering on a second connection used to leave the first socket open forever, receiving nothing, with its eventual
leavea no-op.updateTimeDeltamoved out of the message loop. It used to be called from inside the per-message loop, so a game with no traffic did not simulate at all. It is now driven by the tick policy above;perMessagereproduces the old cadence.loopInterval→pollIntervalMillis, matching the repository's*Millisnaming.Outbound messages moved behind hooks. The stage and entrance messages are unchanged unless
onStageChanged/onMemberEnteredis set, in which case the hook replaces them rather than adding to them.BroadcastStageOptions→StageChangedOptions, andbroadcastStagenow resolves with theRespondResultof its broadcast.The wait stage can start short-handed via
minPlayers; the default is still every user. It relaxes the verdict, not the schedule — latecomers still get the whole waiting window.
Ending a game over a gateway transport
endRepeatCount (default 1) repeats the end-stage announcement and the
drops, spaced by endRepeatIntervalMillis (default 200). With
createRedisPubSubTransport both are published exactly once and pub/sub has
no redelivery, so a subscriber gap either shows the party no result or
leaves their sockets open forever — and unlike a tick snapshot, nothing
later heals it. Set it to 2 or more there; both operations are idempotent.
Every announcement goes out before any drop, so a repeat is never addressed
to a socket the previous round already closed. Leave it at 1 for the API
Gateway transport, where each repeat is one PostToConnection per player
against a connection that is already gone.
Migrating from the legacy package
- The npm package was renamed:
@yingyeothon/do-game-all-together→@yingyeothon/gamebase-all-together. - Options types follow the monorepo
*Optionsconvention:RunGameAllTogetherArgs→RunGameAllTogetherOptions, and every exported function's options object now has a named, exported*Optionstype (DoInStageWaitOptions,DoInStageRunningOptions,StageChangedOptions,ProcessEnterOptions,ProcessLeaveOptions,ProcessEnterLeaveOptions). Function names are unchanged. @yingyeothon/lambda-gamebaseno longer keeps module-singleton network clients. Passnetwork: { context }(aGamebaseContextfromcreateGamebaseContext) ornetwork: { client }inRunGameAllTogetherOptions— it is threaded to everybroadcast/dropConnectioncall. Without it, network calls fall back to environment-independent defaults and will fail outside tests.- Logging:
loggernow defaults tonullLogger(previously a debug-level console logger); passcreateConsoleLogger("debug")from@yingyeothon/loggerto restore the old behavior. Log calls use the message-first stylelogger.info("Game end", { gameId })instead of the pino-style context-first form. - All exports are named; there are no default exports.
GameControllermoved to a root export (it was a deep import fromservices/doInStageRunningbefore), and the stage helpers (doInStageWait,doInStageRunning,broadcastStage,processEnter*) are now part of the public surface. - The message type parameter
Mis now constrained byGameMessageBase({ type: string; connectionId: string }) instead ofBaseGameRequest. The legacy constraint made it impossible to type game-specific messages (the whole point ofprocessMessage); every legacy-validMstill satisfies the new constraint. Runtime dispatch is unchanged:enter/leavego to enter/leave processing, everything else toprocessMessage. - See Behavior changes above for what moved since the first v2 pass. Note that the underlying
@yingyeothon/lambda-gamebasemoved to AWS SDK v3: a gone WebSocket client now surfaces asGoneExceptioninstead of astatusCode === 410error, butbroadcast/dropConnectionstill report such connections as undelivered/dropped, so this package's enter/leave and end-of-game processing behaves as before.
