Árbol de juego, búsqueda, variante principal y variante actual

Todos estos juegos de tablero, da un poco igual de qué juego se trate (ej. ajedrez, damas, Othello, etc.) o de los detalles de su implementación, se basan en los mismos principios:

  • Partiendo de la posición actual (raíz), el programa genera todos los movimientos posibles.
  • El proceso anterior se repite recursivamente hasta una profundidad N, dando lugar a un árbol de jugadas.
  • El árbol de jugadas se evalúa. Se parte de las hojas (tableros a profundidad N) aplicándoles una función de evaluación que establece cómo de buenos o malos son esos tableros para un bando y para el otro.
  • Las evaluaciones se llevan desde las hojas hasta la raíz aplicando un procedimiento llamado “minimax”, que consiste en suponer que ambos bandos elegirán su mejor jugada cuando sea su turno. Un bando elegirá las jugadas que maximizan la puntuación, y el otro las que la minimizan. De ahí el nombre “minimax”.
  • En ocasiones, se podan las ramas del árbol que no interesa analizar. Esto es muy importante para que el rendimiento sea bueno.
  • Finalmente, el ordenador elige su mejor jugada, lo que da lugar a una nueva posición actual.
  • Si el ordenador juega contra sí mismo se repite el proceso anterior desde la nueva posición actual. Si juega contra un humano se le pide su jugada.

Desde un punto de vista gráfico sería algo así:

Al proceso anterior se le denomina “búsqueda”, puesto que en el fondo lo que hace el ordenador es buscar la mejor jugada para su bando, asumiendo que el bando contrario también optará por su mejor jugada. La búsqueda puede ser “minimax” pura o, si incluye poda, puede ser búsqueda alfa – beta. Los detalles los veremos más adelante.

El resultado de la búsqueda, en realidad, no es sólo la siguiente jugada (en el caso del árbol anterior la jugada hacia la derecha que da lugar a un tablero valorado en 3), sino la secuencia de jugadas de uno y otro bando desde la raíz hasta una hoja. Es lo que se llama la “variante principal”, y en el fondo no es más que la “ruta” que el ordenador piensa que va a seguir la partida con los datos conocidos hasta ese momento, que siempre son imperfectos, porque la profundidad de análisis no es infinita:

En un momento dado de la ejecución, el programa estará generando y/o evaluando un determinado tablero del árbol. La ruta desde la raíz hasta ese tablero que se está generando y/o evaluando es lo que se llama la “variante actual”.

Todo lo anterior (el árbol de juego, la búsqueda “minimax” o alfa-beta, la variante actual, la variante principal, etc.) son ideas teóricas, son conceptos. Luego hay diferentes formas de implementarlas o llevarlas a la práctica.

Por ejemplo, alguien podría plantearse desarrollar todo el árbol de juego completo, almacenarlo en memoria, evaluarlo y elegir la siguiente jugada. No es la mejor opción, en primer lugar, porque consume mucha memoria (se almacena el árbol completo) y, en segundo lugar, porque no admite poda. La poda se hace en función de las evaluaciones y, aunque fuera posible hacerla en algunas circunstancias, con el árbol completo en memoria ya no tendría sentido hacerlo, porque ya se habría incurrido en el coste de construirlo y almacenarlo.

Por todo ello, en la práctica el árbol de juego se construye y se evalúa sobre la marcha, según se va construyendo. La construcción del árbol se hace “en profundidad”, es decir, primero se desarrolla la primera rama completa hasta la primera hoja, luego la segunda rama completa hasta la segunda hoja, etc. Y se va podando lo que, en función de las evaluaciones, no tiene sentido construir o desarrollar.

Es más, con un único tablero de 64 bytes es suficiente para gestionar el árbol de juego completo. Con ese tablero almacenamos la posición actual de la partida y, cuando toca decidir la siguiente jugada, generamos los movimientos posibles y los vamos “probando” y deshaciendo. Probarlos consiste en ejecutarlos o aplicarlos hasta la profundidad de análisis, evaluar las hojas con la función de evaluación, aplicar “minimax” para propagar las evaluaciones hacia la raíz, podar las ramas que se puedan podar, determinar la variante principal, y elegir el siguiente movimiento.

Por tanto, aunque desde un punto de vista conceptual el programa desarrolla y evalúa un árbol de jugadas, en la práctica todo se maneja con un único tablero. Todo esto quedará más claro según vayamos avanzando con el proyecto.

Inicialización e impresión del tablero

Ahora que ya sabemos cómo representar el tablero en la memoria del C64 los siguientes pasos van a ser inicializar el tablero y “pintarlo”.

Inicializar el tablero es colocar sobre él el ratón y los gatos en su posición inicial. Esto es necesario para empezar el juego.

E imprimir el tablero es hacer una representación del mismo en la pantalla del C64, de momento en modo texto. Esto será necesario casi para cualquier cosa, por ejemplo, para depurar los programas.

