1 · Anatomy of a game
Every TEXTCADE game is the same console: a board of nodes, a handful of commands, an advisor that coaches, and a field log that surfaces the real concept behind each move. Play is turn-based: spend energy on commands, end the turn, the world responds, repeat until a win or lose rule fires.
- Nodes are the regions of your system — organs, servers, habitats, market
sectors. Each carries numeric fields (like
load) and can be adjacent to others, which is how things spread. - Commands are the verbs. Each costs energy, may target a node, and runs effects that change state.
- Facts fire once, the first time their key is triggered — this is where the learning lands.
- Win/lose rules are checked after every action; the debrief screen states the lesson either way.
The design bar for the arcade: the winning strategy should be the lesson. If a player can win while ignoring the concept you're teaching, tighten the rules until the concept is the strategy.
2 · Build your first game
Open /submit. The builder starts with HEAT SINK, a small complete game — the fastest path is to reshape it into yours while the live preview keeps proving it still plays.
- Overview — title, subject, grade band, the one-line “learn”, turns and energy per turn. Short games teach best: 6–10 turns.
- The board — 2–16 nodes. Give each an
id(slug), a display name, a role, adjacency, and starting field values. Mark a node critical if it overloading should end the game. - Commands — 1–8 verbs. A good game has a cheap local action and an expensive systemic one, so every turn is a real tradeoff.
- Python scripting (optional) — skip it for your first game, or see section 4.
- Turn & advisor rules — what the world does each turn (spread/replicate), and advisor lines checked top-to-bottom.
- Field-log facts — one honest, sourceable sentence per key.
- Win & lose — win first, then losses, and always end with a turn-limit
rule like
turn > turn.max.
The preview is the real engine — the exact thing players get. Playtest until you can lose on purpose and win on purpose.
3 · Expressions & effects (the no-code way)
Anywhere the builder says expr, you write a small read-only expression.
In display text, {expr} interpolates a value and
{expr|round} rounds it.
| Read | turn · turn.max · your vars by name ·
node.load (inside a node context) · node('core').load |
|---|---|
| Math & logic | + - * / % · == != < <= > >= ·
&& || ! · cond ? a : b |
| Aggregates | sum(…) count(…) all(…)
any(…) — evaluated over every node |
| Functions | min max clamp round floor abs |
Effects are how commands change the game (they're the only way state changes):
setNode | { "setNode": { "field": "load", "value": "max(0, node.load - 25)" } } — writes a field on the target node |
|---|---|
setVar | writes a game variable |
log | adds a field-log line (small HTML subset allowed); optional when |
fact | fires a fact key (shows once) |
spend | spends extra energy |
pulse | flashes a node on the board |
forEachNode | runs a do list on every node, with optional when filter and pulse |
when | conditional: cond, do, optional else |
Display strings allow only <b> <em> <i> <span class> <br> —
everything else is stripped. The full worked example is
IMMUNE // OUTBREAK, which ships as pure JSON.
Quoting: two traps worth knowing
Your expressions live inside a JSON file, so two syntaxes are stacked on top of each other. The first trap below makes the file invalid JSON; the second passes JSON but fails validation, so the spec will not publish. Both are easy to avoid once you have seen them.
1 · Use single quotes inside expressions. A double quote ends the JSON string it is sitting in. The DSL accepts either kind, so single quotes are always the easy answer:
"value": "round(node("jobs").stress)" ✗ the inner quote ends the JSON string
"value": "round(node('jobs').stress)" ✓
2 · || is rejected inside {…}. In display text
the brace syntax reads {expr|round}, where | introduces a
formatter — so validation refuses a template containing || and your
spec will not publish. && is fine, and so is ||
outside braces, in when, enabled, targetable
and other plain expressions. Inside braces, count instead:
"text": "{node('a').gone || node('b').gone ? 'one fell' : 'both hold'}" ✗ rejected
"text": "{(node('a').gone ? 1 : 0) + (node('b').gone ? 1 : 0) > 0 ? 'one fell' : 'both hold'}" ✓
An apostrophe inside a single-quoted expression string needs escaping through both layers, which is fiddly enough that rewording is usually quicker — write the imperial authority rather than the Emperor's authority.
Neither is a subtle bug — the submit page runs the same validation the server does and will tell you before you publish. They are just much quicker to spot when you already know what you are looking at.
4 · Python scripting
When the DSL feels tight, script your logic in real Python — f-strings, loops, functions, the works. Open the Python scripting step in the builder (a proper editor with syntax highlighting loads right there), write top-level functions, and point hooks at them by name.
Where Python can run
| A command's effects | switch the command's effects mode to python function and give the function name |
|---|---|
onEndTurn | { "effects": { "python": "end_turn" } } |
checkEnd rule | { "python": "check_end" } — mixes freely with DSL rules |
nextTurnEnergy | { "python": "energy" } — return an int |
Render-time fields (targetable, enabled, nodeView,
advisor rules) stay expressions — they run dozens of times per frame.
Hook signatures
def fn(game, node): # command that targets a node def fn(game): # no-target command, onEndTurn, checkEnd, nextTurnEnergy
The API
game.turn / game.turn_max / game.ap | read-only numbers |
|---|---|
game.vars["x"] | your variables — read and write |
game.nodes / game.node("id") | node list / lookup. Fields read & write
as attributes: node.load = 0. node.id and node.adj are read-only. |
game.log(html, cls="") | field-log line (same HTML subset as the DSL) |
game.fact(key) | fire a fact |
game.spend(n) | spend extra energy |
game.pulse(node) | flash a node (pass a node or its id) |
A complete example
def cool(game, node):
before = node.load
node.load = max(0, node.load - 25)
game.log(f"Cooled <b>{node.name}</b>: {before:.0f} -> {node.load:.0f}")
def end_turn(game):
for n in game.nodes:
n.load = min(100, n.load + 6)
def check_end(game):
if all(n.load <= 0 for n in game.nodes):
return {"win": True, "title": "STABILISED",
"lessons": ["System-wide thinking beats local fixes."]}
return None # keep playing
Load the full version in the builder: paste tab → the
HEAT SINK // PY demo spec (in the repo at
src/games/heat-sink-py.json).
Rules & limits
- Store state in
game.vars, not Python globals — module globals reset when the player hits replay. - Scripts get ~2 seconds per move; a hung script is stopped, the move is refunded, and the game continues.
- No network, no files, no imports beyond built-ins. Your code runs in a sandbox (MicroPython in an isolated worker) and changes the game only through the API above.
- Everything you log or write is sanitized exactly like DSL output.
- Source limit 20,000 characters.
When something breaks
The live preview shows real tracebacks in the field log — line numbers point into your code. You can't submit until the preview compiles your Python cleanly. Players never see tracebacks; they get a friendly “this game's script hit an error”.
5 · Submitting & review
- Submit — the spec is validated (same validator, client and server), stored, and you get a private status link. Save it; it's the only handle on your submission.
- Review — a human plays your game. The checklist: factually correct · a real tradeoff (no dominant strategy) · the advisor teaches · win and lose states state the lesson · and for Python games, the code is read for runaway loops and abuse.
- Outcome — published to the arcade at
/g/<id>, or returned with feedback. “Edit & resubmit” from your status page reopens the exact spec in the builder.
Rate limit: 8 submissions per hour. Credit appears as the creator name you give.
Found a bug, hit a wall, or want a feature? Email admin@find-server.com.