cd /news/developer-tools/stress-test-pen-test-yes-tests · home topics developer-tools article
[ARTICLE · art-127952] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Stress Test? Pen Test? Yes Tests!

A developer shipping the browser-based trading card game Prismwar found that a mutation campaign of 70 deliberately injected bugs left 31 undetected by the original test suite, including dropped deck-validation guards, discarded blocker state, and an integrity check that accepted unsigned save files granting 720 cards and a million wins. The team traced the failures to a test harness that shared defects with the code under test, since record/replay round trips exercised the same buggy path on both sides. They replaced outcome comparisons with entry guards at the newGame() choke point, property assertions, and a SHA-256 fingerprint over a 512-game corpus to detect any drift in AI or rules behavior.

by read4 min views2 publishedSep 12, 2026

Published on DHSeaDev. A hard-won lesson from shipping a browser-based TCG.

When we stress-tested Prismwar, our original gate suite reported everything was fine. Then we ran a mutation campaign, and 31 out of 70 carefully crafted bugs survived without raising a flag. Here's what went wrong—and how we fixed it.

The first blind spot was the most embarrassing: our test harness shared defects with the code it was supposed to verify.

We had a replay system that recorded matches as a seed, both decks, and the player's actions. The Rival's moves are derived at playback time by running the same AI code. When we tested replay fidelity, we recorded and played back ten matches—and all ten matched perfectly.

Sounds good, right? Wrong.

The issue: our harness omitted the AI's defence step in the replay player, and our scripted human player omitted it in the recorder. So the test round-tripped through the same bug and agreed with itself. A round trip cannot test its own driver. Neither can it test its engine. We rebuilt the test to drive the AI's defence window directly and assert the match phase advances—that found it immediately.

The lesson applies everywhere: when testing a system that has two paths (encode/decode, server/client, record/replay), don't let both paths share the same code on the test side. Property assertions work. Round trips don't.

The mutation campaign replaced 70 isolated lines of code with deliberate defects. 39 made the game unplayable. But 31 made the game fail silently:

Dropped guards: validateDeck checks deck legality before a match starts. We deleted the lower-bound check. The gate passed. Invalid 4-card decks loaded fine and then threw an uncaught error in checkWin as soon as someone attacked. The error boundary swallowed it into a "Something went wrong" message, and the assertion "no console errors" stayed green.

Discarded state: An attacker declared blockers but the code forgot to store which ones. The match proceeded as if nothing was blocked. Replays still worked and reproduced correctly (for the wrong reason—the entire engine had the same bug).

Unsigned profiles: The integrity check exempted unsigned saves to allow mid-session work before signing. An attacker simply wrote a JSON with no sig field, granting 720 cards and a million wins. It imported as integrity:'ok'.

Every one of these would have made it into production without sabotage-driven proof.

We stopped relying on "no crash" and "match outcomes are deterministic" as evidence. Instead:

Entry guards at the choke point. The game's state machine enters through newGame(). We made that the single place that refuses invalid inputs: non-array decks, decks under 10 cards, missing players. Everything downstream assumes the precondition is true. One sabotage per boundary, and each one must actually throw (not silently default). We verified that the guard itself catches that attack—not just that the game doesn't crash.

Property assertions instead of outcome comparison. Instead of "does the game end in 50 turns or fewer," we assert that the stall detector measures progress—and if you remove that measurement, the test fails. The property is "this mechanism exists and is consulted," not "bad state doesn't happen."

Pinned baselines for non-observable code paths. The AI engine has zero randomness once seeded. We computed a SHA-256 fingerprint over the outcomes of a 512-game corpus. Any change to the rules, the AI decision weights, or the game logic moves that hash. We assert it doesn't move. If it does, we decide to bump it—or revert. But "no change" is measured, not assumed.

Instrument-proved test instrumentality. Before we ran our 70 mutations, we proved the harness could see a break by installing an attacker key and verifying that it unlocked a legendary card it shouldn't. When we replaced the stall detector with a board-count check, we proved the harness caught it by deliberately deleting the right code and watching the test fail.

Security tests are not about passing. They're about finding specific ways the code could fail and then proving you catch every one:

This is inverse from "does my happy path work." You're asking: "What specifically am I refusing?"

For Prismwar: textContent, not string concatenation).

Final count: 122 assertions, 99 sabotages, 0 escapes, control green. The gates ran against a real build packaged for the Chrome Web Store. Not a best-case scenario. The actual game. Three automated checks broke themselves in the process:

When you're shipping something that handles user data—saves, settings, payment—or something competitive where cheating matters, your test suite needs to be adversarial:

Prismwar ships with a ledger wall where users post their best cards. That wall is read-only. The game's state is signed, and the signature is verified on load. Every one of these decisions is backed by a sabotage that proves what fails if you remove it.

That's not perfection. It's shipping with evidence.

Prismwar is live at play.dhseadev.online./ The gate suite is in the DHSeaDev arcade build at scripts/verify-prismwar.mjs and sabotage-prismwar.mjs on GitHub.

── more in #developer-tools 4 stories · sorted by recency
── more on @prismwar 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/stress-test-pen-test…] indexed:0 read:4min 2026-09-12 ·