Skip to content

Build1 publisher3 min readPublished

Send kills, not scores: the leaderboard fix that turns anti-cheat into a schema decision

A Space Invaders build on AWS accepts kill events instead of scores and recomputes the total in a pure function. ECS Express Mode absorbs the ALB, certificate and DNS that choice drags along.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • The author, publishing as roxsross on dev.to, left a Space Invaders game running on AWS and says the hardest part was not the game but that a leaderboard whose server trusts the client-sent score fills with cheaters in five minutes; the result was a backend that receives kill events instead of scores plus an 8-rule anti-cheat that recomputes the score server-side.
  • The inevitable bug of a global leaderboard: the client sends { nickname, score: 999999 } and the server stores it; it works until someone with devtools open notices.
  • The naive solution is to send the score encrypted from the client; the better defence is not to trust the client score at all, having the client send raw events and the server do the summing, which the author calls a decision rather than a feature.
  • The client does not send the final score; it sends an array of kill events shaped { type, level, t }.
  • The server recomputes the score using an official KILL_POINTS table (small=30, medium=20, large=10, ufo=150), the single source of truth for recomputation.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A developer publishing as roxsross put a Space Invaders clone on AWS and reported that the hard part was not the game but the leaderboard: a ranking that trusts the score the client sends fills up with cheats almost immediately [1]. The interesting part is where the fix lives, which is in the request schema rather than in the client binary.

The failure mode is familiar. The browser posts `{ nickname, score: 999999 }`, the server stores it, and everything works until someone with devtools open notices [2]. According to the post, the naive answer is to encrypt the score on the client; the better one is to stop trusting the client's score at all and have it send raw events while the server does the addition, which the author frames as a decision rather than a feature [3].

So the client sends an array of kill events shaped `{ type, level, t }` [4], and the server recomputes the total from an official points table: small=30, medium=20, large=10, ufo=150 [5]. The validator, `validateScoreSubmission` in `src/scores.mjs`, is a pure module with no I/O that runs eight rules in strict order, first failure wins [6]. The early ones are shape and hygiene: `malformed_request`, `missing_fields`, `invalid_nickname`, then `score_mismatch`, which compares the declared score against the recomputed one and rejects with an `expected` value [7]. The middle ones are budgets rather than heuristics: a run cannot be shorter than 8 seconds, cannot contain more than 55 kills in a level, and cannot sustain more than 10 kills per second [8].

Those numbers set a hard ceiling. At 55 kills per level and 150 points for the best target, a level is worth at most 8,250 points, so the classic `score: 999999` would require more than 121 levels of flawless UFO hunting to survive recomputation [9]. When validation passes, the function returns the recomputed score and a server-side `playedAt` as authoritative, and the `POST /api/scores` handler persists those, never `req.body.score` [10]. The only module that touches AWS, `src/db.mjs`, also mints `scoreId` and `playedAt` itself and never accepts the client's, and it maps SDK failures into typed errors that the HTTP layer turns into 503 [11]. Fastify's schema runs first for shape, the semantic rules second, persistence last, and `additionalProperties` is left true on purpose: a client can post a bogus `scoreId` and the server simply discards it [12].

The deployment side is the same argument applied to infrastructure. A game service on classic ECS means VPC, subnets, security groups, ALB, target groups, listeners, an ACM certificate, auto-scaling and IAM policies, all of which can be written wrong in twenty ways [13]. Here the whole Terraform stack is five resources: an ECR repository, a DynamoDB table and three IAM roles [14], with ALB, certificate, target group and DNS provisioned by AWS [15]. The author says Express Mode saved writing about thirty Terraform resources [16], which puts the hand-written share at roughly one seventh of the total [17].

Two things to watch. The rules described in the post validate arithmetic and rates, but none of them binds the submitted events to a session the server observed, so the residual attack is a patient client posting a plausible event array under the caps [18]. And the caps are game-design constants: change level pacing or spawn counts and 55 kills or 8 seconds stops describing legitimate play. On the infrastructure side, the same trade reappears when you need to touch a listener rule or a certificate that AWS now owns [15].

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories