Construyendo KarelPy: un IDE para Karel en Python con generación de código por IA
Karel es una de las herramientas más queridas para enseñar programación. Si creciste en México participando en la OMI (Olimpiada Mexicana de Informática), casi con certeza escribiste tu primer algoritmo para este pequeño robot. La implementación más popular hoy es karel.js de OmegaUp — un IDE completo en el navegador donde los estudiantes pueden escribir, ejecutar y visualizar programas Karel.
Quería construir algo similar pero completamente en Python, con una variante: una sintaxis que se siente como Python en lugar de Pascal, y un asistente de IA que pueda generar código Karel a partir de una descripción en lenguaje natural.
Esta es la historia de cómo construí KarelPy.
¿Qué es Karel?
Karel es un robot que vive en un mundo de cuadrícula. Puede orientarse en cuatro direcciones (Norte, Sur, Este, Oeste), avanzar, girar a la izquierda, recoger y dejar "zumbadores" (fichas), y detectar su entorno. El lenguaje no tiene variables ni aritmética — solo movimiento, condicionales, ciclos y recursión. Esa simplicidad es exactamente lo que lo hace perfecto para enseñar.
Un programa típico de Karel se ve así:
def programa():
while frente_libre():
avanza()
gira_izquierda()
apagate()
Simple, legible y aun así suficientemente poderoso para expresar algoritmos no triviales.
Visión general de la arquitectura
KarelPy está dividido en dos capas completamente independientes:
karelpy/ ← Paquete Python puro (sin dependencias web)
web/ ← Capa web con FastAPI (consume el paquete)
Esta separación significa que el motor Karel puede ser probado, importado y reutilizado sin necesidad de levantar un servidor web. La capa web es solo un envoltorio delgado.
Parte 1: Construyendo el motor Karel
El pipeline
Cada vez que el usuario hace click en "Ejecutar", el código pasa por un pipeline clásico de compilador:
Código fuente → Lexer → Tokens → Parser → AST → Intérprete → Traza de ejecución
Cada etapa es un módulo separado con una única responsabilidad.
Paso 1: El Lexer
El lexer convierte el texto fuente en una lista plana de tokens. La parte más compleja de un lexer estilo Python es manejar la indentación significativa — el lenguaje usa espacios en blanco para definir bloques en lugar de { } o inicio/fin.
La solución es un stack de indentación. Cada vez que la indentación aumenta, se emite un token INDENT. Cada vez que disminuye, se emiten uno o más tokens DEDENT.
# lexer.py
indent_stack = [0]
for line_num, line in enumerate(lines, start=1):
indent = 0
while indent < len(line) and line[indent] == ' ':
indent += 1
if indent > indent_stack[-1]:
tokens.append(Token(TokenType.INDENT, indent, line_num, 1))
indent_stack.append(indent)
elif indent < indent_stack[-1]:
while indent_stack[-1] > indent:
indent_stack.pop()
tokens.append(Token(TokenType.DEDENT, indent_stack[-1], line_num, 1))
Esto convierte la indentación en tokens estructurales explícitos, lo que hace al parser mucho más sencillo de escribir.
Paso 2: El Parser
El parser usa descenso recursivo — un método por regla gramatical, cada uno devolviendo un nodo del AST. Lee tokens de la salida del lexer y construye un árbol.
# parser.py
def _parse_while(self) -> WhileLoop:
token = self.expect(TokenType.WHILE)
condition = self._parse_condition()
self.expect(TokenType.COLON)
body = self._parse_block() # parsea recursivamente el bloque indentado
return WhileLoop(condition=condition, body=body, line=token.line)
Cada nodo del árbol es un dataclass de Python — sin lógica, solo datos:
# ast_nodes.py
@dataclass
class WhileLoop(Node):
condition: Optional[Condition] = None
body: List[Node] = field(default_factory=list)
@dataclass
class Condition(Node):
name: str = ""
negated: bool = False # maneja la palabra clave `not`
Paso 3: El mundo y el robot
El mundo es una cuadrícula 2D. Las paredes se almacenan como un conjunto de tuplas (col, fila, dirección). Cada pared interior se almacena dos veces — una desde cada lado — por lo que las verificaciones de colisión son búsquedas O(1).
# world.py
def has_wall(self, col: int, row: int, direction: str) -> bool:
dc, dr = DIRECTION_DELTA[direction]
ncol, nrow = col + dc, row + dr
if not self.in_bounds(ncol, nrow):
return True # los bordes del mundo son paredes implícitas
return (col, row, direction) in self.walls
El robot es aún más simple — solo un dataclass con cuatro campos:
# robot.py
@dataclass
class Robot:
col: int = 1
row: int = 1
direction: str = "NORTH"
bag: int = 0 # -1 significa zumbadores infinitos
Paso 4: El intérprete y la traza de ejecución
Esta es la decisión de diseño más importante del proyecto. Una implementación ingenua ejecutaría el programa y devolvería solo el estado final del mundo — pero eso hace imposible la animación.
En su lugar, el intérprete registra un snapshot después de cada instrucción primitiva. El resultado es una lista de estados del mundo — la traza de ejecución completa.
# interpreter.py
def _exec_primitive(self, name: str, line: int):
self.step_count += 1
if self.step_count > MAX_STEPS:
raise MaxStepsError("Posible ciclo infinito detectado.")
r, w = self.robot, self.world
if name == "avanza":
if w.has_wall(r.col, r.row, r.direction):
raise KarelRuntimeError("Karel chocó con una pared.", line)
dc, dr = DIRECTION_DELTA[r.direction]
r.col += dc
r.row += dr
elif name == "gira_izquierda":
r.direction = TURN_LEFT[r.direction]
# ... otras instrucciones primitivas ...
self._snapshot() # ← registrar estado después de cada instrucción
El frontend recibe todos los snapshots de una sola vez y los anima del lado del cliente — sin WebSockets, sin streaming, sin estado en el servidor.
Recursión gratis
Uno de los resultados más elegantes de este diseño: la recursión simplemente funciona. Cuando el intérprete llama a una función definida por el usuario, llama a _exec_block — una función Python normal. Si esa función se llama a sí misma, Python apila los frames de forma natural.
def _exec_call(self, stmt: Call):
if stmt.name in self.functions:
try:
self._exec_block(self.functions[stmt.name].body)
except KarelReturn:
pass # termina() sale solo de la función actual
La instrucción termina() funciona como un return — lanza una excepción KarelReturn que es capturada en el límite de la llamada a función, no en el nivel superior. apagate() lanza KarelShutdown, que se propaga hasta arriba y termina el programa.
Este es un programa Karel recursivo que camina hasta una pared y luego deshace el camino:
def copy_move():
if frente_bloqueado():
gira_derecha()
termina() # caso base: salir de esta llamada, desapilar
avanza()
copy_move() # llamada recursiva
avanza() # se ejecuta al regresar por el stack
def programa():
copy_move()
apagate()
La API pública
Todo el motor se expone a través de una sola función:
# karelpy/__init__.py
def run(code: str, world_dict: dict, robot_dict: dict) -> dict:
try:
tokens = Lexer(code).tokenize()
program = Parser(tokens).parse()
world = World.from_dict(world_dict)
robot = Robot.from_dict(robot_dict)
steps = Interpreter(world, robot).run(program)
return {"ok": True, "steps": steps}
except KarelError as exc:
return {"ok": False, "error": str(exc), "line": getattr(exc, "line", None)}
Nunca lanza excepciones — los errores se devuelven como datos. Esto hace que la capa web sea trivialmente simple.
Parte 2: La capa web
El backend
FastAPI envuelve el motor en dos endpoints:
# routes/run.py
@router.post("/api/run")
def run_karel(req: RunRequest) -> dict:
return run(req.code, req.world, req.robot)
Eso es literalmente todo el endpoint de ejecución. El motor hace todo el trabajo.
El frontend
El frontend tiene dos clases principales:
WorldRenderer dibuja la cuadrícula de Karel en un Canvas HTML. Convierte el sistema de coordenadas de Karel (col, fila) (origen abajo a la izquierda) a píxeles del canvas (origen arriba a la izquierda):
toCanvas(col, row) {
return {
x: (col - 1) * this.cellSize,
y: (this.worldHeight - row) * this.cellSize,
};
}
El robot se dibuja como un triángulo que rota según la dirección de Karel:
_drawRobot({ col, row, dir }) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(DIR_ANGLE[dir]); // { NORTH: 0, EAST: π/2, SOUTH: π, WEST: -π/2 }
ctx.beginPath();
ctx.moveTo(0, -r); // punta del triángulo = frente de Karel
ctx.lineTo(-r * 0.7, r * 0.55);
ctx.lineTo( r * 0.7, r * 0.55);
ctx.closePath();
ctx.fill();
ctx.restore();
}
KarelApp gestiona el editor (CodeMirror), se comunica con la API y controla la reproducción. Después de recibir la traza de ejecución, anima los pasos con un timer:
_tick() {
if (!this.playing) return;
if (this.currentStep >= this.steps.length - 1) { this.pause(); return; }
this.currentStep++;
this._renderStep();
this._updateCounter();
const speed = parseInt(document.getElementById('speed-slider').value);
this.playTimer = setTimeout(() => this._tick(), Math.round(1000 / speed));
}
El usuario también puede hacer click en el canvas para editar el mundo. Hacer click cerca del borde de una celda coloca una pared; hacer click en el centro agrega un zumbador:
_clickZone(e) {
const { localX, localY } = this._cellAt(e);
const t = Math.max(6, Math.floor(this.renderer.cellSize * 0.2));
if (localY < t) return 'NORTH';
if (localY > cellSize - t) return 'SOUTH';
if (localX < t) return 'WEST';
if (localX > cellSize - t) return 'EAST';
return 'center';
}
Parte 3: Generación de código con IA
La última funcionalidad es un asistente de IA que genera código Karel a partir de una descripción en texto. El usuario puede escribir "haz que Karel recoja todos los zumbadores de su fila" y recibir código funcional al instante.
La estrategia: prompt engineering, no fine-tuning
Karel es un lenguaje pequeño — unas 10 instrucciones y 20 condiciones. GPT-4o ya conoce Karel en sus variantes Java y Pascal. Lo único que necesita es contexto sobre esta sintaxis específica.
El enfoque, en orden de menor a mayor complejidad:
- System prompt con la referencia de sintaxis completa — inyectar
SINTAXIS.mden cada request - Ejemplos few-shot — 5 pares problema/solución hardcodeados en el prompt
- RAG — un banco de problemas Karel resueltos (no necesario todavía)
- Fine-tuning — no es necesario
Los niveles 1 y 2 son suficientes para la gran mayoría de los problemas Karel.
# routes/generate.py
def _build_system_prompt() -> str:
sintaxis = SINTAXIS_PATH.read_text(encoding="utf-8") # siempre fresco del disco
return f"""Eres un experto en KarelPy.
Genera SOLO código KarelPy válido dentro de un bloque ```python ... ```.
Siempre define programa() como punto de entrada.
Usa únicamente las instrucciones y condiciones definidas en la referencia de sintaxis.
{sintaxis}
{FEW_SHOT}"""
Leer SINTAXIS.md del disco en cada request significa que mejorar la documentación del lenguaje mejora automáticamente la salida de la IA — sin cambios en el código.
El endpoint es directo:
@router.post("/api/generate")
def generate_karel(req: GenerateRequest) -> dict:
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
response = client.chat.completions.create(
model = "gpt-4o",
messages = [
{"role": "system", "content": _build_system_prompt()},
{"role": "user", "content": req.prompt},
],
)
code = _extract_code(response.choices[0].message.content)
return {"ok": True, "code": code}
La clase AiPanel del frontend envía el request, muestra el código generado y permite insertarlo en el editor con un click:
insertIntoEditor() {
const code = this.codeOutput.value.trim();
this.karelApp.editor.setValue(code);
this.karelApp.editor.focus();
}
Lo que aprendí
Las trazas de ejecución son la abstracción correcta para herramientas educativas. Devolver una lista de snapshots en lugar de hacer streaming de cambios de estado mantiene el backend sin estado, hace que el frontend sea trivialmente pausable y rebobinble, y facilita enormemente el testing.
El call stack de Python es un motor de recursión gratuito. Al implementar el intérprete como un tree-walking interpreter con llamadas a funciones Python normales, las funciones recursivas definidas por el usuario funcionan automáticamente sin ninguna maquinaria adicional.
El prompt engineering supera al fine-tuning para DSLs pequeños. Un system prompt bien escrito con la especificación del lenguaje y algunos ejemplos es suficiente para que un modelo genere código correcto en un lenguaje personalizado que nunca ha visto antes.
Cómo correrlo localmente
git clone https://github.com/fragosoa/karelpy
cd karelpy
pip install -e ".[dev,web]"
# Agrega tu API key de OpenAI (opcional, solo para la generación con IA)
echo "OPENAI_API_KEY=sk-..." > .env
uvicorn web.app:app --reload
# Abre http://localhost:8000
Qué sigue
- Editor de mundos: un editor visual completo para diseñar mundos Karel con guardado por nombre
- Biblioteca de problemas: una colección de problemas clásicos de Karel con salidas esperadas para calificación automática
- Base de conocimiento RAG: un banco de problemas resueltos que la IA pueda usar como contexto de recuperación para retos más difíciles
- Extensiones de sintaxis:
or/anden condiciones, constantes con nombre para cantidades de zumbadores