Todo esto lo vamos a hacer en una versión inicial del programa que iremos sofisticando de manera incremental. De momento, nos llega con cuatro ficheros *.asm:

  • “Globals.asm”: Recoge las constantes como el valor de las piezas, las direcciones de movimiento, los bandos (ratón o gatos), etc.
  • “Init.asm”: Recoge las variables y estructuras de datos principales, como el tablero, el bando que mueve, la profundidad de análisis, el número de nodos / tableros analizados, etc.
  • “Display.asm”: Recoge todo lo que tiene que ver con la impresión por pantalla.
  • “Main.asm”: Es el programa principal. En este primer paso, va a inicializar el tablero y pintarlo.

Inicialización del tablero:

Para inicializar el tablero nos apoyaremos en una tabla como ésta (etiqueta “init_board”):

Obsérvese que tiene un 0 (PIECE_MOUSE) sobre el índice 4 (casilla E1), un 1 (PIECE_CAT1) sobre el índice 57 (casilla B8), un 2 (PIECE_CAT2) sobre el índice 59 (casilla D8), un 3 (PIECE_CAT3) sobre el índice 61 (casilla F8), un 4 (PIECE_CAT4) sobre el índice 63 (casilla H8), y un 6 (PIECE_EMPTY) sobre el resto de casillas.

Pues bien, para inicializar el tablero simplemente definimos una rutina que recorra “init_board”, lea la pieza que hay almacenada en cada casilla, y la escriba en el tablero “board”. Se trata de la rutina “initBoard”:

Además de lo ya mencionado (copiar el tablero inicial sobre “board”), también hace otras labores de inicialización de la partida:

  • Mete el valor “SIDE_MOUSE” en la variable “side”, puesto que el primer bando que mueve al comienzo de la partida es el ratón.
  • Mete cero en “ply” y “hply”. Estas variables controlan la profundidad del árbol de jugadas que se está analizando (“ply”) y el número de movimientos jugados desde el comienzo de la partida (“hply”).
  • También mete cero en “first_move”. Esto es como decir que de momento no hay movimientos generados.

Bueno, pues ya está. Tenemos una rutina que inicializa el tablero y, más en general, para inicializar la partida.

Impresión del tablero:

Para imprimir el tablero inicialmente buscamos una representación de este tipo:

Es decir, primero las letras “ABCDEFGH”, luego las ocho filas del tablero, y finalmente las letras “ABCDEFGH” de nuevo. Los gatos los representaremos con una “C”, aunque podríamos usar letras distintas porque somos capaces de distinguirlos, el ratón con una “M”, las casillas blancas con un “.”, y las casillas negras con una “X”.

Más adelante podemos buscar una representación más sofisticada usando caracteres personalizados o, incluso, sprites.

La impresión la resolvemos con la rutina “displayBoard”, que sigue el mismo esquema anterior:

  • Imprime las letras con la rutina “displayLetters”.
  • Imprime las ocho filas con la rutina “displayRow”.
  • Vuelve a imprimir las letras con la rutina “displayLetters”.
  • E imprime algunos datos adicionales con la rutina “displayOther” (el bando al que le toca mover, la profundidad de análisis actual “ply”, y el número de movimientos de la partida “hply”).

A su vez, las rutinas anteriores se apoyan en las rutinas auxiliares:

  • “displaySq” para imprimir una casilla, ya sea “M” si contiene el ratón, “C” si contiene un gato, o “X” / “.” si está vacía.
  • “displayHex” para imprimir un número en formato hexadecimal.
  • “displayText” para imprimir un texto, ya sea “ABCDEFGH” o cualquier otro texto.

Programa principal:

El programa principal lo iremos complicando poco a poco, igual que los demás programas. De momento, nos vale para probar lo ya programado (inicialización e impresión del tablero) e ir construyendo sobre esa base:

Total, si ensamblamos, cargamos y ejecutamos vemos esto:

El siguiente paso será empezar a generar jugadas.


Código del proyecto: RYG3-01

Representación del tablero

Hay muchas formas de representar el tablero en la memoria del C64. Quizás la más natural o intuitiva sería hacerlo como una matriz de 8 x 8, cuyos valores representen el contenido de las casillas (0 = vacía, 1 = ratón, -1 = gato). De este modo, el tablero inicial se representaría así:

Ahora bien, la memoria del C64 –y la de todos los ordenadores– es lineal, en el sentido de que las posiciones de memoria van una detrás de otra según va creciendo la dirección ($0000 – $ffff). Por ello, para el ordenador es más natural almacenar o representar el tablero como un vector o tabla de 64 posiciones.

Y como en ajedrez se considera que la primera casilla es la A1 y la última es la H8, eso nos da estos índices del 0 al 63:

Es decir, la tabla de 64 posiciones tendría la siguiente pinta en memoria (0 = vacía, 1 = ratón, -1 = gato):

[0, 0, 0, 0, 1, 0, 0, 0] … [0,-1, 0,-1, 0,-1, 0,-1]

Por último, la forma de representar las piezas, por ejemplo, 0 para casilla vacía, 1 para ratón y -1 para gato, es arbitraria. Podemos elegir la que más nos guste.

