Marking the answer, not the spelling
Hacking and SOC game · 103 contracts · Turkish and English
# summary
> A hacking and SOC game in which finishing an attack seats you in the SOC the client outsources to, and your own actions arrive in the alert queue you now have to triage. Its objectives are satisfied by a fact having been read on the machine it lives on, not by the shape of the command that read it.
# the problem
> A game that teaches by grading has one failure mode worth building the whole thing around: paying for something the player did not do. Matching a regular expression against the command line is the cheap way to grade a terminal, and it is wrong in a specific direction. It pays the same for reading the wrong file, for reading nothing at all, and for typing the right string on a machine the player never logged into. The player is not told. They collect the points, conclude the lesson landed, and carry the wrong habit into the next contract.
> That is not a hypothetical, and it was ours. Counted on 2026-09-05, before the work described below, 34% of Act IV objectives were graded on a pattern over a reading command, against 8% in Act I, and cat rol-tanimlari, typed on the player's own box where no such file exists, completed "read the role definitions". The design question was not how to make the regular expressions stricter. It was what a contract can hook that stays true whatever the player typed.
# what we built
> 103 contracts across four acts, written in Turkish and in English, neither one a translation of the other. Vite, TypeScript, React 19 and xterm.js, with no game engine underneath: the shell is hand-written and answers 55 commands, all of them real ones. nmap, hydra, gobuster, ssh, scp, curl, grep, sha256sum, airodump-ng. No invented syntax, and every flag the shell accepts does something.
> Behind the campaign sit 136 simulated hosts with 188 open ports, 67 web routes and 270 files, all of it declared in YAML rather than in code. 46 contracts are hand-written and the other 57 come from 19 templates. The game runs in a browser, as a 2.6 MB single file for playtests, and as a Tauri desktop build that was packaged into a Steam depot on 2026-09-07.
> The part worth writing up is the second half of each contract. When the attack is finished the game moves the player to the night shift at the SOC the client outsources to and hands them the last hour of alerts. Across the 100 contracts CI can replay, those queues hold 1,000 rows and 237 of them exist because the player did something; the rest is authored office traffic. Nothing in the queue is invented at debrief time. Rows are decoded from the telemetry of the run that just happened, which is the whole difference between this and a scripted alert list.



