framework/ is the reusable runtime layer for CasinoShiz modules.
Release notes and the NuGet/GitHub publishing procedure live in
docs/releases/1.0.0.md and
docs/framework-release.md.
The framework is split into transport-neutral contracts, backend/runtime infrastructure, and channel adapter infrastructure. Telegram and Discord are presentation/transport adapters; game contracts, decisions, effects and domain events do not depend on either channel or on the deployment shape. Game modules should depend on the smallest framework surface they need.
The current public integration boundary is tenant-aware and intentionally
breaking. Every inbound operation carries a TenantContext:
public sealed record TenantContext(
TenantId TenantId,
ScopeId ScopeId,
PlayerId? PlayerId,
BotChannel Channel,
RequestId RequestId,
RequestId CorrelationId);TenantId, ScopeId, PlayerId and RequestId are opaque string value
types. TenantKey and ScopeKey are internal PostgreSQL numbers and never
appear in SDK DTOs, REST paths, gRPC metadata contracts or module signatures.
Wallets are scope-local: the same player id in two scopes or tenants is always
a different wallet boundary.
The scoped ITenantContextAccessor is the ambient boundary for infrastructure
below a transport adapter. Host code pushes it once at ingress and restores it
after the operation. Background jobs and outbox consumers restore it from
persisted tenant/scope metadata before invoking module code.
| Channel | Tenant | Scope |
|---|---|---|
| Telegram forum chat | chat | topic id |
| Telegram ordinary chat | chat | main |
| Telegram private chat | private chat container | main |
| Discord guild | guild | channel or thread |
| Discord DM | private container | main |
| REST | trusted JWT tenant_id |
trusted JWT scope_id |
REST uses the JWT sub claim as the opaque PlayerId. The route tenant and
scope are checked against the token before a module handler is called. A
channel binding is provisioned idempotently in the Host registry for the
resolved platform container.
The supported package-only surface is:
| Package | Purpose | Target |
|---|---|---|
BotFramework.Contracts |
Opaque ids, tenant context, transport contracts, errors, pagination and limiter contracts | net8.0;net10.0 |
BotFramework.Sdk |
Pure module, domain, decision/effect, event and scheduling abstractions | net8.0;net10.0 |
BotFramework.Testing |
Test doubles and in-memory SDK fixtures | net8.0;net10.0 |
BotFramework.Text |
Platform-neutral text normalization, analyzers, policies and effect contracts | net8.0;net10.0 |
BotFramework.Scheduling.Abstractions |
Transport-neutral scheduled command contracts | net8.0;net10.0 |
BotFramework.Rest |
Canonical REST middleware and route-module contract | net10.0 |
BotFramework.Telegram.Abstractions |
Telegram update and tenant-resolution contracts | net10.0 |
BotFramework.Discord.Abstractions |
Discord tenant-resolution contracts | net10.0 |
BotFramework.Client |
Authenticated typed REST transport and generated OpenAPI client | net8.0;net10.0 |
The text-processing subsystem is documented separately in
docs/botframework-text.md. It intentionally
contains no censorship, spam, moderation, persistence, or rule-management
business logic.
BotFramework.Host, BotFramework.Telegram, BotFramework.Discord and
other runtime projects are composition/runtime adapters. They may use
PostgreSQL, Redis, CAP, Discord.Net or Telegram.Bot and target net10.0.
They are not dependencies of a pure domain or contracts project.
The supported packages are released as 1.0.0 and carry this framework
README, MIT license metadata, repository metadata, SourceLink, embedded
symbols and deterministic package output. BotFramework.GameTemplates is a
separate dotnet new consumer artifact released at the same version.
REST modules map only under:
/api/v1/tenants/{tenantId}/scopes/{scopeId}/{module}/{operation}
The old scope-only /scopes/{scopeId} route is removed. State-changing
requests require a printable Idempotency-Key. The framework creates or
propagates X-Request-ID and X-Correlation-ID and returns them on the
response. Errors are RFC 7807 application/problem+json documents with
stable code, correlationId and optional retryAfterSeconds fields.
BotFramework.Client owns token acquisition, tenant/scope context,
idempotency and correlation headers, and typed RFC 7807 failures. Its low-level
client is generated by NSwag from the checked-in OpenAPI artifact:
dotnet build framework/BotFramework.Client \
-p:BotFrameworkGenerateClient=trueList endpoints use CursorPageRequest and CursorPage<T>; cursors are opaque
and must not be interpreted by clients.
The service-defaults client interceptor propagates request metadata with these lowercase keys:
tenant_id
scope_id
player_id
request_id
correlation_id
channel
Numeric Telegram/Discord update and message ids stay inside their adapters.
They are not public module identity or wire-contract fields. Removed protobuf
field numbers remain reserved in the owning .proto files.
REST, Telegram, Discord and the Host command pipeline use the same
IRateLimiter contract. Redis evaluates all applicable token buckets in one
Lua call:
tenant
tenant + player
tenant + IP (REST only)
tenant + route
tenant + player + route
Route keys are stable module/command identifiers, never raw URLs or user
payloads. Deployment defaults are REST player 120/min, REST IP 300/min,
Telegram burst 10 with refill 1/s, Discord 8/10s, and a tenant ceiling
of 3000/min. PostgreSQL tenant overrides use this precedence:
tenant + route -> tenant + channel -> deployment route -> deployment channel -> global default
All applicable dimensions are charged. Redis outages switch to bounded local
buckets with the same policy values; the decision carries IsFallback, the
service reports degraded limiter telemetry and retries Redis with backoff.
REST returns 429, Retry-After and rate-limit headers; channel adapters stop
before game side effects.
Framework instrumentation uses BotFramework.* meters and activity sources.
Metric labels are limited to service, channel, module, route template,
outcome, limiter dimension and fallback state. Tenant, scope, player, request
and correlation identifiers belong only in structured logs and traces.
The standard service defaults expose request/error counters and latency,
atomic execution, lock wait and transaction duration, outbox lag/depth,
limiter decisions/fallback, policy-cache hits/misses and tenant provisioning.
Framework integration messaging additionally exposes durable outbox depth and
publish/failure counters, inbox duplicate/result-replay/handler-failure
counters, schema rejection/quarantine counters, durable workflow timeout
schedule/dispatch/retry counters, and service database ownership violations.
Compose includes an OTLP Collector, Tempo, Prometheus, Grafana trace links and
Alertmanager's configurable generic webhook. Helm supports an external OTLP
endpoint and optional Prometheus Operator ServiceMonitor, PrometheusRule
and AlertmanagerConfig resources. The alert rules cover unavailable
service/DB/Redis, prolonged local fallback, HTTP/game failures, request/game
p95 latency, outbox lag, queue backlog, CPU and memory pressure.
The 1.0.0 repository baseline was measured on 2026-08-07 UTC. It is a
development reference for comparing changes, not an SLA or a production
capacity guarantee. The complete route matrix, error classifications and
artifacts are documented in docs/rest-baseline.md.
The measurement host was:
| Property | Value |
|---|---|
| OS | CachyOS Linux, rolling, x86_64 |
| Kernel | 7.1.5-1-cachyos |
| CPU | AMD Ryzen 7 6800H, 8 cores / 16 logical CPUs |
| RAM | 30 GiB host memory; 30 GiB swap |
| .NET | SDK 10.0.110, runtime 10.0.10 |
| Deployment | local k3d/k3s dev cluster, ingress api.casinoshiz.localhost |
| Dev pod profile | 2 CPU / 1 GiB for game backend and REST pods after the final tuning pass |
| Services | PostgreSQL and Redis; ClickHouse and OTLP exporters disabled for the clean post-fix REST run |
The load generator was wrk f8eb608 with the epoll backend. The profiling
harnesses were eng/run-rest-profile-matrix.sh and
eng/profile-rest-endpoint.sh. Runtime diagnostics were collected with
dotnet-trace and dotnet-counters, both version 9.0.661903; process CPU
and RSS were sampled from Linux /proc/ps. PostgreSQL CPU, RSS and
transactions were sampled where the benchmark used a Docker PostgreSQL
container. The high-concurrency matrix used 2 wrk threads, 128 connections
and 10 seconds per scenario; the stable post-fix Horse comparison also used
32 and 64 connections.
Representative successful read baselines:
| Route | Profile | RPS | Average latency |
|---|---|---|---|
Horse GET /horse/info |
2 threads / 32 connections / 10 s, post-fix | 2704.70 | 12.00 ms |
Horse GET /horse/info |
2 threads / 64 connections / 10 s, post-fix | 2510.38 | 26.20 ms |
Leaderboard GET /leaderboard/ |
2 threads / 128 connections / 10 s | 2005.54 | 66.98 ms |
Pick GET /pick/daily/schedule |
2 threads / 128 connections / 10 s | 3855.30 | 33.37 ms |
PixelBattle GET /pixelbattle/grid |
2 threads / 32 connections / 10 s, stable profile | 131.20 | — |
The measurements used real framework middleware and game routes through the
dev ingress. Routes returning 404 or 500 were retained for diagnostics but
are not capacity baselines. Stateful game mutations were excluded from the
read-only matrix unless a dedicated fixture and cleanup strategy existed.
Kafka/Redpanda was not part of these RPS measurements; its framework transport
was verified separately with a functional CAP round-trip smoke test.
The repository validates the framework as an external consumer, not only via
project references. eng/package-consumer-smoke.sh packs public packages into
.artifacts/local-feed with package validation enabled, restores the external
samples/CoinFlip module from that feed and runs its tests. It also installs
the template package in an isolated dotnet new hive, generates all channels,
builds the atomic scaffold and runs its isolated tests.
Install the scaffold from a local feed or NuGet:
dotnet new install BotFramework.GameTemplates::1.0.0
dotnet new botframework-game -n CoinFlip --module-id coin-flipThe default scaffold contains pure Domain, Application, Contracts,
Infrastructure, REST, Telegram, Discord and isolated tests. Use
--channels rest|telegram|discord|all,
--persistence atomic|event-sourced|none and
--include-tests true|false to select the generated surface.
The repository still contains demonstration games using previous numeric
contracts. They are a repository compatibility layer only; they are not part
of the supported package-only consumer contract. New modules use opaque ids,
TenantContext, tenant wallet effects and tenant-aware state/event/schedule
persistence. The package contract is frozen for 1.0.0; repository game
migration does not change it.
The assembly map below includes the existing runtime and demonstration-game surface for repository maintainers. It is not the supported package-only SDK surface described above; legacy numeric game contracts are staging material until the games migration is completed.
In addition to a one-shot atomic action and the turn-based action base, the
SDK has a third composition model for a game whose result is delivered later by
another system: DeferredOutcomeWager. It fits a bot animation, remote match
result, asynchronous renderer or any other flow shaped as:
place wager -> wait for external outcome -> settle
\-> abort and refund if delivery fails
The model owns idempotent atomic execution, per-user/chat versioned JSONB state, wallet debit/credit, optional standard daily quota, and compensation. A game owns only its marker type, outcome type, deterministic payout function, and optional domain-event factories. The Host adds all three actions, descriptors, state stores and a compact executor in one call:
services.AddDeferredOutcomeWagerGame(
new DeferredOutcomeWagerDefinition<CoinFlipGame, CoinSide>(
gameId: "coin-flip",
displayName: "Coin flip",
calculatePayout: static (wager, side) =>
side == CoinSide.Heads ? wager.Amount * 2 : 0,
dailyQuota: new DeferredOutcomeWagerDailyQuota("coin-flip.daily")));An application service injects
IDeferredOutcomeWagerGameExecutor<CoinFlipGame, CoinSide> and passes the
three typed Place, Resolve, and Abort commands. The transport remains
responsible for producing a stable command id and delivering the external
outcome. The model deliberately does not force the older native-dice modules
to change their public contracts or relational persistence.
For a custom action with a daily quota, derive its descriptor from
DailyGameQuotaExecutionDescriptor. Override UserId and DailyQuotaId; the
descriptor delegates the schedule, limit and exemption rules to
IGameDailyQuotaPolicy. Returning null from DailyQuotaId opts out, while an
unlimited policy can still provide a zero-limit snapshot for actions that need
one. The default runtime policy is the separate
TelegramDiceGameDailyQuotaPolicy adapter for the existing native-dice
configuration; game code and the common model do not reference Telegram and
can use another policy unchanged.
New modules can opt into a GameDefinition manifest. It contains only stable,
transport-neutral metadata: game id and display name, supported execution
styles, stake limits, daily-quota id and named entropy values. Registering a
definition makes it available through IGameCatalog; Telegram, REST and
Discord adapters can use that catalog without a game depending on any of them.
GameCommandContext carries the repeated player/chat/idempotency fields, and
PlayerGameExecutionDescriptor maps it to the standard aggregate and wallet
identity. Existing command contracts keep working unchanged; this is an opt-in
shape for new games.
For a synchronous game, use InstantWagerDefinition: it validates its declared
stake limits and optional quota, debits the stake, resolves a deterministic
outcome from framework entropy, pays the result and emits
GameCompletedMetaEvent in the same atomic decision. The normalized completion
event feeds meta projections such as quests, achievements, clans and risk
without making the game depend on the Meta module.
services.AddInstantWagerGame(
new InstantWagerDefinition<CoinFlipGame, CoinSide>(
new GameDefinition(
"coin-flip",
"Coin flip",
GameCapabilities.InstantWager,
new GameStakeLimits(10, 1_000),
entropyNames: ["side"]),
static context => context.Entropy.GetDouble("side") < 0.5
? CoinSide.Heads
: CoinSide.Tails,
static (wager, side) => side == CoinSide.Heads ? wager.Amount * 2 : 0));The Host registers a stateless state store and
IInstantWagerGameExecutor<CoinFlipGame, CoinSide>. A module-specific service
only creates InstantWagerCommand<CoinFlipGame> with a GameCommandContext and
an amount. Deferred-outcome wagers now also expose and register a manifest, and
by default emit the same completion event when the external outcome settles.
For an action that is neither an instant nor a deferred wager, the Host offers small composition helpers instead of making every module repeat action, descriptor and state-store registration:
services.AddAtomicJsonGameAction<MoveCommand, MatchState, MoveAction, MoveResult, MoveDescriptor>();
// Or, for a stateless command:
services.AddAtomicStatelessGameAction<RollCommand, RollAction, RollResult, RollDescriptor>();AddAtomicGameAction is the equivalent helper when the module supplies its own
state-store implementation. AddAtomicTurnBasedGameAction remains available;
it is now the turn-based convenience wrapper over the same JSONB registration
and additionally registers the scheduled-command adapter.
IGameSessionService is a separate, opt-in durable flow layer for games that
need to pause and resume across messages, requests or workers. It records
Started, Suspended, Resumed, Completed and Failed lifecycle entries,
an optimistic revision, JSON payload and an idempotent correlation id for every
operation. GameId, OwnerId and ScopeId are opaque strings: a Telegram,
Discord or REST adapter maps its own identities at the edge, so neither game
code nor the session model depends on a channel.
var started = await sessions.StartAsync(
new GameSessionStartRequest("blackjack", "player:42", "table:main", correlationId), ct);
var suspended = await sessions.SuspendAsync(
new GameSessionTransitionRequest(started.SessionId, suspendCorrelationId, started.Revision), ct);
var resumed = await sessions.ResumeAsync(
new GameSessionTransitionRequest(suspended.SessionId, resumeCorrelationId, suspended.Revision), ct);Repeating an operation with the same correlation id returns its recorded
snapshot. GetByCorrelationAsync resolves either a root or later operation
correlation to the current session, which lets a new transport adapter resume a
flow without needing any Telegram-specific state.
GameCapabilities continues to describe execution style (InstantWager,
DeferredOutcomeWager, TurnBased). Runtime requirements are a separate,
opt-in GameCapabilitySet on GameDefinition. This prevents a game being
coupled to a frontend while still making its requirements explicit:
var game = new GameDefinition(
"story-blackjack",
"Story blackjack",
GameCapabilities.TurnBased,
requiredCapabilities:
[
EconomyCapabilities.Wallet,
SchedulingCapabilities.Timers,
MessagingCapabilities.Messages,
NarrativeCapabilities.Scenes,
]);The SDK exposes groups for EconomyCapabilities, PersistenceCapabilities,
SchedulingCapabilities, MessagingCapabilities, NarrativeCapabilities,
TurnBasedCapabilities and optional CasinoCapabilities. The Host already
provides the transactional economy/persistence/scheduling subset. A Telegram,
Web or test frontend declares the features it implements through
IGameRuntimeCapabilityProvider and registers an IGameEffectHandler for its
own semantic effects:
services.AddSingleton<IGameRuntimeCapabilityProvider>(
new StaticGameRuntimeCapabilityProvider(
[MessagingCapabilities.Messages, MessagingCapabilities.Input]));At action execution the Host rejects a manifest requirement the current runtime
does not provide, and rejects an emitted effect whose capability was not
declared by the game. Module-defined effects implement
IGameCapabilityEffect, which names the required feature. Built-in economy,
quota, record, event and scheduling effects are mapped automatically. This is
the stable core for frontend-owned effects.
BotFramework.Narrative supplies the transport-neutral effects SceneEffect,
DialogEffect, ChoiceEffect, NarrativeFlagEffect and
NarrativeCheckpointEffect. NarrativeText contains a localization key,
parameters and an optional fallback—not frontend markup. NarrativeAddress
uses opaque conversation and recipient ids, so games stay independent from
Telegram chat ids, browser connection ids and any other transport identity.
services.AddNarrativeEffectSink<WebNarrativeSink>();
var scene = new SceneEffect(
new NarrativeAddress("table:42", "player:7"),
"forest",
new NarrativeText("scene.forest.title"));BotFramework.Narrative.Host registers the PostgreSQL INarrativeProjectionStore
and migrations for flags, the latest checkpoint and the active choice. The
projection is keyed by NarrativeAddress and, when supplied by the Host, its
tenant/scope. It is applied before the frontend sink and de-duplicates an
at-least-once durable effect by its delivery id. A Web or Telegram sink now owns
only rendering and delivery; it can read the same projection to render a resumed
screen. AddNarrativeEffectSink<TSink>() enables this persistence by default,
or AddNarrativePersistence() registers it for a query-only frontend. A
frontend returns NarrativeChoiceSelection; the game maps that semantic input
to its own command. Neither package references a frontend implementation.
BotFramework.Presentation complements narrative with general-purpose,
transport-independent UI output: NotificationEffect, RichResultEffect,
InputFormEffect and MediaEffect. These are semantic effects, not Telegram
messages, HTML, browser components or direct media URLs. A frontend implements
IPresentationEffectSink through BotFramework.Presentation.Host:
services.AddPresentationEffectSink<WebPresentationSink>();
var request = new InputRequestEffect(
requestId: "bet:42",
expectedPlayerId: "player:7",
scopeId: "table:42",
route: "table.bet",
expiresAt: now.AddMinutes(1));
var form = new InputFormEffect(
new PresentationAddress("table:42", "player:7"),
request.RequestId,
new PresentationText("bet.title"),
[new PresentationInputField("amount", new PresentationText("bet.amount"), PresentationInputKind.Number)]);Emit the input request and form in one game decision. The Host stores the
request before commit; all presentation effects are durable and therefore
delivered from the transactional effect outbox after commit. A form submits a
JSON object through the existing GameInputSubmission path, which still owns
one-time consumption, player/scope checks, expiry and correlation. The game
route validates field values as untrusted input. MessagingCapabilities now
distinguishes ordinary messages, rich results, input and media.
InputRequestEffect is the transport-independent half of an interaction. It
binds an opaque request id to an expected player and scope, optional allowed
values, an expiry, a game route and optional GameSession. Emit it with the
matching ChoiceEffect; use the same request id as ChoiceEffect.InteractionId.
var input = new InputRequestEffect(
requestId: "forest:turn:17",
expectedPlayerId: "player:7",
scopeId: "table:42",
route: "story.choice",
expiresAt: now.AddMinutes(2),
allowedValues: ["follow", "leave"],
sessionId: sessionId);The Host writes the request in the game transaction. A transport converts its
callback or HTTP request into GameInputSubmission; IGameInputRequestDispatcher
atomically checks request/player/scope/value/expiry and calls the unique
IGameInputRouteHandler for (GameId, Route). Re-delivery with the same
correlation id calls the handler again, so the handler should use that id as
the game's normal command idempotency key.
IDurableGameEffect marks an external effect that must not be delivered before
commit. Narrative effects implement it by default. The Host stores them in
game_effect_outbox in the same transaction as state, input requests and
events. Its lease-based dispatcher deserializes the effect and invokes the
registered generic effect handler after commit; it retries failures and keeps
the effects of one command in original order. An adapter should therefore
implement an idempotent INarrativeEffectSink, but it need not own an outbox.
Role
Referenced by
BotFramework.Contracts
Transport-neutral service contracts, DTOs, wallet/identity/read ports and portable integration contracts
hosts, services, transport adapters, game contracts
BotFramework.Sdk
Module-facing abstractions: modules, domain/events, repositories, projections, analytics, localization, runtime contracts
backend Games.* modules
BotFramework.Sdk.Testing
xUnit helpers and in-memory test doubles
tests/CasinoShiz.Tests
BotFramework.Host
Backend/runtime infrastructure: composition, persistence, CAP/event bus, economics/wallet adapters, analytics, runtime jobs, admin/security, health
backend/monolith composition roots
BotFramework.Telegram.Abstractions
Telegram update context, routes and handler contracts
Telegram adapters and runtime
BotFramework.Scheduling.Abstractions
Transport-neutral scheduled command contracts
game application layers
BotFramework.Scheduling.Quartz
Persistent Quartz implementation of game scheduling
backend composition roots
BotFramework.Rendering
Bounded TPL render queues, content-addressed artifact cache, MinIO media history and admin read endpoints
backend, Telegram adapters and composition roots
BotFramework.Telegram
Telegram adapter runtime: bot client, polling/webhook ingress, update pipeline, router, route attributes, Telegram update context and delivery helpers
Telegram BFF, monolith compatibility host, Games.*.Telegram adapters
BotFramework.Discord
Discord adapter runtime: gateway ingress, message and interaction routing, slash commands, autocomplete, modals, component tokens, embeds, localization, UX rate limiting/cooldowns, health checks and delivery outbox
Discord BFF and Games.*.Discord adapters
The intended dependency direction is:
Games.* backend modules
-> BotFramework.Sdk
-> BotFramework.Contracts
Games.*.Telegram adapters
-> BotFramework.Telegram
-> BotFramework.Contracts / game contracts
Games.*.Discord adapters
-> BotFramework.Discord
-> BotFramework.Contracts / game contracts
Composition roots
-> BotFramework.Host
-> BotFramework.Telegram and/or BotFramework.Discord when channel ingress is enabled
Modules should not reference deployment-specific hosts. Hosts select which modules and transports are active.
framework/
BotFramework.Contracts/ transport-neutral contracts and portable DTOs
BotFramework.Sdk/ module, domain, persistence and event abstractions
BotFramework.Sdk.Testing/ test helpers and in-memory doubles
BotFramework.Host/ backend infrastructure and composition
BotFramework.Telegram.Abstractions/ Telegram update contracts
BotFramework.Scheduling.Abstractions/ scheduled command contracts
BotFramework.Scheduling.Quartz/ persistent Quartz scheduler
BotFramework.Rendering/ bounded rendering runtime and artifact/history ports
BotFramework.Telegram/ Telegram ingress, update routing and delivery
BotFramework.Discord/ Discord ingress, interactions, UX and delivery
games/
Games.X.Contracts/ logical interfaces and portable DTOs
Games.X/ backend application/domain/infrastructure
Games.X.Telegram/ Telegram presentation adapter
Games.X.Discord/ Discord presentation adapter
Games.X.Transport.Grpc/ protobuf and remote adapters when needed
host/
CasinoShiz.Host/ combined compatibility deployment
CasinoShiz.Backend/ Telegram-free backend process
CasinoShiz.TelegramBff/ Telegram client process
CasinoShiz.DiscordBff/ Discord client process
CasinoShiz.AdminBff/ browser/admin BFF without direct database access
services/
CasinoShiz.IdentityService/
CasinoShiz.WalletService/
tests/
CasinoShiz.Tests/
Not every context needs every optional project. Simple modules may only have Games.X and Games.X.Telegram. Split-service modules may additionally provide Games.X.Contracts and Games.X.Transport.Grpc.
The following map describes the checked-in source layout of framework/.
Build output (bin/, obj/) is intentionally omitted. The project name and
namespace do not always have the same suffix: for example, the source project
BotFramework.Sdk.Testing produces the package BotFramework.Testing.
framework/
├── BotFramework.Contracts/ # public, transport-neutral contracts
│ ├── Caching/ # cache ports and cache metadata
│ ├── Cases/ # durable human-review case contracts
│ ├── Economics/ # wallet and economic service contracts
│ ├── Games/ # portable game-facing contracts
│ ├── Identity/ # player and identity contracts
│ ├── Messaging/ # channel and request metadata
│ ├── Observability/ # meters and telemetry contracts
│ ├── Operations/ # operational/service contracts
│ ├── Ledger/ # hold/capture/release/refund contracts
│ ├── RateLimiting/ # limiter decisions and policies
│ ├── ResponsibleGaming/ # protection and player-stat contracts
│ ├── Risk/ # policy decisions and evaluator ports
│ ├── Tenancy/ # opaque ids and tenant context
│ └── Transport/ # pagination and wire-level contracts
├── BotFramework.Sdk/ # pure module and game abstractions
│ ├── Admin/ # admin commands and effects
│ ├── Commands/ # command and request contracts
│ ├── Configuration/ # neutral options/validation contracts
│ ├── Domain/ # aggregates, rules and state primitives
│ ├── Events/ # domain events and event contracts
│ ├── Execution/ # actions, decisions and effects
│ ├── Health/ # health/readiness abstractions
│ ├── Metrics/ # SDK-level telemetry abstractions
│ ├── MiniGames/ # mini-game contracts
│ ├── Modules/ # module registration contracts
│ ├── Projections/ # projection/read-model contracts
│ └── Snapshots/ # snapshot contracts
├── BotFramework.Sdk.Testing/ # test doubles for SDK consumers
│ ├── Fakes/
│ └── Repositories/
├── BotFramework.Text/ # platform-neutral text processing
│ ├── normalization and tokenization primitives
│ ├── analyzer, matcher and policy contracts
│ ├── effect contracts and dispatch
│ └── immutable compiled-snapshot boundaries
├── BotFramework.Rest/ # REST middleware and route support
│ └── RateLimiting/ # REST limiter options and adapter
├── BotFramework.Client/ # typed REST client package
│ ├── Generated/ # NSwag-generated client; do not edit
│ ├── openapi-v1.json # checked-in source OpenAPI contract
│ └── openapi-client.nswag.json # NSwag generation configuration
├── BotFramework.Telegram.Abstractions/ # transport-facing Telegram contracts
│ ├── MiniGames/
│ ├── Pipeline/
│ ├── Tenancy/
│ └── UpdateHandling/
├── BotFramework.Discord.Abstractions/ # transport-facing Discord contracts
├── BotFramework.Host/ # backend runtime and infrastructure
│ ├── Admin/ # admin effects and execution
│ ├── Analytics/ # analytics/query integrations
│ ├── Caching/ # cache implementations
│ ├── Cases/ # durable case aggregate adapter
│ ├── Commands/ # command middleware and dispatch
│ ├── Composition/ # Host builders and migrations
│ ├── Configuration/ # runtime configuration
│ ├── Contracts/ # internal Host contracts
│ ├── DiscordOutbox/ # Discord delivery outbox
│ ├── Economics/ # wallet/economy implementations
│ ├── Events/ # event bus and outbox dispatch
│ ├── Execution/ # atomic game execution pipeline
│ ├── Fairness/ # fairness and entropy services
│ ├── Games/ # game runtime composition
│ ├── Health/ # database/dependency health checks
│ ├── Localization/ # backend localization services
│ ├── Ledger/ # generic ledger operation adapter
│ ├── Messaging/ # request/transport support
│ ├── Persistence/ # PostgreSQL stores and migrations
│ ├── Random/ # framework entropy providers
│ ├── RateLimiting/ # Redis/DB limiter implementations
│ ├── Redis/ # Redis infrastructure
│ ├── Runtime/ # hosted services and lifecycle
│ ├── Security/ # auth and authorization
│ ├── TelegramOutbox/ # Telegram delivery outbox
│ ├── Tenancy/ # tenant provisioning and resolution
│ └── Workflows/ # durable command workflows, steps and replay
├── BotFramework.Telegram/ # Telegram adapter runtime
│ ├── Composition/ # Telegram builder extensions
│ ├── Hosting/ # hosted polling/webhook services
│ ├── Outbox/ # Telegram delivery workers
│ ├── Pipeline/ # update middleware
│ ├── Text/ # text pipeline adapter and opt-in handlers
│ └── Redis/ # Telegram adapter state
├── BotFramework.Discord/ # Discord adapter runtime
│ ├── Commands/ # slash/message command handling
│ ├── Composition/ # Discord builder extensions
│ ├── Hosting/ # gateway and hosted services
│ ├── Interactions/ # buttons, selects, modals and tokens
│ └── Routing/ # message and interaction routing
├── BotFramework.Scheduling.Abstractions/ # transport-neutral schedule contracts
├── BotFramework.Scheduling.Quartz/ # persistent Quartz implementation
└── BotFramework.Rendering/ # bounded rendering and media history
| Need | Project/location | Boundary rule |
|---|---|---|
| Public DTO, opaque id, tenant or pagination contract | BotFramework.Contracts |
No database, transport SDK or Host dependency |
| Pure game action, domain rule, effect or module contract | BotFramework.Sdk |
Synchronous decision code must remain deterministic and I/O-free |
| Consumer test fake or in-memory repository | BotFramework.Sdk.Testing |
Keep test-only helpers out of runtime packages |
| REST middleware, route metadata or typed HTTP client | BotFramework.Rest / BotFramework.Client |
REST details must not leak into game/domain contracts |
| Telegram/Discord-specific contract | BotFramework.*.Abstractions |
Keep channel types out of the neutral SDK |
| Ingress, routing, presentation or delivery | BotFramework.Telegram / BotFramework.Discord |
Adapters call logical module contracts; they do not own persistence |
| PostgreSQL, Redis, outbox, migrations or atomic execution | BotFramework.Host |
Infrastructure is selected by a composition root |
| Long-running command workflow, durable retry or operator replay | BotFramework.Host.Workflows |
Modules provide commands/handlers; Host owns Wolverine and workflow persistence |
| Durable scheduling | BotFramework.Scheduling.* |
Quartz is an implementation, not a module-facing contract |
| GIF/PNG rendering and artifact history | BotFramework.Rendering |
Rendering runs after commit and never participates in the game transaction |
Keep public package dependencies pointed inward:
BotFramework.Contracts
↑
BotFramework.Sdk ────────┐
↑ │
Abstractions ────────────┤
↑ │
Host ───┴── Telegram / Discord / Rest / Client adapters
BotFramework.Client/Generated/ is regenerated from openapi-v1.json; change
the OpenAPI document or NSwag settings first and run the documented generation
command. Do not hand-edit generated files. New framework projects should also
be added to CasinoShiz.slnx, package smoke validation, public API manifests
and this map when they become part of the supported surface.
┌────────────────────────────────────────────────────────────────────────────┐
│ L5 Presentation adapters │
│ games/*/*.Telegram, BotFramework.Telegram │
│ games/*/*.Discord, BotFramework.Discord │
│ channel parsing, embeds, modals, callbacks and interactions │
├────────────────────────────────────────────────────────────────────────────┤
│ L4 Application │
│ games/*/Application │
│ services, commands, jobs, projections, use-case result records │
├────────────────────────────────────────────────────────────────────────────┤
│ L3 Domain │
│ games/*/Domain │
│ pure domain transitions, aggregates, policies, state machines │
├────────────────────────────────────────────────────────────────────────────┤
│ L2 Platform contracts │
│ BotFramework.Contracts + BotFramework.Sdk │
│ modules, repositories, event store, projections, event bus, ports │
├────────────────────────────────────────────────────────────────────────────┤
│ L1 Infrastructure │
│ games/*/Infrastructure + BotFramework.Host + transport projects │
│ Postgres, Redis/CAP, ClickHouse, gRPC, migrations, jobs, admin, ops │
└────────────────────────────────────────────────────────────────────────────┘
BotFramework.Telegram and BotFramework.Discord are intentionally not part
of the backend SDK. Channels are adapter boundaries, not domain dependencies.
Game backend modules use a consistent directory shape:
games/Games.X/
Application/
Services/ Jobs/ Projections/ Results/ Analytics/
Domain/
Configuration/ Commands/ Entities/ Events/ Rules/ Results/
Infrastructure/
Persistence/ Migrations/ Modules/ Rendering/ Integrations/ Queues/
Telegram adapters live separately:
games/Games.X.Telegram/
Handlers/
Rendering/
CallbackData/
Modules/
Transport adapters live separately when the module can run across a process boundary:
games/Games.X.Transport.Grpc/
Protos/
Clients/
Servers/
Mapping/
BotFramework.Host is feature-first:
Admin/ Analytics/ Commands/ Composition/ Contracts/ Economics/ Events/
Health/ Localization/ Persistence/ Random/ Redis/ Runtime/ Security/
BotFramework.Telegram owns Telegram-specific runtime concerns:
Composition/ Ingress/ Pipeline/ Routing/ UpdateHandling/ Delivery/ Redis/
BotFramework.Sdk is also feature-first and keeps module-facing namespaces stable:
Admin/ Commands/ Configuration/ Domain/ Events/ Health/ Metrics/
MiniGames/ Modules/ Pipeline/ Projections/ Snapshots/
For the system-level container, transaction and concurrency diagrams, see
docs/arch.md.
Games that mutate wallets or persistent state should implement a pure
IGameAction<TCommand, TState, TResult>. The action receives materialized state,
wallet/quota snapshots, framework-provided entropy and UTC time. Decide performs
no I/O and returns one complete GameDecision.
transport request
-> command envelope / idempotency / sorted advisory locks
-> load snapshots
-> game.Decide(input)
-> materialize and validate GameEffectPlan
-> apply effects in deterministic transaction phases
-> inbox result + commit
-> asynchronous outbox delivery
Declarative consequences are split into executable effects and domain events.
IDomainEvent is intentionally not an IGameEffect: events are persisted and
dispatched by the event pipeline, while IGameEffect values are handled by
the effect executor. Built-in categories are deliberately explicit:
EconomyEffectdebits or credits the command wallet;WalletEconomyEffecttargets an explicitly declared wallet in a multi-wallet command;QuotaEffectconsumes, restores, or grants capacity on a declared quota;IGameRecordwrites module-specific history through a registered writer;IDomainEventis persisted to the transactional event outbox;ScheduleEffectschedules or cancels a durable command through the schedule outbox.
Not every game command needs the economy boundary. IGameStateExecutor<TCommand, TState,TResult> uses the same command envelope, idempotency inbox, aggregate
locks, revision checks, PostgreSQL transaction and post-commit outboxes as the
atomic executor, but does not load or mutate a wallet, quota, or player-protection
state. Its effect pipeline rejects typed economy/quota effects and wallet custom
effects. State-only execution is still transactional for the game aggregate; it
is not fire-and-forget background work.
Use the executors according to the boundary the command actually needs:
| Command needs | Executor | Commit boundary |
|---|---|---|
| Game state, wallet, quota, protection, or legacy payout/refund in one decision | IAtomicGameExecutor<,,> |
Game state + economic effects + inbox/outbox |
| Game state, records, domain events or local schedules only | IGameStateExecutor<,,> |
Game state + inbox/outbox |
| Wagered game play after a reservation | IOutcomeOnlyGameExecutor<,,> over the state-only executor |
Game outcome first; Wagering settles economics later |
The wager path deliberately keeps stake, balance and payout out of the game aggregate:
Ledger reserve
-> Wagering sends game command
-> game commits state and publishes GameOutcomeDeclared
-> Wagering validates terms and applies the game payout policy
-> Ledger settles or releases the reservation
The outcome-only adapter is used by the generic wager handler and by specialized Blackjack, Horse, Poker and Secret Hitler wager slices. Challenge and Pick also provide multi-party wager adapters. The game owns rules and deterministic outcome state; Wagering owns the frozen terms snapshot, payout policy and settlement operation ids.
The repository keeps legacy Atomic paths where they are still the public mode or
where a command performs dynamic economics, multi-wallet refunds, quotas or
legacy payout. For example, the old BlackjackGameState flow remains Atomic while
the separate BlackjackWagerState flow is outcome-only. Pure state-only commands
such as Poker start/message, Secret Hitler start/nominate/discard/messages,
Challenge decline, Redeem issue and PixelBattle updates use IGameStateExecutor.
The framework exposes a domain-neutral operations layer in addition to the Wagering compatibility contracts:
BotFramework.Contracts.LedgerprovidesHold,Capture,Release,Refund,Transferand auditedAdjustmentcommands plus the commonLedgerOperationCompletedevent. Hold/capture/release are separate from payout transfers, and refunds are bounded by captured amount.BotFramework.Contracts.RiskprovidesIRiskEvaluator<TContext>andRiskDecision(Allow,Deny,Review,Hold). Policies stay in the application; the framework carries policy version, evidence and workflow refs.BotFramework.Contracts.Casesprovides the reusableOpen -> Evidence -> Review -> Resolved -> Appealedlifecycle and integration commands/events.BotFramework.Hostpersists it in Postgres and publishes transitions through the normal outbox.
The local Postgres Ledger adapter is idempotent by operation id and uses the same tenant wallet transaction boundary as the existing wallet implementation. A remote Ledger or Case service can consume these contracts without changing game code. The single-player and multi-party wager slices now compose Hold/Capture/Release/Transfer; the old wager-specific messages remain only as a drain-compatible handler for already persisted outbox messages.
The admin BFF exposes the read-only wager workflow timeline by operation id. It
joins the wager status with committed module_events (ES) and generic
ledger_operations in one read query. No timeline write is added to a game
commit, so observability does not add a per-step round trip to the hot path.
Admin mutations use the smaller companion kernel. An admin action produces an
AdminEffectPlan<TResult> containing typed IAdminEffect values. The Host opens
one transaction, resolves exactly one handler for every effect type, applies the
finite materialized list in order, appends admin_audit, and commits. Handlers get
the restricted IAdminExecutionContext, not NpgsqlConnection or a transaction.
Runtime configuration is the first migrated effect (RuntimeConfigurationPatchEffect),
so the JSONB update and its audit record cannot commit separately.
The same kernel now covers the administrative write paths for Users, Ledger, Games.Admin, and the Meta season/quest/alert pages:
| Area | Typed effects | Transactional consequences |
|---|---|---|
| Users | WalletAdjustmentAdminEffect |
wallet row, version, ledger line and admin_audit |
| Ledger | LedgerRevertAdminEffect |
locked source line, compensating wallet/ledger update and audit |
| Games.Admin | ClearChatBetsAdminEffect, DisplayNameOverrideAdminEffect |
pending-bet deletion/refund or override upsert/delete and audit |
| Meta seasons | MetaSeason*AdminEffect |
season status/config/plans, reward credits, meta_event_log and audit |
| Meta alerts | MetaAlertStatusAdminEffect |
risk flag state, meta_event_log and audit |
| Meta quests | MetaQuestCatalogSaveAdminEffect, MetaQuestCatalogReloadAdminEffect |
validated catalog replacement/reload and audit |
Pages keep their existing read models and response contracts. Only the mutation boundary goes through the executor, so the monolith and split BFF paths share the same effect handlers. Reward effects use deterministic operation ids; retrying a season payout therefore cannot create a second ledger entry. The clear-bets effect refunds and removes all supported mini-game rows in the same PostgreSQL transaction, then clears process/distributed session state after commit.
The quest catalog is currently file-backed for compatibility. Its effect writes a temporary file and atomically replaces the catalog before recording the audit row; the filesystem replacement cannot be rolled back by PostgreSQL. A future DB/object store catalog can use the same effect contract for full database atomicity.
Not every mutation starts as a pure game decision. Quest progress/claims, tournament
lifecycle commands and season settlement use IAtomicEffectExecutor with the same finite,
typed effect discipline. A module registers one AtomicEffectHandler<T> per effect;
the Host supplies only a restricted SQL context, takes the declared locks, checks
the inbox, applies all effects, stores the result and commits once. These handlers
do not receive NpgsqlConnection, a transaction, or a service locator. Deterministic
operation ids make wallet rewards safe to retry.
Quartz remains the trigger and durable schedule store, not the business transaction
boundary. ScheduleExecutionPolicy makes background semantics explicit:
Misfire:FireOnce,Ignore, orDoNothing;Concurrency:Disallow(the default) orAllow;BatchSize: a bounded amount of work passed asbatch-sizeto the command;MaxAttemptsandRetryBackoff: bounded command-level retries.
The scheduler maps these values to Quartz trigger/job settings. The scheduled command itself still enters the atomic/effect executor, so a retry or a process restart cannot partially settle a season. Heavy rendering and analytics remain post-commit consumers; they must not be hidden inside a scheduled effect handler.
Modules register tunable options and their validator explicitly:
services.BindOptions<HorseOptions, HorseOptionsValidator>(HorseOptions.SectionName);
public sealed class HorseOptionsValidator
: FluentConfigurationValidator<HorseOptions>
{
public HorseOptionsValidator()
{
RuleFor(x => x.HorseCount).InclusiveBetween(2, 16);
RuleFor(x => x.TimezoneOffsetHours).InclusiveBetween(-14, 14);
}
}IConfigurationValidator<TOptions> lives in BotFramework.Sdk and depends only
on neutral validation result types. FluentConfigurationValidator<TOptions> is a
Host adapter; modules may use it without coupling their domain contracts to
FluentValidation. The same validator runs at startup (ValidateOnStart) and for
admin preview/apply. Runtime JSON is deserialized strictly: unknown sections,
unknown nested properties, invalid shapes, and semantic rule failures are rejected
with structured path, code, and message issues. A valid patch is normalized,
previewed as effective typed options, and only then handed to the admin effect
executor.
The SDK portion of the system has no database, DI, transport, outbox, or async dependency. A game action is a synchronous function from an input snapshot to a decision:
public interface IGameAction<TCommand, TState, TResult>
{
GameDecision<TState, TResult> Decide(
GameActionInput<TState, TCommand> input);
}GameActionInput contains the command, loaded state, primary-wallet snapshot,
declared quota snapshots, named entropy and the framework UTC timestamp. The
action must not read the clock, generate randomness, resolve services, query a
database, publish messages, or start background work.
public sealed class CoinFlipAction
: IGameAction<CoinFlipCommand, NoGameState, CoinFlipResult>
{
public GameDecision<NoGameState, CoinFlipResult> Decide(
GameActionInput<NoGameState, CoinFlipCommand> input)
{
if (input.Wallet.Balance < input.Command.Stake)
return new(
DecisionStatus.Rejected,
input.State,
CoinFlipResult.InsufficientBalance,
[], [], [], [], [],
RejectionReason: "insufficient_balance");
var won = input.Entropy.GetDouble("coin-flip") >= 0.5;
var economy = won
? new[] { EconomyEffect.Debit(input.Command.Stake, "coinflip.stake"),
EconomyEffect.Credit(input.Command.Stake * 2, "coinflip.win") }
: new[] { EconomyEffect.Debit(input.Command.Stake, "coinflip.stake") };
return new(
DecisionStatus.Accepted,
input.State,
new CoinFlipResult(won),
economy,
[],
[],
[new CoinFlipCompleted(input.Command.UserId, won, input.UtcNow)],
[]);
}
}The descriptor supplies infrastructure metadata without putting it in the domain command: game id, command id, aggregate id, wallet identity, quota identities, entropy names, and additional lock keys. Lock keys are acquired in stable sorted order.
public sealed class CoinFlipDescriptor
: GameExecutionDescriptor<CoinFlipCommand, NoGameState, CoinFlipResult>
{
public override string GameId => "coinflip";
public override string CommandId(CoinFlipCommand command) => command.CommandId;
public override string AggregateId(CoinFlipCommand command) =>
$"{command.ChatId}:{command.UserId}";
public override long ChatId(CoinFlipCommand command) => command.ChatId;
public override string DisplayName(CoinFlipCommand command) => command.DisplayName;
public override WalletIdentity Wallet(CoinFlipCommand command) =>
new(command.UserId, command.ChatId);
public override IReadOnlyList<string> EntropyNames => ["coin-flip"];
}Register the action, descriptor and state store explicitly in the backend module. This keeps discovery deterministic and avoids a reflection DSL:
services
.AddScoped<IGameAction<CoinFlipCommand, NoGameState, CoinFlipResult>,
CoinFlipAction>()
.AddScoped<GameExecutionDescriptor<CoinFlipCommand, NoGameState, CoinFlipResult>,
CoinFlipDescriptor>()
.AddScoped<IGameStateStore<CoinFlipCommand, NoGameState>,
CoinFlipStateStore>();The compatibility application service depends on
IAtomicGameExecutor<CoinFlipCommand,NoGameState,CoinFlipResult> and delegates
the complete mutation path to it. Transport handlers continue to depend on the
existing logical game contract.
| Effect | Target | Transactional behavior |
|---|---|---|
EconomyEffect |
Descriptor primary wallet | Balance check, wallet update and ledger row; protected wager debits pass player-protection checks |
WalletEconomyEffect |
Explicit (userId, balanceScopeId) |
Multi-wallet balance update and ledger row; every wallet lock must be declared by the descriptor |
QuotaEffect.Consume |
Declared quota | Increases used capacity and rejects overflow |
QuotaEffect.Restore |
Declared quota | Returns consumed capacity, clamped at zero |
QuotaEffect.Grant |
Declared quota | Adds future capacity and may move usage below zero |
IGameRecord |
Module history table | Written by exactly one registered typed record writer |
custom IGameEffect |
Module-owned state | Applied by exactly one typed handler through IGameExecutionContext |
IDomainEvent |
Game event outbox | Written once per command/event index and delivered after commit |
ScheduleEffect |
Schedule outbox | Durable schedule/cancel request delivered after commit |
WalletEconomyEffect is intentionally separate from primary-wallet
EconomyEffect. It supports transfers, challenge stakes, race payouts, and
other multi-wallet decisions. It enforces non-negative balances and ledger
consistency, but primary-wallet player-protection checks are not implicitly
reapplied to arbitrary target wallets. A module introducing protected
multi-wallet wager debits must model and test that policy explicitly.
GameEffectSet.MaterializeEffects() materializes executable categories before
the first mutation; its Events collection remains separate. GameEffectPlan
rejects null effects, mutations on rejected decisions, unknown
quotas, missing or duplicate handlers, and built-in effects placed in the
custom category. Effects are not represented by IAsyncEnumerable: laziness
would make the transaction boundary and retry semantics depend on enumeration
progress.
The Host applies effects in a fixed order inside one PostgreSQL transaction:
player protection
-> economy
-> quotas
-> aggregate state
-> module records
-> custom typed effects
-> event outbox
-> schedule outbox
-> command inbox result
-> commit
Economy ledger rows, events and schedules use batch SQL. Quota and custom effects are grouped once while building the plan. PostgreSQL commands on one connection are still awaited sequentially; parallel commands on the same transaction are not supported by Npgsql.
Modules can define additional typed effects without receiving the connection or transaction directly:
public sealed record AchievementEffect(
long UserId,
string AchievementId) : IGameEffect;
public sealed class AchievementEffectHandler
: GameEffectHandler<AchievementEffect>
{
protected override Task ApplyBatchAsync(
IReadOnlyList<AchievementEffect> effects,
IGameExecutionContext context,
CancellationToken ct) =>
context.ExecuteAsync(
"""
INSERT INTO achievements (user_id, achievement_id)
SELECT item.user_id, item.achievement_id
FROM unnest(@UserIds, @AchievementIds)
AS item(user_id, achievement_id)
ON CONFLICT DO NOTHING
""",
new
{
UserIds = effects.Select(effect => effect.UserId).ToArray(),
AchievementIds = effects.Select(effect => effect.AchievementId).ToArray(),
},
ct);
}Register the handler explicitly:
services.AddAtomicGameEffectHandler<AchievementEffectHandler>();Then include materialized instances in GameDecision.CustomEffects. Handlers of
the same effect type receive one batch. Their Order values and effect type names
provide stable ordering. A handler failure rolls back every earlier mutation,
including wallet, quota, state, records and outbox rows.
External work that must not affect the game commit belongs behind a committed domain event/outbox consumer. Analytics, Telegram delivery and other remote calls must not be implemented as transactional custom effect handlers.
The framework exposes BotFramework.Host.Workflows as a reusable long-running
workflow primitive. A module implements immutable commands with
IDurableWorkflowCommand, registers its handler assembly, and calls:
builder.AddDurableWorkflows(typeof(MyWorkflowHandler).Assembly);The framework owns PostgreSQL-backed durable command delivery, correlation,
partitioning, retries, step persistence, replay and generic saga state. The module
uses IDurableWorkflowDispatcher at its transport/application boundary and
IDurableWorkflowStepExecutor inside a command handler. Domain mutation remains in
the module's own AtomicEffect/transaction boundary.
durable_workflow_steps stores workflow/command/causation ids, command type,
original command_json, payload, result, status and terminal marker. It is an
operator-visible timeline and recovery projection, not the source of domain truth.
IDurableWorkflowReplayService requeues a failed or non-terminal command while
keeping its original command id, so application inboxes and external operation-id
idempotency can protect against duplicate mutations.
The smallest consumer example is
samples/CoinFlip/CoinFlip.Workflow/CoinFlipWorkflow.cs. It shows the command,
handler and transport/application dispatcher boundary without importing any
game-specific workflow code into the framework.
Meta's tournament workflow is the first consumer. Its Wallet mutations are workflow
steps, outside the local Backend transaction:
join entry fees, final prizes and cancellation refunds use stable Wallet operation
ids. Definitive local rejection triggers a compensating .rollback mutation. A
process/network failure is retried durably with the same command and operation ids;
the client may receive Pending while that retry continues. The existing Telegram
and Discord client outboxes remain the delivery mechanism for notifications, and
the CAP domain-event outbox remains unchanged.
The framework owns the integration transport boundary. Modules publish typed contracts and implement handlers; they do not instantiate CAP, Kafka clients, Wolverine or a shared database connection. Register the boundary once in the composition root:
builder.AddFrameworkIntegrationMessaging("wallet");
builder.AddDurableWorkflows(typeof(BetWorkflowHandler).Assembly);AddFrameworkIntegrationMessaging creates the PostgreSQL integration inbox and
outbox, schema quarantine store, route resolver and the selected CAP transport.
The service name becomes the consumer group, so replicas of one logical service
share delivery while different services receive their own copy of events. Use
Local for in-process tests/development, Redis for Redis Streams, or Kafka
for Kafka/Redpanda:
{
"Messaging": { "Transport": "Kafka" },
"Kafka": {
"Servers": "redpanda:9092",
"MainConfig": {
"security.protocol": "SASL_SSL"
}
}
}Kafka:BootstrapServers is accepted as an alias for Kafka:Servers. Broker
credentials and TLS settings belong in deployment secrets or provider
configuration; they are not part of module code.
Events implement IIntegrationEvent; addressed requests implement
IIntegrationCommand. Prefer the typed publishers:
public sealed record FundsReserved(
string BetId,
long Amount,
DateTimeOffset OccurredAt) : IIntegrationEvent
{
public string EventType => "wallet.funds_reserved";
}
await eventPublisher.PublishAsync(
new FundsReserved(betId, amount, clock.UtcNow), ct);
await commandPublisher.SendAsync(
new ReserveFundsForBet(betId, amount, clock.UtcNow), ct);The publisher creates a stable envelope containing message id, contract type,
schema version, correlation/causation metadata and tenant/scope/player
metadata. It writes the complete envelope to integration_outbox_messages;
the relay later publishes that retained envelope. Domain code is therefore not
run again when a relay retries a message.
When a publisher is called while the framework integration inbox is processing a message, its outbox row uses the same PostgreSQL connection and transaction. The consumed message, local domain mutation and outgoing message then commit or roll back together. A lower-level adapter that already has an outbox record can make the boundary explicit:
await outbox.EnqueueAsync(
message,
IntegrationTransactionContext.From(inboxContext),
ct);Use IIntegrationInboxContextAccessor.Current when the handler needs the
ambient context. Do not open a second connection for a mutation that must be
atomic with inbox completion.
IIntegrationMessageRouter supplies a default route based on message kind,
tenant, scope and aggregate/player identity. The route is persisted with the
outbox envelope, and the message key is forwarded to Kafka/Redpanda as
cap-kafka-key. This preserves ordering for one aggregate without using a
single global partition key.
A contract may override the route for addressable bounded-context delivery:
public sealed record ReserveFundsForBet(
string BetId,
long Amount,
DateTimeOffset OccurredAt) : IIntegrationCommand, IIntegrationMessageRouted
{
public string CommandType => "wallet.reserve_funds";
public string? Topic => "wallet.commands.v1";
public string? MessageKey => $"bet:{BetId}";
}Commands should use an explicit owning-service topic. Events may use a bounded-context event topic and multiple consumer groups. A command must not be broadcast to unrelated services; the receiving service remains responsible for authorization and idempotent handling.
Every integration consumer is at-least-once. The framework wraps a resolved
handler in IIntegrationInbox.ExecuteOnceAsync. The inbox key is the logical
consumer plus tenant/scope/message id. A duplicate does not invoke the handler
again; a completed result is replayed from the inbox.
Handlers that write service-owned state should use the transaction supplied by
the framework's repositories or by IntegrationInboxContext:
public async Task HandleAsync(
ReserveFundsForBet command,
CancellationToken ct)
{
var inbox = inboxContextAccessor.RequireCurrent();
await walletStore.ReserveAsync(
command.BetId,
command.Amount,
inbox.Connection,
inbox.Transaction,
ct);
await eventPublisher.PublishAsync(
new FundsReserved(command.BetId, command.Amount, clock.UtcNow), ct);
}The handler must only mutate its own service database. Cross-service effects are integration commands/events and are committed to the local outbox; there is no distributed transaction.
Before handler resolution, the dispatcher validates the schema version, stable
contract type, JSON payload and the payload's declared message type. The
current supported envelope schema is version 1. Invalid or unsupported
messages are written to the service-owned integration quarantine table with a
stable error code and acknowledged, preventing an infinite retry loop.
Quarantine is an operational safety boundary, not a successful business
transition. Operators must inspect the payload and either deploy a compatible
consumer, replay a corrected message, or move the owning workflow to
ManualReview according to its domain policy. Financial quarantine must never
silently mark a bet or settlement as completed.
Long-running workflows keep their saga state in the owning module and use the generic framework recovery service for timeout delivery:
await recovery.ScheduleTimeoutAsync(
new DurableWorkflowTimeoutRequest(
TimeoutId: $"bet:{betId}:acceptance-timeout",
WorkflowId: $"bet:{betId}",
CommandId: $"bet:{betId}:acceptance-timeout:v1",
Operation: "bet.acceptance_timeout",
DueAt: clock.UtcNow.AddMinutes(2),
Command: new AcceptanceTimeout(betId),
AggregateId: betId,
MaxAttempts: 10),
transaction: IntegrationTransactionContext.From(inboxContext),
ct: ct);
await recovery.CancelTimeoutAsync(
$"bet:{betId}:acceptance-timeout", ct);IDurableWorkflowRecoveryService persists timeout rows in
durable_workflow_timeouts, claims them with leases and SKIP LOCKED, sends
the immutable command through Wolverine, retries failures with bounded
backoff, and exposes timeout inspection by workflow id. Schedule a timeout in
the same local transaction as the state transition that enters the waiting
phase. Cancel it when that phase completes. Timeout handlers must verify the
current saga phase and remain idempotent because delivery is at-least-once.
Enable ownership checks after provisioning a dedicated PostgreSQL role for the service:
{
"ServiceOwnership": {
"Enforce": true,
"Schema": "wallet",
"ExpectedDatabase": "wallet",
"RequireNonSuperuser": true,
"RequireSchemaCreate": true
}
}At startup the framework verifies the current database, schema existence,
USAGE/CREATE privileges and that the runtime role is neither a superuser
nor BYPASSRLS. A violation fails readiness/startup and increments
service.ownership.violations. Enforce is false by default to preserve
existing deployments during migration; it should be enabled per service once
the ownership boundary is ready.
The framework exposes counters for outbox enqueue/publish/failure/depth, inbox duplicates/result replays/handler failures, schema rejection/quarantine, workflow timeout schedule/dispatch/retry and ownership violations. The metrics contain service and route dimensions only; tenant, scope and player identifiers remain in logs/traces.
IGameStateStore<TCommand,TState> loads and saves module state through the
limited IGameExecutionContext. It never owns the transaction. The framework
also provides a JSON aggregate store for ordinary versioned state; modules may
provide transaction-aware stores for existing normalized tables.
Choose the smallest aggregate key that protects a real invariant:
- user game:
game:{chatId}:{userId}; - turn-based table:
table:{inviteCode}; - multi-wallet operation: one aggregate plus every affected wallet lock;
- realtime board:
tile:{index}, so different cells remain parallel; - batch settlement: race/table id plus the complete declared payout-wallet set.
Commands with the same aggregate key execute sequentially. Commands with different aggregate keys can run concurrently unless they share another declared lock such as a wallet or quota. Do not use a global game lock merely because the game has a global read model.
Turn-based games can use the SDK types in Execution/TurnBased for common
revision, actor, turn and terminal-state validation. They still emit a normal
materialized GameDecision; there is no task tree or hidden saga.
For a conventional multiplayer rotation, TurnEngine<TPlayerId> is the
opt-in state machine above that validation. It owns only generic mechanics:
admission before start, declared cyclic order, current-player rotation, round
and turn numbers, deadline-safe timeout handling, suspension/resumption and a
player leaving. A game embeds TurnEngineState<TPlayerId> in its own versioned
state and continues to own board rules, scoring, win conditions and any wallet
effects.
var turns = new TurnEngine<PlayerId>();
var started = turns.Start(
turns.CreateWaiting([firstPlayer, secondPlayer]),
turnDeadline: clock.UtcNow.AddMinutes(1));
// Persist this token in the input request and in the scheduled timeout command.
var turn = started.State.CurrentTurn;
var next = turns.Advance(
started.State,
turn,
nextTurnDeadline: clock.UtcNow.AddMinutes(1));TurnToken<TPlayerId> contains both the monotonic turn number and the player.
Pass it back to Advance, Suspend, Resume, or Timeout. A delivery for an
earlier token is rejected as stale_turn, so an old timer or input cannot act
on a later turn of the same player. Timeout additionally rejects an early
delivery as turn_not_expired; it does not create a timer itself because the
game alone knows the concrete command that should be scheduled. Use the normal
ScheduleEffect for that command and include the token in its payload.
For game rules that need to return state, effects and turn progression together,
use TurnResult<TState, TPlayerId> and apply it with the same TurnEngine.
This keeps game rules pure while ensuring an old input cannot accidentally
commit its state or effects after the FSM rejects its turn token.
TurnResult<TableState, PlayerId> Handle(TableState state, ChooseMove command)
{
var input = new InputRequestEffect(
requestId: $"move:{state.Id}:{state.Revision + 1}",
expectedPlayerId: command.PlayerId.ToString(),
scopeId: state.Id,
route: "table.choose-move",
expiresAt: clock.UtcNow.AddMinutes(1));
return new(
State: state with { PendingMove = true },
Effects: GameEffectSet.Empty,
Completion: TurnCompletion.WaitFor<PlayerId>(input));
}
var applied = turns.Apply(
currentState: state,
turns: state.Turns,
expectedTurn: command.Turn,
result: Handle(state, command),
bindTurns: static (nextState, nextTurns) => nextState with { Turns = nextTurns });Continue() retains the player and deadline, Complete() finishes the game,
and PassTo(player, deadline) starts a new numbered turn for an admitted
player. WaitFor(input) retains the current player, uses the input expiry as
the turn deadline, and adds that ordinary InputRequestEffect to the effect
set. The input request is still consumed and correlated by the generic input
runtime; neither the result nor the turn engine knows whether Telegram, web or
a test harness delivers it. If a wait has a timeout policy, emit the matching
token-bearing ScheduleEffect beside the result as usual.
TurnResultTransition.ToGameDecision(...) maps the applied result back to the
ordinary atomic action contract, including all six effect categories. This lets
an existing IGameAction use the primitive without a separate effect adapter.
RoundEngine<TPhase> is the companion for games whose flow is not merely a
cyclic player order: quizzes, card-game stages, voting, draft/pick phases,
waves, and multi-round contests. It deliberately has no fixed phase graph. The
game chooses Setup -> Play -> Vote -> Resolve (or any other graph), while the
engine owns lifecycle, monotonically numbered phase occurrences and stale-input
protection.
var rounds = new RoundEngine<TablePhase>();
var started = rounds.Start(
rounds.CreateWaiting(TablePhase.Deal),
firstPhaseDeadline: clock.UtcNow.AddMinutes(1));
var phase = started.State.CurrentPhase; // (round: 1, phaseNumber: 1, Deal)
var next = rounds.AdvanceTo(
started.State,
phase,
TablePhase.Betting,
nextPhaseDeadline: clock.UtcNow.AddMinutes(2));RoundPhaseToken<TPhase> contains the logical round, a monotonically increasing
phase number, and the game-defined phase id. Persist that exact token in input
and timeout commands. RoundResult<TState, TPhase> is the phase analogue of
TurnResult: a pure rule returns state, a GameEffectSet, and one of
Continue, AdvanceTo, NextRound, WaitFor, or Complete. Calling
Apply(...) validates the token before returning any new state or effects;
ApplyTimeout(...) additionally rejects a timer delivered before its deadline.
The scheduler is already durable: ScheduleEffect is committed into the
schedule outbox and only then delivered to the configured scheduler. Use
IRoundDeadlineCommand<TPhase> and RoundDeadlineSchedule to bind a scheduled
atomic command to the exact phase without a transport dependency:
public sealed record PhaseExpired(RoundPhaseToken<TablePhase> ExpectedPhase)
: IRoundDeadlineCommand<TablePhase>;
var timeout = new PhaseExpired(next.State.CurrentPhase);
var effect = RoundDeadlineSchedule.Schedule(next.State.PhaseDeadline!.Value, timeout);
// When a phase changes early, atomically cancel the old timer and schedule the new one.
var timerEffects = RoundDeadlineSchedule.Replace(
phase,
next.State.PhaseDeadline!.Value,
timeout);The AtomicGameScheduledCommand<PhaseExpired, ...> registration remains the
same as for any other scheduled command. Its handler passes ExpectedPhase to
ApplyTimeout; an old Quartz delivery is therefore harmless even if it races a
phase transition or its cancellation. Declare round-based.rounds,
round-based.phases, round-based.deadlines, and scheduling.timers in a game
manifest when that module exposes these mechanics.
LobbyEngine<TPlayerId> is a pure aggregate component for multiplayer setup:
players and spectators, deterministic numbered seats, player limits, readiness,
teams, roles, start and cancellation. It has no concept of an invite transport,
wallet or UI. MatchmakingEngine<TPlayerId, TMatchKey> is a separate pure FIFO
queue: it forms the earliest exact compatible bucket of a requested size, then
the application creates a regular lobby for those players. The game defines the
compatibility key and owns rating windows, invites and bot-fill policy. A game embeds LobbyState<TPlayerId> and uses
the started players as the input order for its own TurnEngine, RoundEngine,
or another rule model.
var lobby = new LobbyEngine<string>(new LobbyConfiguration(2, 6));
var first = lobby.JoinPlayer(lobby.Create(), "alice", teamId: "red");
var second = lobby.JoinPlayer(first.State, "bob", teamId: "blue");
var ready = lobby.SetReady(lobby.SetReady(second.State, "alice").State, "bob");
var started = lobby.Start(ready.State);GameAudience selects Public, Participants, Spectators, one Player, a
Team, or a Role. LobbyAudienceResolver<TPlayerId> resolves that selector
against the durable lobby snapshot for a game projection. Presentation effects
can carry the same selector in PresentationAddress.Audience, so a Web or
another frontend can deliver a role/team-private effect without any Telegram
type entering a game. Direct RecipientId and an audience are intentionally
exclusive. This is a routing/visibility primitive, not encryption: do not put a
private game state snapshot in a public effect or read model.
Use GameRuleSet<TContext> to keep shared validation ordered and explicit:
var rules = new GameRuleSet<PlayContext>(
[
GameRule.Require<PlayContext>("active", c => c.IsActive, GameRuleRejection.GameNotActive),
GameRule.Require<PlayContext>("member", c => c.IsMember, GameRuleRejection.PlayerNotJoined),
GameRule.Require<PlayContext>("turn", c => c.IsCurrentPlayer, GameRuleRejection.NotYourTurn),
]);
var check = rules.Evaluate(context); // first stable Code, e.g. "not_your_turn"Standard reasons include not_your_turn, insufficient_players,
phase_closed, stale_input, deadline_expired and invalid_input; games can
also create a namespaced GameRuleRejection.Custom(...) without losing a common
shape.
Card<TCardId, TFace>, Deck<TCardId, TFace> and Hand<TCardId, TFace> are
immutable card-state primitives. A card id identifies a physical copy, so a
deck can contain several equal faces. Deck.Draw returns both the drawn cards
and its remaining deck; Deck.Shuffle accepts IGameRandom, preserving the
normal entropy/replay guarantees. A game owns dealing order, discard piles,
hand visibility and card-specific rules.
var draw = deck.Shuffle(random, "deal").Draw(2);
var nextHand = hand.Add(draw.Cards);
var nextDeck = draw.RemainingDeck;GridBoard<TPieceId, TValue> combines GridBounds, GridPosition and unique
BoardPiece values. A BoardMove includes its expected source location, so
TryMove detects stale input. It handles empty-destination moves and an
explicit generic capture mode; chess/poker-style ownership, legal paths,
promotion, score and win conditions remain game rules.
var move = new BoardMove<string>("white-knight", new(0, 1), new(2, 2));
var result = board.TryMove(move, BoardMoveMode.RequireEmptyDestination);
if (result.Applied)
board = result.Board;An executor supplies named cryptographic EntropyValue values and persists them
with command idempotency. Use EntropyGameRandom inside the pure action rather
than Random.Shared: it consumes each declared name at most once, supports
integers, chance, picks and Fisher-Yates shuffle, and exposes a GameRandomTrace
for game-level audit records. EntropyNames.ForDraws and ForShuffle generate
the matching descriptor names.
var random = new EntropyGameRandom(input.Entropy);
var card = random.Pick("deal:0", deck);
var shuffled = random.Shuffle("deck", deck);For commit/reveal outcomes exposed to players, Host's existing
IRandomOutcomeGenerator remains the verifiable SHA-256 fairness service. The
new random facade does not generate seeds or bypass that audit; it makes normal
game rules deterministic from the executor-provided entropy.
Host now records every normal atomic or state-only decision in
game_execution_history in the same transaction as state, inbox and outboxes.
Each GameExecutionHistoryEntry contains command and result payloads, before/
after state snapshots, decision/rejection, entropy and every declared effect or
domain event. IGameExecutionHistoryReader exposes lookup by command and a
bounded aggregate timeline. Rollback writes no entry. Raw payloads can include
private cards, roles or input, therefore the reader is for an authorized
internal/admin projection only; the normal tenant RLS boundary applies to its
rows and public clients must receive a separate safe view.
The transport supplies a stable command id. Before loading state, the executor
locks the command and aggregate and checks game_command_idempotency:
- a new command stores entropy, applies the decision and persists the result;
- a duplicate returns the previously serialized result without reapplying effects;
- a failure or cancellation before commit rolls back state, ledger, records, inbox completion and outbox rows together;
- a response lost after commit is recovered by retrying the same command id.
Transport payloads do not need to expose the id publicly. Telegram message or callback identity, gRPC metadata, and HTTP idempotency headers can populate the framework envelope. Generating a fresh id inside a retrying facade does not provide lost-response recovery.
When adding or migrating a mutation:
- Put rules and calculations in a synchronous
IGameAction. - Pass time and named entropy through
GameActionInput. - Return every database consequence as state or a materialized effect list.
- Declare every quota and every additional wallet/aggregate lock up front.
- Keep the state store and custom handlers transaction-aware and transport-free.
- Publish analytics and remote notifications from committed events.
- Test deterministic decisions, rejection without effects, rollback, duplicate command ids, concurrent same/different aggregates, and lost-response replay.
- Keep read-only queries outside the executor; the effect system is for commands.
Heavy GIF/image generation is runtime work, not an atomic game effect. A game
first commits its decision, then submits an immutable render specification to
IRenderQueue. BotFramework.Rendering describes the specification with a
content-addressed RenderKey, checks the shared artifact store, deduplicates
identical in-flight work, and runs a registered IRenderJob<TSpec> in a bounded
TPL Dataflow worker.
committed game result
-> immutable render spec
-> content key (renderer + version + input hash)
-> memory/in-flight dedup
-> MinIO cache hit -------------------------------> artifact
-> bounded TPL queue -> CPU render -> MinIO put --> artifact
-> optional RenderHistoryEntry manifest
There are separate interactive and background/prewarm queues. They share one
global MaxParallelism semaphore, so together they cannot exceed the configured
CPU budget. Queue capacity provides backpressure; PrewarmAsync may accept a
whole matrix, but it cannot create unbounded active renders. This is intentionally
not IAsyncEnumerable: one render is one finite, awaitable artifact.
A renderer is explicit and versioned:
public sealed class BoardRenderJob : IRenderJob<BoardRenderSpec>
{
public RenderKey Describe(BoardRenderSpec spec) =>
new("board", "3", HashCanonical(spec), "png", "image/png");
public ValueTask<RenderOutput> RenderAsync(BoardRenderSpec spec, CancellationToken ct) =>
ValueTask.FromResult(RenderOutput.FromBytes(RenderBoard(spec), "board.png"));
}
services.AddRenderJob<BoardRenderSpec, BoardRenderJob>();RendererVersion must change whenever pixels, encoding, fonts, localization or
canonicalization change. Specifications must contain every input that affects
the bytes and must not read mutable game state during rendering.
Horse registers deterministic winner/variant GIF specifications. Quartz runs
horse.render-prewarm at 03:15 UTC and feeds the complete winner × variant
matrix through the bounded prewarm queue; a cache miss is still rendered on
demand. Poker board PNGs use the same queue and content deduplication.
When MinIO is enabled, artifacts live under
artifacts/{renderer}/{version}/{hash}.{extension}. Match history stores small
JSON manifests under history/{game}/{aggregate}/...; manifests point to the
same immutable artifact instead of copying GIF/PNG bytes. Authenticated admin
inspection is available through:
GET /admin/render-history/{gameId}/{aggregateId}?take=50;GET /admin/render-artifact/{rendererId}/{version}/{hash}.{extension}.
Configuration is under Rendering:
{
"Rendering": {
"QueueCapacity": 64,
"MaxParallelism": 0,
"MaxArtifactBytes": 33554432,
"Minio": {
"Enabled": true,
"Endpoint": "minio:9000",
"AccessKey": "minioadmin",
"SecretKey": "change-me",
"Bucket": "casinoshiz-media",
"Secure": false
}
}
}MaxParallelism = 0 selects half of the available logical processors (at least
one). MinIO is a cache/history dependency, not a transaction participant. Read
or write failure falls back to a transient artifact for the current response;
history writes are retried and logged without changing an already committed game
result. Production credentials must come from secrets, not committed settings.
The combined compatibility host can compose backend and channel adapters in one process. The example below enables Telegram; Discord can be enabled by adding the Discord composition and the corresponding channel modules.
var builder = WebApplication.CreateBuilder(args);
builder.AddBotFramework()
.AddModule<DiceModule>()
.AddModule<PokerModule>()
.AddModule<SecretHitlerModule>();
builder.AddTelegramFramework()
.AddTelegramModule<DiceTelegramModule>()
.AddTelegramModule<PokerTelegramModule>()
.AddTelegramModule<SecretHitlerTelegramModule>();
var app = builder.Build();
app.UseBotFramework();
app.UseTelegramFramework();
app.Run();A split deployment composes the backend and each channel BFF separately. The backend-facing game contracts stay the same in both cases.
Backend process:
var builder = WebApplication.CreateBuilder(args);
builder.AddBackendFramework()
.AddModule<DiceModule>()
.AddModule<PokerModule>()
.AddModule<SecretHitlerModule>();
var app = builder.Build();
app.UseBackendFramework();
app.Run();Discord BFF process:
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddDiscordBackend();
// Each client uses Backend:GameAddresses:<GameId> when it is set;
// Backend:GrpcAddress remains the monolith/single-backend fallback.
builder.Services.AddDiceGrpcClient(/* resolved Dice address */);
builder.Services.AddPokerGrpcClient(/* resolved Poker address */);
builder.Services.AddDiceDiscord();
builder.Services.AddPokerDiscord();
var app = builder.Build();
app.UseDiscordBackend();
app.Run();A channel BFF owns ingress, presentation and remote client wiring. It must not open a game or service database directly.
Telegram BFF process:
var builder = WebApplication.CreateBuilder(args);
builder.AddTelegramBff()
.AddTelegramModule<DiceTelegramModule>()
.AddTelegramModule<PokerTelegramModule>()
.AddTelegramModule<SecretHitlerTelegramModule>();
var app = builder.Build();
app.UseTelegramFramework();
app.Run();The composition root decides whether a contract is implemented locally or through a transport adapter such as gRPC. Application-facing interfaces do not change when a module crosses the process boundary.
Backend modules implement the framework module contract and register their services:
public sealed class MyGameModule : IModule
{
public void ConfigureServices(IModuleServiceCollection services)
{
services.AddApplicationService<MyGameService>();
services.AddProjection<MyProjection>();
services.RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced);
services.AddMigrations<MyGameMigrations>();
}
}Telegram modules register handlers and presentation services:
public sealed class MyGameTelegramModule : ITelegramModule
{
public void ConfigureServices(ITelegramModuleServiceCollection services)
{
services.AddHandler<MyGameCommandHandler>();
services.AddRenderer<MyGameRenderer>();
}
}Discord modules follow the same rule with channel-specific handlers:
public static class MyGameDiscordModule
{
public static IServiceCollection AddMyGameDiscord(
this IServiceCollection services) => services
.AddScoped<IDiscordMessageHandler, MyGameDiscordHandler>()
.AddScoped<IDiscordInteractionHandler, MyGameDiscordInteractionHandler>();
}IDiscordMessageHandler and IDiscordInteractionHandler call logical game
contracts. They do not move game state or persistence into the channel adapter.
A new module should be able to start with a small set of contracts and grow only when it needs persistence, projections, transport adapters or Telegram presentation.
Every Telegram update flows through the Telegram update pipeline and then the attribute router:
Exception → Deduplication → Logging → RateLimit → KnownChats → UpdateRouter
Routing is attribute-driven. A Telegram adapter adds a handler by implementing IUpdateHandler, decorating it with route attributes and registering it in the Telegram module:
services.AddHandler<MyHandler>();Supported route attributes include:
[Command]
[CallbackPrefix]
[CallbackFallback]
[MessageDice]
[ChannelPost]
Only the Telegram layer knows Telegram update types, bot clients and rendering details. Backend services receive application commands through logical contracts.
Discord has a separate adapter runtime, but the same application boundary:
Discord Gateway
-> DiscordHostedService
-> message/interaction router
-> UX rate limit and cooldown
-> channel handler
-> logical game contract / gRPC client
The interaction router supports slash commands, autocomplete, buttons/selects
and modals. Input that is naturally multi-field (for example a table code, bet
and raise amount) belongs in a modal rather than in a plain-text command.
Handlers should use DiscordEmbeds for structured responses and
DiscordLocalization for ru/en text. The adapter owns Discord-specific
formatting; game results remain channel-neutral.
Component custom IDs are backed by persistent component tokens. A token is validated before a component or modal handler runs, so a button from before a restart is rejected as stale instead of being interpreted against new state. Rate limiting and cooldown decisions happen at the UX layer before the handler is invoked. This protects both local and horizontally scaled BFF replicas; the underlying game executor still remains the authority for idempotency and state concurrency.
The framework supports one codebase across four operational shapes. The composition root and environment select the shape; game/application contracts do not change.
CasinoShiz.Host
backend modules
Telegram modules
admin UI
persistence
eventing
jobs
This mode is useful for local development, compatibility and simple deployment.
The monolith keeps the existing shared postgres/cazino database and loads all
registered game modules. Backend:ServiceName may be omitted; the framework
keeps the legacy Quartz scheduler partition for existing schedules.
The compatibility split keeps one all-games Backend while separating service and channel processes:
CasinoShiz.Backend
backend modules
event store
projections
jobs
admin compatibility surface
CasinoShiz.TelegramBff
Telegram ingress
update routing
Telegram handlers
gRPC/local contract clients
CasinoShiz.AdminBff
browser auth/session
reverse proxy / operations calls
no direct database access
CasinoShiz.IdentityService
identity-owned storage and contracts
CasinoShiz.WalletService
wallet, ledger, limits and protection
The service databases are separate physical ownership boundaries:
Backend -> backend-postgres/backend
Identity -> identity-postgres/identity
Wallet -> wallet-postgres/wallet
Identity and Wallet run only their own migrations. Backend never reads their tables; composed admin/player views call Identity, Wallet and Backend APIs.
The distributed Compose profile uses the same CasinoShiz.Backend image for
each selected game service. Backend:Modules controls which module is loaded:
Backend__Modules=dice
Backend__ServiceName=game-dice
ConnectionStrings__Postgres=...backend...
Each channel BFF resolves a game independently:
Backend__GrpcAddress=http://game-admin:8081 # fallback/control plane
Backend__GameAddresses__Poker=http://game-poker:8081
Backend__GameAddresses__Dice=http://game-dice:8081
If a per-game address is absent, the BFF falls back to
Backend:GrpcAddress, preserving the monolith and legacy microservices path.
Native dice transports use named gRPC clients, so DiceCube, Darts, Football,
Basketball and Bowling can also be routed independently.
The service DNS names are stable, so scaling changes only replica count:
docker compose --profile distributed up --scale game-poker=3The game service owns only its selected module migrations and schedule rows. Other game services may share the Backend PostgreSQL instance, but they do not perform cross-module table reads.
The Helm chart deploys the same image and configuration model as the distributed Compose profile. Each game is a Deployment/Service pair; Identity, Wallet and Backend have separate PostgreSQL StatefulSets, and Redis carries CAP/event transport. A game can be scaled without a code or image variant:
helm upgrade --install cazinoshiz ./deploy/helm/cazinoshiz
kubectl scale deployment game-poker --replicas=3Production installations can replace the chart's PostgreSQL/Redis templates with managed services by overriding addresses, credentials and storage values.
Transport choice belongs to composition. Modules should depend on logical contracts, not on whether the target is local, gRPC or another transport.
The framework supports two aggregate persistence styles.
Classical aggregates are persisted by module-owned stores/repositories, usually with Dapper and module-specific tables. Most simple game modules should use this style.
Event-sourced aggregates implement IEventSourcedAggregate and are registered by the module:
services.RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced);This registration wires:
IRepository<MyAggregate>→EventSourcedRepository<MyAggregate>IAggregateFactory<MyAggregate>→DefaultAggregateFactory<MyAggregate>unless the module already registered a custom factory
The event-sourced repository owns the aggregate lifecycle:
- Load stored events from
IEventStore. - Deserialize and replay them through
LoadFromHistory. - Append
PendingEventswith optimistic concurrency. - Dispatch appended events through
EventDispatcher. - Call
MarkEventsCommittedafter successful append.
DefaultAggregateFactory<T> tries to create aggregates through DI, first with the stream id as a string constructor argument and then without it. A module can override this by registering its own IAggregateFactory<T> before calling RegisterAggregate<T>(PersistenceStrategy.EventSourced).
The current event-sourced flow is intentionally post-commit:
Application service
↓
IRepository<TAggregate>
↓
EventSourcedRepository<TAggregate>
↓
IEventStore.AppendAsync(...) // commits module_events
↓
EventDispatcher.DispatchAsync(...) // projections, domain bus, analytics
IEventStore remains a small storage primitive: load stream and append events. It does not know about projections, subscribers or analytics.
EventDispatcher fans each committed event into:
- matching
IProjections; IDomainEventBussubscribers, including framework-wide subscribers;IAnalyticsService.Track(...).
Post-commit dispatch means a dispatch failure does not roll back the already committed event append. Projection handlers and subscribers must be idempotent. Recovery should be done with replay/rebuild jobs or targeted retries.
ProjectionContext.Transaction is nullable. It is normally null in the current implementation. The field exists so a future same-transaction unit-of-work implementation can pass a provider-specific transaction object without changing module contracts.
IDomainEventBus lets modules react to events without referencing each other. Subscriptions are pattern-based:
Pattern
Meaning
sh.game_ended
exact event
sh.*
every event from one module
*.game_ended
one action from any module
*
every event
At startup, EventSubscriptionInitializer subscribes framework listeners to *:
EventLogSubscriberwrites events toevent_logfor admin/history views.ClickHouseEventMirrormirrors events to ClickHouse when analytics is enabled.
Modules add their own subscribers with:
services.AddDomainEventSubscription<MySubscriber>("poker.*");IDomainEventBus is an abstraction. The active implementation can be in-process or backed by CAP with
Redis Streams or Kafka/Redpanda depending on composition and Messaging:Transport.
When a broker-backed CAP transport is enabled, the event path is:
game transaction
-> game_event_outbox (PostgreSQL)
-> lease-based outbox dispatcher
-> CAP PostgreSQL outbox + Redis Streams or Kafka/Redpanda
-> CapEventConsumer
-> local projections/subscribers
CAP consumer groups are derived from Backend:ServiceName. Replicas of one
logical service use the same group and load-balance delivery. Different game
services use different groups, so a subscriber in game-meta is not skipped by
game-poker. Consumers and projections must still be idempotent because the
transport is at-least-once.
Projections are module-owned read models. A projection declares which event types it handles:
public sealed class MyProjection : IProjection
{
public IReadOnlySet<string> SubscribedEventTypes { get; } =
new HashSet<string> { "poker.hand_won" };
public Task ApplyAsync(IDomainEvent ev, ProjectionContext ctx, CancellationToken ct)
{
// update read model table
}
}Register it in the module:
services.AddProjection<MyProjection>();Projections run after the event append commits. They should be idempotent and safe to replay.
module_events stores event-sourced aggregate streams. It is the source of truth for replayable aggregate state.
event_log is a flat audit/history stream populated by EventLogSubscriber. It can contain events from event-sourced modules and other published domain events. Admin history pages should read this table, not replay aggregate streams.
module_snapshots stores optional aggregate snapshots through ISnapshotStore<T>.
event_dispatch_failures stores failed post-commit dispatch attempts for targeted retry and operational recovery.
The framework distinguishes event delivery from external side effects.
Domain/integration events can flow through the configured event bus. External effects such as Telegram messages emitted from subscribers and background jobs should use a durable outbox.
Telegram outbox records are persisted before sending. In the monolith, the local dispatcher claims due rows and sends them to Telegram. In the split deployment, the Backend relay claims rows, publishes a CAP command through the configured broker to Telegram BFF, and marks a row sent only after the BFF confirms the Telegram message id. Both modes reclaim expired leases and retain retry metadata on failure.
Handlers that respond immediately to live Telegram updates may still send direct Telegram responses. Critical asynchronous notifications should use the outbox.
The same failure model applies to Discord: discord_outbox is persisted in the
owning PostgreSQL database and Discord BFF replicas claim rows with leases.
Message delivery is therefore independent from the update/interaction request
that produced it.
game_event_outbox is a fan-out source for the configured event bus. Its
SKIP LOCKED lease means any healthy Backend replica can publish an event once;
CAP then fans it out to the logical consumer groups.
game_schedule_outbox is different: a schedule must be applied by the service
that registered its command. Every row stores game_id, and a distributed
worker claims only rows for its loaded modules. Migration
027_schedule_outbox_ownership backfills older scoped schedule IDs.
Quartz uses Backend:ServiceName as the persistent sched_name partition.
Replicas of game-poker therefore cluster together, while a game-dice
replica cannot acquire Poker triggers. The monolith falls back to the legacy
CasinoShiz scheduler name for upgrade compatibility.
Wallet and identity are logical service boundaries.
Backend modules should not directly query wallet or identity tables. They should use framework contracts such as wallet read/write ports, player directory ports and service adapters selected by composition.
In a combined host those ports may be implemented locally. In a split deployment they may be implemented by gRPC clients pointing at CasinoShiz.IdentityService and CasinoShiz.WalletService.
The Admin BFF is the composition boundary for cross-service read models. For example, player and admin views call Identity, Wallet and Backend Operations over gRPC and return one response to the browser:
/api/aggregation/players/{userId}
-> Identity player data
-> Wallet balance/protection data
/api/aggregation/admin
-> Backend operations data
-> Wallet analytics data
These are composed read models, not database joins. No BFF or Backend process may connect to another service's PostgreSQL database or read another service's tables directly.
Wallet invariants such as idempotent debits/credits, balance scopes, ledger append, limits, cooldowns and self-exclusion belong to the wallet owner, not to individual game modules.
Framework-owned tables are created by framework migrations first. Module migrations are then applied through IModuleMigrations and tracked in __module_migrations.
Migrations are forward-only raw SQL migrations executed via Dapper.
Startup uses a PostgreSQL advisory lock around the migration phase. This is
required when Kubernetes starts many game replicas simultaneously. In the
monolith, framework and all loaded module migrations run against cazino. In
the legacy microservices profile, Backend, Identity and Wallet each run only
their own migration set against their own connection string. In the distributed
profile, every game process runs framework migrations plus the selected module's
migrations against the Backend database.
Backend migrations must not create or mutate Wallet/Identity-owned tables except during explicitly marked compatibility transitions. A module that is not selected must not be required for a selected service to start.
A new backend module should:
- Define its application service contract or use an existing framework contract.
- Define domain model, commands, results and events.
- Choose classical persistence or event-sourced persistence.
- Register services through
ConfigureServices(IModuleServiceCollection). - Register migrations through
IModuleMigrations. - Register projections and domain-event subscribers when needed.
- Keep Telegram, HTTP and transport-specific code out of the backend module.
A new Telegram adapter should:
- Reference
BotFramework.Telegram. - Reference the game contract or backend-facing interface it needs.
- Implement Telegram handlers using route attributes.
- Parse Telegram input and render Telegram responses.
- Call logical application contracts, not concrete backend services.
- Register handlers through
ConfigureServices(ITelegramModuleServiceCollection).
The adapter should not own game state, wallet state or persistence.
A new module that wants event sourcing should:
- Define domain events implementing
IDomainEventwith stableEventTypestrings, e.g.mygame.round_started. - Define an aggregate implementing
IEventSourcedAggregate. - Register it with
RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced). - Optionally register
IAggregateFactory<MyAggregate>if the default DI/id constructor creation is not enough. - Register projections with
AddProjection<TProjection>(). - Register cross-module subscribers with
AddDomainEventSubscription<TSubscriber>(pattern).
After that, appended aggregate events automatically flow through projections, event log, ClickHouse mirror, module subscribers and analytics.
BotFramework.Sdkshould remain transport-neutral.BotFramework.Telegramis the framework project that owns Telegram runtime types.- Backend modules should not depend on Telegram adapters.
- Telegram adapters should not own persistence.
- Transport adapters should live outside domain/application modules.
- Composition roots decide local vs remote implementation.
- Event handlers, projections and subscribers must be idempotent.
- External side effects that must survive process failure should use an outbox.
- Wallet and identity tables are owned by their services, not by games.
- Game modules should communicate through contracts and events, not by importing another game's internals.