Skip to content

Latest commit

 

History

History
341 lines (243 loc) · 9.84 KB

File metadata and controls

341 lines (243 loc) · 9.84 KB

Python Enough for CynMeith

This guide is not a general Python course.

It teaches only the Python you need to start doing useful work in CynMeith:

  • defining a piece
  • checking the board
  • writing a custom move rule
  • attaching a side effect

If you can follow the examples here, you are already far enough to prototype games with this engine.

Who This Guide Is For

This page is for you if:

  • you are curious about making your own board-game rules
  • you can read some code, but do not want a full Python textbook first
  • you want the smallest possible set of ideas that unlock CynMeith

This page is not for advanced Python users. If you are already comfortable with functions, loops, and dataclasses, skim this and move on to Your First Custom Game.

The Mental Model

Before syntax, understand the shape of the engine.

  • A PieceState is data: what kind of piece, whose it is.
  • A BoardState maps positions to pieces. You read it; you never edit it.
  • A move generator is a function that looks at the board and describes what one piece could do.
  • The Engine takes a GameState and a Move and produces the next GameState.

When you write custom rules in CynMeith, you are usually doing one of two things:

  • describing which moves exist (generators, filters)
  • describing what extra should happen when a move lands (effects)

That is why the Python you need is mostly small functions that return or yield values. There is almost no class-writing: pieces are built by calling functions, not by subclassing.

1. Names and Values

In Python, you give a value a name with =.

distance = 2
symbol = "S"
allowed = True

You will do this constantly in move logic:

dr = destination.r - origin.r
dc = destination.c - origin.c

Here dr means "change in row" and dc "change in column". These two values are often enough to describe a move.

2. Functions

A function is a named block of code that does one job.

def is_forward_step(start, end):
    dr = end.r - start.r
    dc = end.c - start.c
    return dr == 1 and dc == 0

Important parts:

  • def starts a function
  • the indented block is the function body
  • return sends the result back

In CynMeith, small functions like this become move predicates and pieces of generators.

3. True, False, and None

These three values matter a lot in the engine.

  • True: yes, this condition holds
  • False: no, it does not
  • None: there is nothing here

Typical CynMeith usage:

def handle(position):
    piece = state.board.at(position)
    if piece is None:
        return  # empty square — nothing to do

board.at(...) returns the piece at a position, or None when the square is empty. Checking is None is the single most common line in rule code.

4. if Statements

An if statement lets you make decisions. Most rule logic is a sequence of small filters:

def can_strike(engine, state, origin, target, piece):
    if not engine.rules.geometry.contains(target):
        return False
    victim = state.board.at(target)
    if victim is None:
        return False
    if victim.side == piece.side:
        return False
    return True

This reads as:

  1. if the target is off the board, reject it
  2. if the target square is empty, reject it
  3. if the target holds a friend, reject it
  4. otherwise allow it

This style is common in CynMeith because it is easy to read and debug.

5. for Loops and yield

A for loop repeats something. In CynMeith, loops usually walk candidate squares, and yield hands each legal move to the engine one at a time:

from cynmeith import Move


def guard_moves(engine, state, origin, piece):
    for destination in engine.rules.geometry.neighbors(origin):
        occupant = state.board.at(destination)
        if occupant is not None and occupant.side == piece.side:
            continue  # skip squares held by friends
        yield Move(origin, destination)

A function that uses yield is called a generator. Read it as: "produce these moves, one by one." You do not need to understand generators deeply — copying this shape is enough.

6. Imports

Imports let you use code from elsewhere. Almost everything you need comes from one place:

from cynmeith import Coord, Move, PieceDef, Remove, leaper, rider, stepper

Read it as "I need these tools from CynMeith."

7. Sides Are Player Numbers

A "side" in CynMeith is an index: 0 is the first player, 1 is the second.

So piece.side is 0 or 1, and comparisons are just:

if occupant.side != piece.side:
    ...  # it's an enemy

In a FEN setup string, uppercase symbols belong to side 0 and lowercase to side 1. You do not need anything fancier than that to start writing rules.

Coordinates

Positions on a square board are Coord(row, col) values:

Coord(2, 3)   # row 2, column 3