# technical decisions
An objective is closed by the file having been opened, not by the command that opened it.
A contract declares discoveries, and a discovery names a host and an absolute path. Opening that path on that machine grants a finding; the objective waits for the finding. Which reader was used stops mattering, so head -40 now finishes work an old pattern accepted only from cat, and cat of a file that is not on this machine finishes nothing.
Counted 2026-09-07 across content/act1..4: 428 objectives, of which 93 are satisfied by a discovery. The 79 discoveries behind them are 66 on a read, 8 on what a command reported, 4 on a fetch and 1 on the body of a response. 70 objectives were moved off command patterns in four commits on 5 and 6 September, in runs of 21, 27, 13 and 9. The second of those corrects its own earlier estimate in its message: thirty had been counted, twenty-seven were reads, because the count matched cat anywhere in a pattern and (rm|truncate|>) contains one.
The distinction that took longest is between reading and reporting. m30 asks the player to reduce a monthly manual check to one command. Graded on the command string it paid 240 xp for grep -c hayir /etc/hosts, which reads an unrelated file and prints nothing, and paid nothing for the command that actually lists the four machines with no agent on them. It is graded on output now, so every honest route counts and reading the whole file by eye does not, because reading it by eye is the manual check the contract exists to replace.
Three command patterns over a reading command survive on purpose, all three the same objective in three variants of one template: what it grades is the choice of -c over reading by eye, and that choice is the command. One more, m09's locate step, still matches (ls|find|cat). It grades a search rather than a read, which is the same argument the templates make for their own find, but it has not been looked at since and should not be described as settled.
The cost is that 21 hand-typed absolute paths became load-bearing, and one wrong character makes an objective permanently unreachable while the file still parses and still validates. A lint shipped in the same commit: every declared path must exist on the host that claims it, and every finding an objective waits for must be granted by something. Without it the replay fails with "objective not reached" and leaves you to guess which end is wrong.
src/ knows no mission names.
Everything a contract is lives in YAML under content/, pulled in with import.meta.glob. Checked 2026-09-07 by grepping all 103 mission ids across the tree: not one of them appears anywhere in src/. The suite asserts the reverse direction as well, that nothing the player reads names a mission file, across all 103.
The price is paid every time a contract wants something the schema cannot say. The honest move is to grow the schema; the tempting one is a special case in the engine, and the second is invisible until somebody writes contract 104. One example of getting it wrong: reaches, the field by which a contract declares a segmented network, was read as an allow-list, so a host that declared nothing reached nothing. Exactly five contracts declare it, so in the other 98 two machines on the same flat /24 could not see each other, and ssh from inside one of them died with "Network is unreachable" while the identical command worked from the kit. Two playtesters lost an evening to it, one of them hunting for a tunnel that was never needed. A flat subnet is flat now, and the five contracts that declare segmentation keep the old behaviour, because for them it is the lesson.
The generator stamps what it wrote and will not overwrite a hand edit.
Hand-writing a hundred short contracts is not realistic and hand-writing a hundred good ones certainly is not, but most of a short contract is the same job with different facts in it. So the split is that the template carries every word of prose and the variant carries only facts: a client, an address, a port, a credential. 19 templates, 57 contracts. The generator writes real YAML to disk rather than objects in memory, which is the part that pays for itself, because every test the campaign already has then applies to generated contracts for free.
Each generated file carries a # sn:generated <hash> line: the first 12 hex digits of the sha256 of the body below it. If the file no longer hashes to its own stamp then somebody edited it by hand, and the generator leaves it alone rather than eating the work. npm run test:generated runs the same comparison in CI and separates the three failures that mean different things: hand-edited, stale because the template moved on, and missing because the template declares a variant no file exists for. Run 2026-09-07: 57 generated contracts match their templates, 0 hand-edited.
It also checks the documentation against the code, which was not the original plan. The roadmap's table of contract shapes listed 13 of the 19 that exist, and the six it was missing included all three that pay in the illegal currency. The file even disagreed with itself: a count above the table said thirteen while a sentence forty lines below said nineteen. That table is a build failure when it drifts now.
Every command the game prints is executed before the build passes.
Hints and man pages are the game speaking in its own voice, and a command it shows and then rejects is the worst defect available here: the player concludes the simulation is fake, which it partly is, and stops trusting the parts that are not. So the audit pulls every <code> span out of the 342 hints in both languages, builds a real session for the contract each hint belongs to, and runs the command through the same shell the player uses. 414 commands on the run of 2026-09-07, 337 of them distinct.
What it looks for is a rejection of the syntax rather than a legitimate "you are not there yet": ten patterns covering invalid option, a bare usage: line, command not found and the rest, plus anything that threw. Gear-gated contracts are given exactly the gear they declare they require, otherwise the audit reports "command not found" for a tool the contract is correctly withholding.
The same idea one level up: every contract declares a reference solution and CI plays it end to end, checking that each objective closes, that the trace stays under its threshold, and that the attempt counts in the prose match the log the player is asked to count. 100 of the 103 replay in CI. The other three run against real Docker containers and real log lines, so they need the lab server and are covered by npm run test:lab instead.
Steam copies a folder, so the game has to survive not being installed.
The desktop window is a WebView2 control, which means the game depends on a Microsoft runtime it does not carry. On Windows 11 that runtime is part of the OS. On an older or a stripped install it can be missing, and the failure takes the worst available shape: Tauri cannot create a window, so there is no window in which to say why. Steam makes it worse by not running an installer at all. It copies a directory and runs one file inside it, so the bootstrapper a normal install would have run never runs, the player double-clicks, nothing happens, and the review says the game is broken.
src-tauri/src/preflight.rs asks Tauri's own lookup whether the runtime is there, which is the same question the failing code will ask. If it is missing and Microsoft's bootstrapper is sitting beside the executable, it runs it and waits rather than spawning, because the point is to be ready afterwards. If that is not possible or did not work, it puts up a system-modal dialog and opens the download page. Exiting in silence is the one outcome worth writing code to avoid.
The alternative was the fixed runtime, which embeds the whole browser beside the executable and depends on nothing. Measured on disk 2026-09-07: 837,201,247 bytes of Microsoft redistributable around a 9,074,176-byte game. The depot we actually build is 10,857,638 bytes, of which 1,783,000 is the bootstrapper that on most machines never runs. Ninety times the download for a case that mostly does not happen is a bad trade to make on everyone's behalf, so npm run steam -- --fixed still builds the other shape, for the day somebody turns up who cannot install anything.
# stack
├─ Vite 7, TypeScript 5.9, React 19. No game engine ├─ xterm.js for the terminal; the shell behind it is hand-written, 55 │ commands over 2,616 lines of tools and shell ├─ All content in YAML, loaded with import.meta.glob: 35,580 lines of it │ against 11,093 lines of TypeScript under src/ ├─ A template generator, 19 contract shapes and 57 contracts, each file │ stamped with a sha256 prefix of its own body ├─ Thirteen test packages run as esbuild-bundled scripts, no test │ framework: 520 named assertions plus 100 full contract replays ├─ Tauri 2 for the desktop and Steam builds, with a WebView2 preflight │ written in Rust ├─ Docker via Colima for layers 2 and 3: real containers, real log lines ├─ vite-plugin-singlefile for the 2.6 MB single-file playtest build └─ A four-event beacon, off unless an endpoint is configured, with no cookies, no referrer, no timings and nothing the player typed
# outcome
> Live at samerule.com. Verified from outside the network on 2026-09-07: the Turkish page, the English page and the game itself all return 200, and the origin sets content-security-policy: frame-ancestors 'none', X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Strict-Transport-Security, and a permissions policy that turns off geolocation, microphone, camera, payment and USB. There is no account, no install and no payment step.
> Measured 2026-09-07 at commit 9db5f9b: 103 contracts over four acts, 428 objectives and 342 hints, all of it in Turkish and English, with a test asserting that every document the game speaks through carries both, that the English is not the Turkish pasted twice, and that no Turkish is left inside an English sentence. Mapped onto the curriculum in content/syllabus.yaml, all 28 topics across five domains have at least one contract behind them. npm run test:fast prints 620 green lines across thirteen packages, which is 520 named assertions and 100 contracts replayed end to end, with 3 skipped because they need the Docker lab. The Steam depot builds to 10.4 MB.
> Four things are open, and are worth saying rather than leaving to be discovered. The Steam depot is packaged and has never been uploaded: steam/app_build.vdf and steam/depot_build.vdf still read APPID and DEPOTID, so no Steamworks application exists and the store page is a markdown file. Three contracts run against real containers rather than the simulator, so CI replays 100 of 103 and the other three are exercised only when somebody starts the lab. m09's locate objective is still graded on a command pattern, for a reason that is defensible and has not been re-checked. And the repository is one week old: 141 commits, the first on 1 September 2026, which is not enough history for anybody to claim the design has settled.
> The number that matters is in none of that. A game with a difficulty curve lives or dies on where people stop, so the beacon records four events and the only one it exists for is the one that carries a contract id when somebody leaves a job without finishing it. It is off unless an endpoint is configured, so the desktop build, the single-file playtest artifact and anybody running from a file:// URL send nothing at all. Read from the collector on 2026-09-07, the funnel holds 0 events. The front door went up on 6 September, so there is nothing to report yet, and the honest version of this paragraph in a month is the one with a contract id in it.