Live: https://qwertide.azurewebsites.net
A browser-based typing game written end to end in C# / .NET 8. A passage shows up, you type it, and Qwertide tracks your words per minute and accuracy as you go, then saves your run to a leaderboard backed by a real API.
The front-end and game loop are all Blazor WebAssembly. The scoring is pure C#; the only JavaScript is a 22-line helper that positions the caret. The leaderboard runs on an ASP.NET Core Web API with EF Core and SQLite behind it. In production the two ship together as one Azure App Service. I built it as a portfolio piece for a junior C#/.NET role, and tried to keep it small but production-minded rather than a pile of half-finished features.
- Overview
- Key features
- Architecture
- Tech stack
- API documentation
- Security
- Testing
- CI/CD
- Deployment
- Performance considerations
- Accessibility
- Project structure
- Local development
- Environment variables
- Engineering tradeoffs
- Known limitations
- Future improvements
- Project status
- License
Qwertide is a full-stack, single-page typing test. Most of the actual engineering sits behind the UI rather than in it. The WPM and accuracy math lives in a pure, dependency-free class so it can be unit-tested on its own, and the leaderboard is a small REST API with real input validation and rate limiting instead of a localStorage shortcut. The point of the project is to show the whole C#/.NET stack working together: a WASM front-end, a Web API, an ORM with migrations, a tested domain layer, CI, and a live cloud deploy.
- Live per-keystroke feedback — each character is highlighted correct/incorrect in place as you type, with a blinking caret on the current position.
- Real-time metrics — gross WPM (5 chars = 1 word) and accuracy update while you type, computed by a pure scoring engine.
- Type-past-errors model — wrong characters are marked and counted but don't block you, matching common typing-test convention.
- Three passage lengths — short warm-ups, full paragraphs, and real C# snippets.
- Persistent leaderboard — top runs are submitted to the API and stored in SQLite via EF Core, surviving restarts and redeploys.
- Hardened public API — input validation, per-IP rate limiting, security headers, and HSTS (see Security).
- Accessible, restrained design — dark terminal-mono theme, WCAG AA text
contrast, and motion gated behind
prefers-reduced-motion.
One solution, three projects, with the scoring engine isolated so it is testable without a browser and the client depending on the API only through an interface.
┌─────────────────────────────────────────────────────────────┐
│ Azure App Service (single Linux service) │
│ │
│ ASP.NET Core Web API ──────────────────────────────────┐ │
│ • GET/POST /api/scores ┌──────────────────┐ │ │
│ • rate limiting, validation, │ EF Core + SQLite │ │ │
│ security headers, HSTS ───────▶│ (/home/qwertide │ │ │
│ • serves the published WASM │ .db, migrations)│ │ │
│ bundle + SPA fallback └──────────────────┘ │ │
│ │ │
│ Blazor WebAssembly client ◀─── served as static files ──┘ │
│ • TypingSession (pure scoring engine) │
│ • ILeaderboardService ──▶ HttpClient ──▶ /api/scores │
└─────────────────────────────────────────────────────────────┘
Key decisions
- Pure domain layer. All the metric math lives in
TypingSessionas static, UI-free functions, so the test project can reference it directly and the Blazor component only has to worry about rendering. - Interface-driven leaderboard. The UI codes against
ILeaderboardService, and the active implementation (ApiLeaderboardService) gets swapped in via DI without touching the UI. There's also a secondlocalStorageimplementation that shows the abstraction works; it isn't currently wired in as a runtime fallback. - Single-service hosting. In production the API serves the published WASM
client and falls back to
index.htmlfor client-side routes. That leaves one deployable, one origin, and no CORS to worry about in prod.
Services/TypingSession.cs holds every calculation as plain, pure C#:
// Gross WPM, guarding the zero-time edge case
TypingSession.GrossWpmFor(charsTyped: 50, elapsedSeconds: 60); // -> 10
// Accuracy = correct / total keystrokes, guarding 0/0
TypingSession.AccuracyFor(correctKeystrokes: 45, totalKeystrokes: 50); // -> 90CountKeystrokes counts every character committed in a single input event, since a
fast typist or an IME can commit several between ticks. That way the accuracy
denominator never gets under-counted.
| Layer | Choice |
|---|---|
| Language | C# (.NET 8), nullable reference types enabled |
| Front-end | Blazor WebAssembly + MudBlazor 8 |
| Back-end | ASP.NET Core Web API |
| ORM / data | EF Core 8 + SQLite (code-first migrations) |
| Testing | xUnit + FluentAssertions + coverlet |
| CI | GitHub Actions (build with warnings-as-errors) |
| Code quality | .editorconfig + dotnet format (CI-enforced) |
| Observability | Health checks (/health, EF Core DbContext check) |
| Hosting | Azure App Service (Linux, single service) |
| API docs | Swagger / OpenAPI (Swashbuckle, Development only) |
Base path /api. In Development, interactive Swagger UI is available at /swagger.
| Method | Route | Description | Success | Notes |
|---|---|---|---|---|
GET |
/api/scores?top={n} |
Top runs, ordered WPM↓, accuracy↓, time↑ | 200 |
top clamped to 1–100 |
GET |
/api/scores/{id:int} |
Single run by id | 200 |
404 if not found |
POST |
/api/scores |
Submit a run | 201 |
Validated; rate-limited (429); returns the persisted row |
The POST body binds to a dedicated ScoreRequest DTO; server-owned fields
(Id, CreatedAtUtc) are intentionally absent so they cannot be set by the client.
A GET /health endpoint (outside /api) returns Healthy/Unhealthy and
verifies the database connection — suitable for platform health probes.
Implemented in this repository (Program.cs, ScoresController.cs, ScoreRequest.cs):
- Over-posting protection — a request DTO separate from the EF entity prevents clients from spoofing server-owned fields.
- Input validation —
[Required],[StringLength], and[Range]attributes enforced automatically by[ApiController]. - Rate limiting — fixed-window limiter, 5 submissions/minute per client IP,
returning
429; partitioned by IP so one abuser can't lock everyone out. - Security response headers —
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referreron every response. - HSTS in production; HTTPS redirection enabled; HTTPS-only enforced at the platform.
- Reverse-proxy awareness —
X-Forwarded-*handling for Azure's load balancer, gated to Production so a non-Azure host can't spoof scheme/client IP. - CORS — a named policy with an explicit allow-list from configuration (no
AllowAnyOrigin). - SPA fallback carve-out — unmatched
/api/*routes return a real404instead of the HTML shell. - SQL injection — eliminated by EF Core parameterization.
- Secrets — none committed; the connection string is injected via an App Service setting (see Environment variables).
Authentication/authorization is intentionally not implemented — see Engineering tradeoffs and Known limitations.
dotnet test21 xUnit tests (with FluentAssertions) cover the scoring engine and the edge cases that break naive implementations: zero elapsed time, all-errors, empty input, multi-character input events, and derived-metric state. Tests target the pure domain layer directly, so they run fast and need no browser or HTTP host.
What's actually covered is the scoring engine. There are no API/controller integration tests, component tests, or end-to-end tests yet (see Future improvements).
GitHub Actions (.github/workflows/ci.yml) runs on every push and pull request to
main:
- Restore (
dotnet restore) - Format check (
dotnet format --verify-no-changes) — style violations fail the build - Build in Release with
-warnaserror— warnings fail the build - Test (
dotnet test)
Code style is pinned by an .editorconfig and enforced by the format check above.
NuGet packages and GitHub Actions are kept current automatically via Dependabot
(.github/dependabot.yml, weekly).
This is CI only. Deployment is currently a documented manual step (see below); there is no automated CD pipeline yet.
Live on Azure App Service (Linux) as a single service: the ASP.NET Core API
hosts both the leaderboard endpoints and the published Blazor client. The SQLite
file lives on the persistent /home share, so scores survive restarts and
redeploys. The full runbook (resource creation, connection string, HTTPS-only, and
the publish/zip/deploy commands) is in DEPLOY.md.
- Indexed leaderboard reads — a DB index on
Wpmbacks the primary ordering. - Bounded queries —
topis clamped to 100 so a request can't ask for an unbounded result set. - Rate limiting — protects the write path from abuse-driven load.
- Single origin — the WASM bundle is served as static files by the same service, avoiding cross-origin round-trips in production.
- Release builds verified in CI.
- WCAG AA text contrast across the theme.
- All motion (caret blink, live counters, in-place colouring) gated behind
prefers-reduced-motion. - Semantic, single-accent design system documented in DESIGN.md.
Qwertide.sln
├── .github/workflows/ci.yml Build + test on push/PR (warnings-as-errors)
├── .config/dotnet-tools.json Pinned local tool: dotnet-ef
├── DEPLOY.md Azure App Service runbook
├── DESIGN.md Design-system rationale
└── src/
├── Qwertide.Client/ Blazor WebAssembly UI + pure scoring engine
│ ├── Services/TypingSession.cs pure, UI-free scoring engine (test surface)
│ ├── Services/ILeaderboardService.cs + ApiLeaderboardService.cs
│ ├── Pages/ Components/ Layout/ game screens, per-glyph render, shell
│ └── Theme/ wwwroot/css/ custom MudBlazor theme + design system
├── Qwertide.Api/ ASP.NET Core leaderboard API
│ ├── Controllers/ScoresController.cs
│ ├── Models/ (Score entity + ScoreRequest DTO)
│ ├── Data/ (DbContext, seeder) + Migrations/
│ └── Program.cs pipeline: rate limiting, headers, CORS, SPA host
└── Qwertide.Tests/ xUnit tests for the scoring engine
Requires the .NET 8 SDK.
git clone https://github.com/ManuelMadu/Qwertide.git
cd Qwertide
dotnet restoreRun the API + client together (production-like single service):
dotnet run --project src/Qwertide.Api
# open the printed http://localhost:5229 (https://localhost:7237) URLRun the client standalone against a separately-running API (dev):
# terminal 1
dotnet run --project src/Qwertide.Api # API on http://localhost:5229
# terminal 2
dotnet run --project src/Qwertide.Client # client points at the API per appsettings.Development.jsonBlazor WASM is served as a cached static bundle, so after a change rebuild and do a hard refresh (Cmd/Ctrl + Shift + R) to clear the cached WASM.
Database: EF Core applies migrations automatically on startup and seeds a few
entries when empty — no manual dotnet ef database update needed for a fresh clone.
Configuration is layered via appsettings.json + appsettings.{Environment}.json,
overridable by environment variables (double-underscore notation).
API (Qwertide.Api)
| Key | Purpose | Default |
|---|---|---|
ConnectionStrings__Qwertide |
SQLite connection string | Data Source=qwertide.db (prod: /home/qwertide.db) |
Cors__AllowedOrigins__0 |
Allowed CORS origin(s) | dev localhost origins |
ASPNETCORE_ENVIRONMENT |
Environment (gates Swagger, HSTS, proxy trust) | Production on Azure |
Client (Qwertide.Client/wwwroot)
| Key | Purpose | Default |
|---|---|---|
Api:BaseUrl |
API base URL | empty = same origin (prod); http://localhost:5229 in dev |
- No authentication. A typing game's leaderboard doesn't need accounts, so the
API is anonymous. The trade-off, which I took on purpose, is that scores are
client-submitted and not server-verified.
[Range]validation blocks absurd values but won't catch a plausible fake. Real anti-cheat would mean server-side gameplay validation or auth, and that's out of scope here. - SQLite over a managed SQL service. Zero-cost, zero-ops, and ideal for a single-instance portfolio app, at the cost of horizontal scalability (see below).
- Migrate-on-startup. Convenient for a one-instance deploy; a controlled migration step would be preferable at production scale.
- Single-service deploy. Simpler ops and no prod CORS, traded against the ability to scale the API and static hosting independently.
The gaps, spelled out so the scope is clear:
- Scores are not authenticated or server-verified (spoofable by design).
- SQLite is single-node — the app cannot currently scale out to multiple App Service instances without changing the data store.
- Tests cover the scoring engine only — no API integration, component, or E2E tests yet; coverage is not published or gated.
- No automated CD, no containerization — deployment is a manual, documented zip deploy.
- Limited observability — a database-backed
/healthendpoint exists, but there is no Application Insights, structured logging, metrics, or alerting yet. - Fonts are loaded from the Google Fonts CDN in production (self-hosting is noted as a TODO for the performance/privacy budget).
- The
localStorageleaderboard implementation is not wired in as a runtime offline fallback; it exists to demonstrate theILeaderboardServiceabstraction.
Roughly in priority order. None of these exist yet:
- API integration tests with
WebApplicationFactory, plus bUnit component tests and a Playwright E2E happy-path; publish coverage from CI. - Continuous deployment — extend the workflow to deploy on green
main. - Containerization — a Dockerfile for reproducible builds and portability.
- Deeper observability — Application Insights, structured logging, and
metrics/alerting (a database-backed
/healthendpoint is already in place). - Scale-ready persistence — move to Azure SQL / PostgreSQL if multi-instance hosting is needed, with a controlled migration step.
- Self-hosted fonts to remove the third-party CDN dependency.
v1 is complete and deployed:
- M1 Core game: passage render, keystroke capture, live highlight, timer
- M2 Scoring engine wired into the results screen
- M3 xUnit tests for WPM, accuracy, and edge cases
- M4 Leaderboard API: ASP.NET Core + EF Core + SQLite
- M5 Connect the client to the API
- M6 Styling, difficulty levels, reduced-motion support
- M7 Deploy to Azure App Service (public URL)
- M8 API hardening: rate limiting, security headers, HSTS, SPA 404 carve-out
- M9 Ops & quality:
/healthcheck, CI format gate, Dependabot, MIT license
Released under the MIT License.