You can add them like vectors — origin + Coord(1, 0) is one row up. Useful helpers include manhattan_to, chebyshev_to, is_orthogonal, is_diagonal, and the direction constants ORTHOGONAL_DIRECTIONS, DIAGONAL_DIRECTIONS, ALL_DIRECTIONS.

Hex boards use Hex(q, r) the same way, with HEX_DIRECTIONS for the six neighbors. Your code mostly won't care which one it has, because it asks the geometry.

The Most Useful Questions

When writing rules, these are the calls to know first:

state.board.at(position)                     # piece there, or None
engine.rules.geometry.contains(position)     # on the board?
engine.rules.geometry.neighbors(position)    # adjacent squares
engine.rules.geometry.line(position, step)   # slide outward until the edge
engine.rules.geometry.distance(a, b)         # steps between two squares

And on pieces:

piece.kind        # "N", "S", ...
piece.side        # 0 or 1
piece.get("has_moved")   # a stored attribute, or None

Defining Your First Pieces

Most pieces need zero custom code — describe them with combinators:

from cynmeith import ORTHOGONAL_DIRECTIONS, PieceDef, rider, stepper

GUARD = PieceDef("G", "Guard", moves=stepper(*ORTHOGONAL_DIRECTIONS))
LANCE = PieceDef("L", "Lance", moves=rider(*ORTHOGONAL_DIRECTIONS))
  • stepper(...): one step in each given direction
  • rider(...): slide until something blocks; enemies are captured
  • leaper(...): fixed jumps (knights), given as offsets

They already handle bounds, friends, and captures.

When a Combinator Is Not Enough

Write a generator function. The guard_moves example above is the full pattern: loop over candidate squares, filter, yield Move(...). The engine passes your function four things:

  • engine — ask it for the geometry and rules
  • state — the current GameState; state.board is the board
  • origin — where the piece stands
  • piece — the PieceState itself

Plug it in exactly like a combinator:

GUARD = PieceDef("G", "Guard", moves=guard_moves)

Effects: "Also Do This"

Effects describe extra consequences of a move — the engine applies them after the piece moves:

from cynmeith import Move, Remove


def sniper_shots(engine, state, origin, piece):
    for target, victim in state.board.items():
        if victim.side != piece.side and engine.rules.geometry.distance(origin, target) == 2:
            yield Move.make(
                origin,
                origin,               # the sniper stays put...
                "shoot",
                move_actor=False,
                effects=(Remove(target),),   # ...the victim is removed
            )

Built-in effects: Remove (captures), Relocate (move another piece), Promote (replace a piece), Place (spawn a new one).

One Rule to Remember: Never Edit, Always Describe

The board is immutable. You cannot do state.board[x] = ... — and you never need to. Your job is to describe moves and effects; the engine applies them and hands everyone a new state. This is also why undo "just works" in every game you build.

If a piece needs memory (say, has_moved), store it as an attribute via the after_move hook:

ROOK = PieceDef(
    "R",
    "Rook",
    moves=rider(*ORTHOGONAL_DIRECTIONS),
    after_move=lambda piece: piece.with_attr("has_moved", True),
)

Good piece attributes: has_moved, remaining charges, a transformed flag. Bad piece attributes: whose turn it is, the score, the phase — those belong to turn/scoring/phase rules.

A Tiny Practice Path

If you want to build confidence, try these in order:

  1. make a piece that steps one square orthogonally (stepper)
  2. make a piece that slides diagonally any distance (rider)
  3. make a piece that moves but never captures (no_captures(...))
  4. write a generator for a piece that jumps to any empty corner of the board
  5. add a stationary "strike" move that removes an adjacent enemy (effects)

If you can do those five things, you are already beyond the beginner stage for this engine.

Common Mistakes

  1. Writing a generator for something a combinator already does. Check leaper / rider / stepper / filtered first.
  2. Trying to mutate the board. Describe changes with effects instead.
  3. Forgetting bounds checks in custom generators — use geometry.contains(position).
  4. Putting game-wide state on a piece. Turn, score, and phase live in rule components.
  5. Over-optimizing too early. A clear rule beats a clever rule while you are still discovering the game.

What You Can Ignore For Now

You do not need to master these before building a game:

  • classes and inheritance (you mostly won't write any)
  • advanced typing and generics
  • decorators, metaclasses, async code
  • packaging theory

Those are not the bottleneck for CynMeith work.

What To Read Next

Once this guide feels comfortable, continue with: