Skip to content

Latest commit

 

History

History
371 lines (293 loc) · 17.5 KB

File metadata and controls

371 lines (293 loc) · 17.5 KB

Qwertide

CI

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.


Table of contents


Overview

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.

Key features

  • 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.

Architecture

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 TypingSession as 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 second localStorage implementation 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.html for client-side routes. That leaves one deployable, one origin, and no CORS to worry about in prod.

The scoring engine

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); // -> 90

CountKeystrokes 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.

Tech stack

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)

API documentation

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.

Security

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 headersX-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer on every response.
  • HSTS in production; HTTPS redirection enabled; HTTPS-only enforced at the platform.
  • Reverse-proxy awarenessX-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 real 404 instead 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.

Testing

dotnet test

21 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).

CI/CD

GitHub Actions (.github/workflows/ci.yml) runs on every push and pull request to main:

  1. Restore (dotnet restore)
  2. Format check (dotnet format --verify-no-changes) — style violations fail the build
  3. Build in Release with -warnaserror — warnings fail the build
  4. 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.

Deployment

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.

Performance considerations

  • Indexed leaderboard reads — a DB index on Wpm backs the primary ordering.
  • Bounded queriestop is 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.

Accessibility

  • 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.

Project structure

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

Local development

Requires the .NET 8 SDK.

git clone https://github.com/ManuelMadu/Qwertide.git
cd Qwertide
dotnet restore

Run the API + client together (production-like single service):

dotnet run --project src/Qwertide.Api
# open the printed http://localhost:5229 (https://localhost:7237) URL

Run 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.json

Blazor 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.

Environment variables

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

Engineering tradeoffs

  • 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.

Known limitations

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 /health endpoint 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 localStorage leaderboard implementation is not wired in as a runtime offline fallback; it exists to demonstrate the ILeaderboardService abstraction.

Future improvements (not yet implemented)

Roughly in priority order. None of these exist yet:

  1. API integration tests with WebApplicationFactory, plus bUnit component tests and a Playwright E2E happy-path; publish coverage from CI.
  2. Continuous deployment — extend the workflow to deploy on green main.
  3. Containerization — a Dockerfile for reproducible builds and portability.
  4. Deeper observability — Application Insights, structured logging, and metrics/alerting (a database-backed /health endpoint is already in place).
  5. Scale-ready persistence — move to Azure SQL / PostgreSQL if multi-instance hosting is needed, with a controlled migration step.
  6. Self-hosted fonts to remove the third-party CDN dependency.

Project status

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: /health check, CI format gate, Dependabot, MIT license

License

Released under the MIT License.