# Card battler template with AI opponents (INKWRIT)

> INKWRIT is a one-against-one online card battler: decks shuffled on the server, hidden hands, mana, units and spells, guards and rush, a draft between duels, and a gauntlet of AI opponents for a player alone. Start it with one command and change it in src/game.ts.

Net Forge, the open-source multiplayer browser game engine built for AI coding agents, is published on npm under `@netforge` with source at https://github.com/netnetdev/net-forge.

Source: https://forge.netnet.capital/docs/templates/card-battler  
Last updated: 2026-10-23. Checked against @netforge/engine 0.2.0.

INKWRIT is a complete online card battler for two players: each hero has health, ink (mana) rises each turn, hands are drawn from decks the server shuffles and keeps secret, and units and spells fight on a shared desk. A run is a gauntlet of four duels with a draft of one of three cards after each, and a player alone faces AI opponents of rising strength.

```sh
npm create netforge@latest my-game -- --template cards --yes
cd my-game
npm run play
```

Open the link in one tab to play the AI gauntlet. Open it in a second tab and the two of you duel each other.

## What you get

| Part | What it does | Where |
|---|---|---|
| The table | Hero health, starting hand, hand and board limits, mana cap, turn and draft timers, duels per run, the starter deck | `cardTable({...})` |
| Cards | 4 to 40 cards: units (attack, health, guard, rush) and spells, each with an optional effect | `card(...)` |
| Effects | `damage`, `heal`, `draw`, `buff` on targets such as one enemy, all enemies, your hero or one of your units | `card({ effect })` |
| AI opponents | 1 to 12 tiers of health and play strength; a seat bot joins a player who is alone | `cardTable({ ai })` |
| Hidden information | Deck order never leaves the server; each hand and draft offer goes only to its owner | the engine |
| Netcode | 30 Hz authoritative room; plays, attacks, end of turn and draft picks are validated actions | the template package |
| Playtest bots | Two players for four minutes: duels decided, earn | `npm test` |

Measured on an Apple M2 Pro, Node 22: two bots over four minutes (7,200 ticks at 30 Hz) played into the fourth duel of the gauntlet and completed three runs, at 0.002 ms per server tick on average.

## 1. Add a card

```ts title=src/game.ts
import { card } from "@netforge/engine";

card("mirror_moth", {
  model: "style.lowpoly.jelly_flyer",
  kind: "unit",
  cost: 3,
  attack: 2,
  health: 2,
  effect: { kind: "damage", amount: 1, target: "all_enemies" },
});
card("final_word", { model: "style.lowpoly.crystal_rock", kind: "spell", cost: 5, effect: { kind: "damage", amount: 5, target: "enemy" } });
```

A unit needs health of 1 or more and a spell needs an effect. Add a label in `src/names.json`. The draft offers every card unless `cardTable({ pool })` lists a subset.

## 2. Tune the table and the AI

```ts title=src/game.ts
import { cardTable } from "@netforge/engine";

cardTable({
  health: 25,
  startHand: 3,
  handMax: 7,
  boardMax: 5,
  manaMax: 10,
  turnSeconds: 40,
  draftSeconds: 15,
  duels: 5,
  starter: ["blot_imp", "blot_imp", "quill_sprite", "quill_sprite", "ink_frog", "ink_frog", "margin_bat", "ledger_crab", "serif_fox", "splash", "splash", "blotting_sand"],
  ai: [
    { health: 14, smart: 0.3 },
    { health: 16, smart: 0.55 },
    { health: 20, smart: 0.75 },
    { health: 24, smart: 0.9 },
    { health: 28, smart: 1 },
  ],
});
```

Duel n against the AI uses tier n. Lower hero health makes faster duels; more duels make longer runs. The starter deck holds 8 to 30 declared cards, with at least one unit and some cards costing 2 or less.

## 3. Add an earn rule

```ts title=src/game.ts
import { earn } from "@netforge/engine";

earn.rule("ink_master", { when: "ai.beaten", n: 3, pay: 15, oncePer: "run" });
```

`duel.won` counts duels won against a person (never against the AI, never a forfeit), `ai.beaten` counts AI opponents beaten, and `run.won` counts runs with more duels won than lost. The AI never earns.

## 4. Check it with bots

```sh
npm test
```

## How it works

A duel is a board genre: a deterministic rules module in `@netforge/engine/rules` with integer state and a seeded random number on the room server. The server shuffles, keeps the deck order to itself, and sends each player only their own hand and draft offer; snapshots carry the cursors only. The hosted room server is tested for hidden-card leaks every few ticks of a run, and nothing in `src/game.ts` can change that. Turns, the turn timer, guards and rush are part of the genre, so your file only declares the cards and the table.

The full guide is [Build an online card game with turns and hidden information](https://forge.netnet.capital/docs/guides/online-card-game.md).

## Alternatives

- **[boardgame.io](https://boardgame.io/documentation/)** is an open-source JavaScript engine for turn-based games, with moves, phases, secret state, a lobby and multiplayer built in. Pick it when you want to describe any turn-based rules in your own code and draw them with your own UI.
- **[Colyseus](https://docs.colyseus.io/)** is a multiplayer server framework with rooms and state sync. Pick it when you have a card client already and want a server to hold the game state.
- **Net Forge** fits when you want a finished card battler with a 3D desk, server-side shuffles, hidden hands, AI opponents and a draft, and you want to change the cards rather than build the duel.

## FAQ

### How does the card game keep hands hidden?

The room server shuffles from its own seeded random number and keeps deck order to itself. A hand and a draft offer are sent only to their owner through a per-player channel, and snapshots carry only the players' cursors.

### Can I play against the computer?

Yes. A player alone for 3 seconds gets an AI opponent from the gauntlet's tier for that duel. When a second person joins, the AI duel is called off and the two people play each other.

### How many cards can a game have?

From 4 to 40 cards, with a starter deck of 8 to 30.

### Is it turn-based with a timer?

Yes. Each turn has `turnSeconds` (30 in the template), and the timer ends the turn if the player does not. A duel that reaches the turn cap is a draw.

### Can I make it a deckbuilder?

The draft between duels already adds one of three cards to your deck each time. You control which cards it offers with `cardTable({ pool })`.
