Rust service that runs language-learning game sessions:
state machine, scoring,
PostgreSQL persistence,
and JSON HTTP API.
Has a crosswords engine inside (heuristic greedy algorithm to compose crosswords)
LLM supplies words, hints, algorithm creates a crossword.
Other text games , based on LLM response
It is standalone (own database and Docker Compose)
-
Start PostgreSQL (or use Docker Compose for only the DB):
docker compose up -d shakti-game-db
-
Export env and migrate (the binary runs migrations on startup):
export DATABASE_URL=postgresql://game:game@127.0.0.1:5435/shakti_game export RUST_LOG=info cargo run -p shakti-game-engine
docker compose up --build- API:
http://127.0.0.1:8010 - Health:
GET /health, readiness + DB:GET /ready
CI / automated integration tests use GAME_ENGINE_LLM_MODE=mock (deterministic passage stitched from DB or inline inputs). Manual play UI checks against a real model use openai plus a key: the model is instructed to write an original passage in the session language (not a copy of your source texts).
- Create a single-line file
openai.key.secretin this repo root (the file is gitignored) containing your API key, or setOPENAI_API_KEYin the environment. - Optional: override file path with
OPENAI_KEY_FILE(defaultopenai.key.secret, relative to the process working directory —/appin the container). - Set
OPENAI_MODELif needed (default in compose:gpt-5.2). GAME_ENGINE_LLM_MODE:mock(default in compose),openaifor live calls, or omit when running the binary locally so that if a key is present the engine picks OpenAI automatically.- For Docker,
docker-compose.ymlbind-mounts./openai.key.secret→/app/openai.key.secret. Create that file in the repo root beforeGAME_ENGINE_LLM_MODE=openai docker compose up --build. If the file is missing, compose may fail to start the engine container; use mock mode or add a placeholder line for local-only dev.
The app logs structured openai_key_source as none, env, file, or actors at startup (never logs the key).
Shakti full stack (shakti-deployment Docker Compose): set GAME_ENGINE_LLM_MODE=openai and SHAKTI_ACTORS_INTERNAL_URL=http://shakti-actors:8080. The engine fetches the key lazily on the first gap-fill start from GET /api/keys/get/openai_main/shakti-game-engine (same encrypted row as install-api-keys.sh → openai_main). Override names with SHAKTI_ACTORS_OPENAI_KEY_NAME / SHAKTI_ACTORS_OPENAI_CONSUMER_SERVICE. Env/file key still wins if set.
contentRequest may include llmSourceTexts (non-empty strings → skip DB learning rows; requires language) and llmHardWords (non-empty list → skip DB vocabulary for the prompt). See the shakti-game-play-ui client.
When GAME_ENGINE_DEV_EXPOSE_GAP_SOLUTION is 1, true, or yes, session/step JSON includes devGapSolution (array of strings, gap index order) on gap-fill steps so shakti-game-play-ui can show a developer hint panel. Keep this off in production (false or unset); docker-compose.yml defaults it to true for local testing only.
SQLx stores a checksum per applied migration in _sqlx_migrations. This error means the migration file on disk (baked into the binary) no longer matches the checksum recorded when that version first ran—usually because an old migrations/*.sql was edited after it was already applied, while the Postgres Docker volume still holds the old checksum.
Local dev (safe to wipe data): reset the database volume and start again:
docker compose down -v
docker compose up --buildRule of thumb: never change the contents of a migration that has already run in an environment you care about; add a new migration file instead. For shared/prod databases, coordinate checksum fixes with your DB team (avoid ad-hoc edits to _sqlx_migrations).
Migrations seed sample learning_items for user id:
11111111-1111-1111-1111-111111111111
A small standalone Vite + TypeScript client for manual testing lives in the sibling project shakti-game-play-ui (same parent directory as this repo). Run the engine (docker compose up), then npm run dev in that UI; it proxies /api to http://127.0.0.1:8010 by default. See that project’s README for env vars and production builds.
Optional request header X-Trace-Id selects the correlation id; if omitted, the server generates one. Every response echoes the effective id in the X-Trace-Id response header (not in JSON bodies). For browser clients, expose that header via CORS (Access-Control-Expose-Headers) if the UI needs to read it.
Create session (camelCase JSON):
curl -s -X POST http://127.0.0.1:8010/api/v1/game-sessions \
-H 'Content-Type: application/json' \
-H 'X-Trace-Id: my-trace-1' \
-d '{
"userId": "11111111-1111-1111-1111-111111111111",
"gameKind": "gap_fill",
"contentRequest": { "source": "hard_words", "limit": 5, "language": "no" },
"options": {
"stepTimeLimitSecs": 60,
"hintTranslationLanguages": ["en", "de"]
}
}'Start session (replace SESSION_ID):
curl -s -X POST http://127.0.0.1:8010/api/v1/game-sessions/SESSION_ID/start \
-H 'Content-Type: application/json' \
-d '{"userId":"11111111-1111-1111-1111-111111111111"}'Get session:
curl -s 'http://127.0.0.1:8010/api/v1/game-sessions/SESSION_ID?userId=11111111-1111-1111-1111-111111111111'Session JSON includes sourceLanguage and hintTranslationLanguages after start when options.hintTranslationLanguages was set at create/bootstrap (normalized locale codes). While the session is in_progress, optional full-passage translation (same OpenAI model as gap-fill; may reveal gap answers):
curl -s -X POST http://127.0.0.1:8010/api/v1/game-sessions/SESSION_ID/hint/translation \
-H 'Content-Type: application/json' \
-H 'X-Trace-Id: my-trace-1' \
-d '{"userId":"11111111-1111-1111-1111-111111111111","targetLanguage":"en"}'Repeat requests for the same target language are served from a per-session cache in base_context._session.translation_cache.
Submit answer for passage gap-fill (replace STEP_ID from session view; selections in gap order, ordinal 0…n−1):
curl -s -X POST http://127.0.0.1:8010/api/v1/game-sessions/SESSION_ID/steps/STEP_ID/answer \
-H 'Content-Type: application/json' \
-d '{"userId":"11111111-1111-1111-1111-111111111111","answer":{"type":"gap_fill_slots","selections":["gikk","svart","regner","leser","hjem"]}}'Result (after session finished):
curl -s 'http://127.0.0.1:8010/api/v1/game-sessions/SESSION_ID/result?userId=11111111-1111-1111-1111-111111111111'Set GAME_ENGINE_SERVICE_API_KEY in the environment. Then POST /api/v1/game-sessions/bootstrap accepts the same auth as internal callers:
- Header
Authorization: Bearer <key>orX-Shakti-Game-Service-Key: <key> - JSON body (camelCase):
userId(UUID),language,gameKind, optionaldefinitionId,contentPackage(object — same shape ascontentRequestfor gap-fill, e.g.source,limit,llmSourceTexts,llmHardWords), optionaloptions, optionaltraceId
The engine stores contentPackage inside session deferred_payload as content_package (audit) and maps it to content_request for start. If the key is unset, bootstrap returns 503.
export KEY=your-long-random-secret
curl -s -X POST http://127.0.0.1:8010/api/v1/game-sessions/bootstrap \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $KEY" \
-d '{
"userId": "11111111-1111-1111-1111-111111111111",
"language": "no",
"gameKind": "gap_fill",
"contentPackage": {
"source": "hard_words",
"limit": 5
}
}'crates/domain— pure model, gap-fill engine, unit testscrates/engine— use cases and repository ports (packageshakti-game-engine-core)crates/infrastructure— SQLx PostgreSQL adapterscrates/api— Axum routes;X-Trace-Idon requests and mirrored on all responses; span fieldtrace_idfor structured logscrates/app— HTTP server binary (shakti-game-engine), config, migrations on startup; avoids name clash withcrates/enginemigrations/— SQL schema + seed data
Design reference: shakti_game_engine_design.md.
When this repository sits next to shakti-deployment, the shakti-game-engine service is part of the default stack (same docker-compose.yml), joins shakti-network, and uses database shakti_game on shakti-db.




