This repository contains the Dotabod backend monorepo, including shared packages and the Dota, Steam, Twitch chat, and Twitch events services.
- Lightweight formatting:
pnpm exec oxfmt --config oxfmt.config.ts <edited files> - GitHub CI workflow:
.github/workflows/ci.yml - Manually dispatch CI for a pushed branch:
gh workflow run ci.yml --ref <branch> - Watch the dispatched run:
gh run watch --exit-status
This checkout shares a host with production services. Builds and repository-wide checks must run only on GitHub-hosted runners.
- Never run
pnpm install,pnpm run build:all, package builds,pnpm test,pnpm run quality,pnpm run check, full-project lint/typecheck, coverage, generators, development servers, Docker builds, or service runners on this host. - Do not substitute a similar local build, test, runtime, coverage, or static-analysis command. If GitHub Actions is unavailable, report verification as blocked.
- Local work is limited to inspection, editing, targeted formatting,
git diff --check, and other negligible-cost operations. - Commit with
--no-verifyif an older checkout still has resource-intensive hooks. - For pre-merge verification, push a feature branch, dispatch
ci.ymlfor that branch, and wait for a successful conclusion. Do not merge or push the change tomasteruntil remote CI passes.
- TypeScript: Use strict types with explicit return types for exported functions
- Formatting: 2-space indentation, 100 char line width, single quotes
- Imports: Use ESM module syntax with explicit file extensions
- Error handling: Use try/catch for async operations, with Winston for logging
- Testing: Files end with
.test.tsor in__tests__directories
- Monorepo with packages in
packages/and microservices inservices/ - Avoid circular dependencies (see CIRCULAR-DEPS.md)
- Docker Compose is used for development and production environments
Each service must run as exactly one replica. They are stateful singletons and running two at once breaks things: dota holds per-token game state in memory and fires non-idempotent side effects with no cross-instance coordination (no socket.io Redis adapter, so overlay emits only reach the process the overlay's socket is on; no Redis lock around bet open/close, so two instances can create duplicate Twitch predictions; map.game_state can double-create clips). steam logs into one Steam account (a 2nd login mutually kicks the Dota GC session). twitch-chat registers an EventSub conduit shard per instance (two → duplicate chat messages). twitch-events is the singleton conduit manager. This also rules out Coolify rolling deploys (which briefly run old+new together); deploys are a hard cutover, which is fine — each service recovers in single-digit seconds and the GSI/socket/webhook paths all self-heal via retry/reconnect. To ever scale dota past one replica you'd first need a socket.io Redis adapter + a cross-instance bet lock (covering both GSIHandler and events/gsi-events/hero.name.ts).
Each service runs on Coolify with node --cpu-prof gated by the CPU_PROF env var. Coolify app UUIDs: dota i8gccg8, twitch-events zwg4g4c, twitch-chat zwgkg48, steam wsgwk8s.
To capture: in Coolify → <service> app → set env CPU_PROF=1 → Restart → let it serve traffic ~5 min → Stop (SIGTERM triggers process.exit(0), node flushes). This workspace normally runs directly on oracle, so retrieve locally first:
sudo -n sh -lc 'cat /var/lib/docker/volumes/<uuid>-profiles/_data/CPU.*.cpuprofile' > svc.cpuprofileOnly when the current host does not run the coolify container, use the remote fallback: ssh oracle "sudo sh -lc 'cat /var/lib/docker/volumes/<uuid>-profiles/_data/CPU.*.cpuprofile'" > svc.cpuprofile. If Coolify is local but the volume is missing, treat that as a local deployment/profile problem.
Open in Chrome DevTools → Performance → Load profile. Source maps resolve frames to src/.... Set CPU_PROF= back to empty + Restart when done.
Monitoring — why no in-container New Relic APM agent: The Node APM agent (loaded via -r newrelic) burned excessive CPU for little benefit — dota alone sat at ~40% container CPU emitting ~540k APM datapoints/30min — so it was removed in 2026-05. Observability now comes entirely from the Oracle host newrelic-infra agent: nri-docker emits ContainerSample (CPU/mem/restart, faceted by label.coolify.resourceName) and host fluent-bit forwards container logs to NR with a container_name attribute. Tradeoff: we lost APM-only HTTP signals (response time, apdex, throughput, transaction error rate) with no host equivalent — backend alerts/dashboards were migrated to ContainerSample/Log queries accordingly. Do NOT re-add the newrelic package or -r newrelic to the services.
Rollback to a prior image: look up a previous master digest with gh api "/orgs/dotabod/packages/container/<service>/versions?per_page=5" --jq '.[] | select(.metadata.container.tags == ["master"]) | .name', then paste sha256-<digest> (dash, not colon) in Coolify's image tag/hash field and Redeploy.
Chat rosters are assembled from up to three Twitch clips, each read by a different detector and each carrying different data. Most "wrong roster" bugs are really "which clip landed?".
| clip | fires at | endpoint | contains |
|---|---|---|---|
| draft | PLAYER_DRAFT +46s |
/detect_draft |
names only, no heroes |
| strategy | STRATEGY_TIME +43.75s |
/detect |
names + ranks + heroes |
| in-game | GAME_IN_PROGRESS +60s |
/detect_in_game |
heroes only, no names |
Only the strategy/loadout panel carries names + ranks + heroes together — miss it and nothing downstream recovers names. Scheduling: packages/dota/src/dota/events/gsi-events/map.game_state.ts.
Non-obvious constraints, all verified against production data — re-deriving them costs hours:
- The draft name strip is not in slot order. Three verified alignments give three different permutations; only draft index 0 (Radiant captain) is stable. Draft↔roster merging must go through the fuzzy name matcher (
_align_players_with_draft), never a positional join. An in-game result withdraft_alignment.mapping == {}is correct behaviour, not a bug — the in-game top bar shows no names to match on. - In-game hero confidence runs low (mean ~0.66; only ~20% of slots reach the 0.75
HERO_CONFIDENCE_THRESHOLD), and no clean cutoff exists — a measured wrong read scored 0.416 while a correct one scored 0.386. Don't add score gates; anchor on GSI, which knows the streamer's own hero exactly (correctSelfHeroWithGsiinVisionResolver.ts). - Draft rows in
processing_queueare alwaysstatus='failed', even when they succeed. Checkclip_resultsfor the real outcome. - GLOBAL_DELAYat thescheduleClipsites intentionally cancels the+ GLOBAL_DELAYinsidegetStreamDelay()— that 7s is for chat, not clips.
Debugging tools live in scripts/clip-debug/ (query_match.sh for a match's full clip history, scan_clip.py to see what a clip's frames actually contain plus the STRATEGY TIME countdown that tells you where it landed). The dota-vision-roster-debug skill has the full workflow, the DB/API recipes, and a list of plausible-sounding fixes that measurement has already disproved — read it before changing clip timing or alignment code.
Use standalone tools: Oxfmt, Oxlint with Ultracite anti-slop presets, TypeScript 7, Knip, Vitest 5, and tsdown. Put focused regression coverage in the change, but execute it through GitHub Actions rather than on this host. Before PR readiness, require the GitHub CI workflow to pass its quality, test, and full-build steps. The quality gate intentionally treats warnings as errors; keep a rule enabled and fix the source instead of adding broad ignores or suppressions.