En el caso de juegos como el ajedrez en que hay dos bandos, pero las piezas de los bandos son iguales, puede resultar natural usar el signo para indicar si una pieza es blanca o negra (ej. +1 = peón blanco y -1 = peón negro). Todavía más, se pueden elegir unos números u otros para representar el valor relativo de las piezas, por ejemplo, +1/-1 para peones, +3/-3 para caballos, +4/-4 para alfiles, +5/-5 para torres, +9/-9 para damas y +10/-10 para reyes. No es lo habitual; más bien se suelen elegir unos números arbitrarios que permiten distinguir las piezas y punto. Ya se encargará la función de evaluación de hacer la valoración material.

En el caso que nos ocupa, aunque tanto ratón como gatos se “pintan” como peones (o como se quiera), no se trata de las mismas piezas. Los gatos se mueven de una manera y el ratón de otra. Por tanto, casi es más lógico usar números diferentes y no usar el signo.

Todavía más, aunque el bando de los gatos tiene cuatro gatos, y todos se mueven igual, si queremos ser capaces de distinguirlos, en el sentido de saber cuál es el gato 1, cuál es el 2, cuál es el 3 y cuál es el 4, independientemente de dónde se ubiquen en el tablero, necesitaremos números distintos para ellos.

Por último, está el tema de la casilla vacía. Parece que lo más natural es usar el 0 para esto. Sin embargo, a la hora de programar será habitual manejar tablas que, para cada tipo de pieza, nos den su valor o sus movimientos. Y como las tablas empiezan en el índice 0, casi mejor reservar ese número para la primera pieza.

Total, al final vamos a representar el tablero así:

  • Valores 0 a 6 para las piezas (constantes “PIECE_*”):
  • Y tabla de 64 posiciones para el tablero (etiqueta “board”):

Por increíble que parezca… ¡¡se puede resolver el juego con un único tablero de 64 bytes!!

El ratón y los gatos

Juegos de tablero hay muchos: go, ajedrez, damas, Othello / Reversi, etc. Y, lógicamente, cuanto más complejo sea el juego, más compleja será su programación.

Como se trata de ejemplificar las técnicas de programación ya citadas, optaremos por un juego sencillo: el ratón y los gatos. Se trata de un juego que se juega sobre un tablero de 8 x 8, igual que el ajedrez. Un bando dispone de un ratón y el otro de cuatro gatos. El ratón se representa como un peón blanco y los gatos como cuatro peones negros.

El ratón puede moverse hacia delante o hacia atrás. Se mueve en diagonal y puede avanzar o retroceder sólo una casilla en cada movimiento:

Los gatos pueden moverse sólo hacia delante. También se mueven en diagonal y pueden avanzar sólo una casilla en cada movimiento:

El objetivo del juego es, para el ratón, llegar a la última fila, y para los gatos, acorralar al ratón. No hay capturas, es decir, no se puede comer.

La posición inicial es ésta, con las cinco piezas sobre casillas negras. Empieza moviendo el ratón:

Una vez descrito el juego, lo primero es ver cómo representar el tablero en memoria del C64.

A la segunda va la vencida: un juego de tablero

Como sabréis los seguidores del blog “Programación Retro del Commodore 64” (https://programacion-retro-c64.blog) y de los libros asociados, desde abril de 2020 hasta febrero de 2021, aproximadamente, he desarrollado un proyecto para programar un juego de tablero para el C64.

El proyecto estaba bastante avanzado, a mi entender, siendo la principal cuestión pendiente el desarrollo de un procedimiento de poda que permitiera analizar menos tableros y conseguir mejor rendimiento.

Desde entonces, y ha pasado casi un año (ahora es enero de 2022), apenas he tenido tiempo que dedicarle. Lo que sí he podido hacer, en cambio, ha sido leer bastante sobre programación de juegos de ajedrez. En particular, me han interesado mucho estos libros de Bill Jordan, informático y maestro de ajedrez:

  • “The joy of chess programming”.
  • “How to write a chess program”.
  • “How to write a JavaScript chess engine”.

El segundo es el que me parece el más práctico de los tres (programación en C++). Todos ellos están disponibles en Amazon junto con muchos otros libros de Bill Jordan sobre ajedrez y/o programación.

La cuestión es que he aprendido técnicas de programación interesantes que son aplicables al ajedrez, a las damas, al Othello, al ratón y los gatos, y a casi cualquier juego de tablero que nos podamos plantear.

Por tanto, me dispongo a revisar el proyecto de implementar un juego de tablero en ensamblador del C64 aplicando estas técnicas. Para aquellos que todavía tengan interés en el proyecto antiguo, he recogido sus entradas en este PDF.

El nuevo proyecto está terminado y funciona bien, así que ya sólo es cuestión de describirlo. Las técnicas principales que vamos a revisar son:

  • La representación del tablero.
  • La generación de jugadas.
  • El árbol de juego.
  • La función de evaluación.
  • La búsqueda minimax.
  • La búsqueda alfa-beta (con poda).
  • La profundización iterativa.
  • La ordenación de movimientos.
  • Las tablas de historia.

Vamos a por ello…