C · Building a Game · Step 1

Card & map tables

Four new tables — cards, the map, a battle log, and the season — plus 15 typed queries. This is the entire persistence layer of the game.

▼ details below
Where you should be
Pre-work done, stack launched at least once. For this step the stack can be stopped: bunx orchestrator stop.

1The migration

Create packages/database/migrations/001-game.sql:

packages/database/migrations/001-game.sql · 43 linesdownload
-- Game tables: creature cards, the shared map, battle history, and the season.

CREATE TABLE cards (
  token_id TEXT PRIMARY KEY,
  owner TEXT NOT NULL,
  element TEXT NOT NULL,        -- 'fire' | 'water' | 'grass'
  power INTEGER NOT NULL,       -- 1..9
  name TEXT NOT NULL,
  block_height INTEGER NOT NULL
);
CREATE INDEX cards_owner_index ON cards(owner);

CREATE TABLE map_cells (
  x INTEGER NOT NULL,
  y INTEGER NOT NULL,
  token_id TEXT NOT NULL UNIQUE REFERENCES cards(token_id),
  owner TEXT NOT NULL,
  placed_at_height INTEGER NOT NULL,
  PRIMARY KEY (x, y)
);

CREATE TABLE battle_log (
  id SERIAL PRIMARY KEY,
  x INTEGER NOT NULL,
  y INTEGER NOT NULL,
  attacker TEXT NOT NULL,
  defender TEXT NOT NULL,
  attacker_token TEXT NOT NULL,
  defender_token TEXT NOT NULL,
  attacker_total REAL NOT NULL,
  defender_total REAL NOT NULL,
  attacker_won BOOLEAN NOT NULL,
  block_height INTEGER NOT NULL
);

-- Single-row table: the current season (set from the Midnight counter).
CREATE TABLE season (
  id INTEGER PRIMARY KEY CHECK (id = 1),
  element TEXT NOT NULL,        -- 'none' | 'fire' | 'water' | 'grass'
  counter_value INTEGER NOT NULL,
  block_height INTEGER NOT NULL
);
INSERT INTO season (id, element, counter_value, block_height) VALUES (1, 'none', 0, 0);

Worth noticing: map_cells has PRIMARY KEY (x, y) — one card per cell — and token_id UNIQUE — one cell per card. Two constraints, half the game rules.

2Register it

Replace packages/database/migration-order.ts:

packages/database/migration-order.ts · 14 linesdownload
import type { DBMigrations } from "@effectstream/runtime";
import databaseSql from "./migrations/000-init.sql" with { type: "text" };
import gameSql from "./migrations/001-game.sql" with { type: "text" };

export const migrationTable: DBMigrations[] = [
  {
    name: "000-init.sql",
    sql: databaseSql,
  },
  {
    name: "001-game.sql",
    sql: gameSql,
  },
];

The sync node applies migrations in this order when it boots. The template's 000-init.sql stays — it's harmless.

3The queries

Create packages/database/sql/game.sql — every read and write the game will ever do:

packages/database/sql/game.sql · 87 linesdownload
/* @name cardsTableExists */
SELECT EXISTS (
    SELECT FROM information_schema.tables
    WHERE  table_schema = 'public'
    AND    table_name   = 'cards'
);

/* @name insertCard */
INSERT INTO cards
    (token_id, owner, element, power, name, block_height)
VALUES
    (:token_id!, :owner!, :element!, :power!, :name!, :block_height!)
ON CONFLICT (token_id) DO NOTHING;

/* @name updateCardOwner */
UPDATE cards SET owner = :owner! WHERE token_id = :token_id!;

/* @name getCardByTokenId */
SELECT * FROM cards WHERE token_id = :token_id!;

/* @name getCards */
SELECT * FROM cards ORDER BY block_height DESC, token_id DESC;

/* @name getMap */
SELECT m.x, m.y, m.token_id, m.owner, m.placed_at_height,
       c.element, c.power, c.name
FROM map_cells m
JOIN cards c ON c.token_id = m.token_id
ORDER BY m.y, m.x;

/* @name getCell */
SELECT m.x, m.y, m.token_id, m.owner,
       c.element, c.power, c.name
FROM map_cells m
JOIN cards c ON c.token_id = m.token_id
WHERE m.x = :x! AND m.y = :y!;

/* @name getNeighbors */
SELECT m.x, m.y, m.owner,
       c.element, c.power
FROM map_cells m
JOIN cards c ON c.token_id = m.token_id
WHERE (ABS(m.x - :x!) + ABS(m.y - :y!)) = 1;

/* @name isTokenPlaced */
SELECT token_id FROM map_cells WHERE token_id = :token_id!;

/* @name placeCard */
INSERT INTO map_cells
    (x, y, token_id, owner, placed_at_height)
VALUES
    (:x!, :y!, :token_id!, :owner!, :placed_at_height!)
ON CONFLICT (x, y)
DO UPDATE SET
    token_id = EXCLUDED.token_id,
    owner = EXCLUDED.owner,
    placed_at_height = EXCLUDED.placed_at_height;

/* @name decrementCardPower */
UPDATE cards
SET power = GREATEST(:min_power!, power - :amount!)
WHERE token_id = :token_id!;

/* @name insertBattle */
INSERT INTO battle_log
    (x, y, attacker, defender, attacker_token, defender_token,
     attacker_total, defender_total, attacker_won, block_height)
VALUES
    (:x!, :y!, :attacker!, :defender!, :attacker_token!, :defender_token!,
     :attacker_total!, :defender_total!, :attacker_won!, :block_height!);

/* @name getBattles */
SELECT * FROM battle_log ORDER BY id DESC LIMIT 20;

/* @name getLeaderboard */
SELECT owner, COUNT(*)::int AS cells
FROM map_cells
GROUP BY owner
ORDER BY cells DESC;

/* @name getSeason */
SELECT * FROM season WHERE id = 1;

/* @name updateSeason */
UPDATE season
SET element = :element!, counter_value = :counter_value!, block_height = :block_height!
WHERE id = 1;

Favorite trick in there: getNeighbors finds orthogonally adjacent cards with ABS(m.x - :x!) + ABS(m.y - :y!) = 1 — Manhattan distance 1.

4Generate the typed queries

With the stack stopped (pgtyped spins up its own database on the same port):

bun run build:pgtypes
expected
[pgtyped] Saved 15 query types from sql/game.sql to sql/game.queries.ts
[pgtyped] ✅ Completed successfully

This ran your migrations against a scratch database, type-checked every query against the real schema, and emitted sql/game.queries.ts. SQL typos become build errors, not runtime surprises. (In a hurry, or it failed? Download the generated file.)

5Export everything

Replace packages/database/mod.ts:

packages/database/mod.ts · 3 linesdownload
export * from "./sql/sm_example.queries.ts";
export * from "./sql/game.queries.ts";
export { migrationTable } from "./migration-order.ts";
Checkpoint
Start the stack (bunx orchestrator start --background) and watch for the migration:
bunx orchestrator logs sync | grep MIGRATION
[APPLY MIGRATION] Block height: 1 | Migration: 001-game.sql
Your tables exist. The database is in-memory, so this reapplies on every boot — by design.
← Template tour2 · Random stats →