Skip to content

Instantly share code, notes, and snippets.

@badlogic
Last active April 21, 2026 23:01
Show Gist options
  • Select an option

  • Save badlogic/328fac76dbb8572904feb4d5d5bdd050 to your computer and use it in GitHub Desktop.

Select an option

Save badlogic/328fac76dbb8572904feb4d5d5bdd050 to your computer and use it in GitHub Desktop.
Pi extension: playable Pitris using only the pi.dev logo block

pi-pitris-widget

Playable Pitris for Pi.

  • exposes /pitris
  • uses only the pi.dev logo shape as the falling piece
  • controls: arrows or i j k l
  • rotate: up, i, or space
  • soft drop: down or k (holding the key repeats and accelerates the fall)

Install

pi -e /absolute/path/to/pi-pitris-widget

Play

/pitris
{
"name": "pi-pitris-widget",
"version": "0.1.0",
"description": "Playable Pitris extension for Pi",
"type": "module",
"keywords": [
"pi-package",
"pi-extension",
"tetris",
"widget"
],
"pi": {
"extensions": [
"./pitris.ts"
]
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*"
}
}
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
type Cell = 0 | 1;
type Point = [number, number];
type PieceDef = {
name: string;
rotations: Point[][];
};
type ActivePiece = {
def: PieceDef;
rotation: number;
x: number;
y: number;
};
type GameState = {
board: Cell[][];
active?: ActivePiece;
score: number;
lines: number;
gameOver: boolean;
};
const BOARD_WIDTH = 10;
const BOARD_HEIGHT = 16;
const TICK_MS = 320;
const CELL_WIDTH = 2;
function normalize(points: Point[]): Point[] {
const minX = Math.min(...points.map(([x]) => x));
const minY = Math.min(...points.map(([, y]) => y));
return points
.map(([x, y]) => [x - minX, y - minY] as Point)
.sort((a, b) => a[1] - b[1] || a[0] - b[0]);
}
function rotate90(points: Point[]): Point[] {
return normalize(points.map(([x, y]) => [3 - y, x] as Point));
}
function uniqueRotations(points: Point[]): Point[][] {
const out: Point[][] = [];
const seen = new Set<string>();
let current = normalize(points);
for (let i = 0; i < 4; i++) {
const key = JSON.stringify(current);
if (!seen.has(key)) {
seen.add(key);
out.push(current);
}
current = rotate90(current);
}
return out;
}
const PI_LOGO_SHAPE: Point[] = [
[0, 0], [1, 0], [2, 0],
[0, 1], [2, 1],
[0, 2], [1, 2], [3, 2],
[0, 3], [3, 3],
];
const PI_PIECE: PieceDef = {
name: "π",
rotations: uniqueRotations(PI_LOGO_SHAPE),
};
function makeBoard(): Cell[][] {
return Array.from({ length: BOARD_HEIGHT }, () => Array.from({ length: BOARD_WIDTH }, () => 0 as Cell));
}
function cloneBoard(board: Cell[][]): Cell[][] {
return board.map((row) => [...row] as Cell[]);
}
function getCells(piece: ActivePiece): Point[] {
return piece.def.rotations[piece.rotation].map(([x, y]) => [piece.x + x, piece.y + y]);
}
function collides(board: Cell[][], piece: ActivePiece): boolean {
return getCells(piece).some(([x, y]) => {
if (x < 0 || x >= BOARD_WIDTH || y >= BOARD_HEIGHT) return true;
if (y < 0) return false;
return board[y][x] === 1;
});
}
function stamp(board: Cell[][], piece: ActivePiece): void {
for (const [x, y] of getCells(piece)) {
if (x >= 0 && x < BOARD_WIDTH && y >= 0 && y < BOARD_HEIGHT) {
board[y][x] = 1;
}
}
}
function clearLines(board: Cell[][]): number {
let cleared = 0;
for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
if (board[y].every((cell) => cell === 1)) {
board.splice(y, 1);
board.unshift(Array.from({ length: BOARD_WIDTH }, () => 0 as Cell));
cleared += 1;
y += 1;
}
}
return cleared;
}
function createInitialState(): GameState {
return {
board: makeBoard(),
active: undefined,
score: 0,
lines: 0,
gameOver: false,
};
}
function spawnPiece(board: Cell[][]): ActivePiece | undefined {
const piece: ActivePiece = {
def: PI_PIECE,
rotation: 0,
x: Math.floor((BOARD_WIDTH - 4) / 2),
y: -1,
};
return collides(board, piece) ? undefined : piece;
}
function tryMove(board: Cell[][], piece: ActivePiece, dx: number, dy: number): ActivePiece | undefined {
const moved = { ...piece, x: piece.x + dx, y: piece.y + dy };
return collides(board, moved) ? undefined : moved;
}
function tryRotate(board: Cell[][], piece: ActivePiece): ActivePiece | undefined {
const rotated = { ...piece, rotation: (piece.rotation + 1) % piece.def.rotations.length };
const kicks: Array<[number, number]> = [
[0, 0],
[-1, 0],
[1, 0],
[-2, 0],
[2, 0],
[0, -1],
];
for (const [dx, dy] of kicks) {
const kicked = { ...rotated, x: rotated.x + dx, y: rotated.y + dy };
if (!collides(board, kicked)) return kicked;
}
return undefined;
}
class PitrisComponent {
private state: GameState = createInitialState();
private interval: ReturnType<typeof setInterval> | null = null;
private tui: { requestRender: () => void };
private close: () => void;
private cachedWidth = 0;
private cachedVersion = -1;
private cachedLines: string[] = [];
private version = 0;
constructor(tui: { requestRender: () => void }, close: () => void) {
this.tui = tui;
this.close = close;
this.state.active = spawnPiece(this.state.board);
if (!this.state.active) this.state.gameOver = true;
this.interval = setInterval(() => {
this.tick();
this.version += 1;
this.tui.requestRender();
}, TICK_MS);
}
private lockPiece(): void {
if (!this.state.active) return;
stamp(this.state.board, this.state.active);
const cleared = clearLines(this.state.board);
this.state.lines += cleared;
this.state.score += 10 + cleared * 100;
this.state.active = spawnPiece(this.state.board);
if (!this.state.active) this.state.gameOver = true;
}
private tick(): void {
if (this.state.gameOver) return;
if (!this.state.active) {
this.state.active = spawnPiece(this.state.board);
if (!this.state.active) this.state.gameOver = true;
return;
}
const fallen = tryMove(this.state.board, this.state.active, 0, 1);
if (fallen) this.state.active = fallen;
else this.lockPiece();
}
private softDrop(): void {
if (this.state.gameOver || !this.state.active) return;
const fallen = tryMove(this.state.board, this.state.active, 0, 1);
if (fallen) {
this.state.active = fallen;
this.state.score += 1;
} else {
this.lockPiece();
}
this.version += 1;
this.tui.requestRender();
}
private reset(): void {
this.state = createInitialState();
this.state.active = spawnPiece(this.state.board);
if (!this.state.active) this.state.gameOver = true;
this.version += 1;
this.tui.requestRender();
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
this.dispose();
this.close();
return;
}
if (this.state.gameOver) {
if (data === "r" || data === "R" || matchesKey(data, "enter") || matchesKey(data, "space")) {
this.reset();
}
return;
}
const active = this.state.active;
if (!active) return;
if (matchesKey(data, "left") || data === "j" || data === "J") {
const moved = tryMove(this.state.board, active, -1, 0);
if (moved) this.state.active = moved;
} else if (matchesKey(data, "right") || data === "l" || data === "L") {
const moved = tryMove(this.state.board, active, 1, 0);
if (moved) this.state.active = moved;
} else if (matchesKey(data, "down") || data === "k" || data === "K") {
this.softDrop();
return;
} else if (matchesKey(data, "up") || data === "i" || data === "I" || matchesKey(data, "space")) {
const rotated = tryRotate(this.state.board, active);
if (rotated) this.state.active = rotated;
}
this.version += 1;
this.tui.requestRender();
}
invalidate(): void {
this.cachedWidth = 0;
}
render(width: number): string[] {
if (width === this.cachedWidth && this.cachedVersion === this.version) {
return this.cachedLines;
}
const board = cloneBoard(this.state.board);
if (this.state.active) stamp(board, this.state.active);
const lines: string[] = [];
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
const accent = (s: string) => `\x1b[35m${s}\x1b[39m`;
const warning = (s: string) => `\x1b[33m${s}\x1b[39m`;
const error = (s: string) => `\x1b[31m${s}\x1b[39m`;
const boxWidth = BOARD_WIDTH * CELL_WIDTH;
const padLine = (line: string) => line + " ".repeat(Math.max(0, width - visibleWidth(line)));
const framed = (content: string) => {
const padding = Math.max(0, boxWidth - visibleWidth(content));
return dim(" │") + content + " ".repeat(padding) + dim("│");
};
lines.push(padLine(dim(` ╭${"─".repeat(boxWidth)}╮`)));
lines.push(padLine(framed(`${accent(bold("Pitris"))} │ score ${warning(String(this.state.score))} │ lines ${warning(String(this.state.lines))}`)));
lines.push(padLine(dim(` ├${"─".repeat(boxWidth)}┤`)));
for (let y = 0; y < BOARD_HEIGHT; y++) {
let row = "";
for (let x = 0; x < BOARD_WIDTH; x++) {
row += board[y][x] ? accent("ππ") : " ";
}
lines.push(padLine(dim(" │") + row + dim("│")));
}
lines.push(padLine(dim(` ├${"─".repeat(boxWidth)}┤`)));
if (this.state.gameOver) {
lines.push(padLine(framed(`${error(bold("game over"))}${bold("R")} restart │ ${bold("Q")} quit`)));
} else {
lines.push(padLine(framed(`←→ / J L move │ ↑/I/space rotate │ ↓/K soft drop`)));
}
lines.push(padLine(dim(` ╰${"─".repeat(boxWidth)}╯`)));
this.cachedWidth = width;
this.cachedVersion = this.version;
this.cachedLines = lines;
return lines;
}
dispose(): void {
if (this.interval) clearInterval(this.interval);
this.interval = null;
}
}
export default function pitris(pi: ExtensionAPI) {
pi.registerCommand("pitris", {
description: "Play Pitris with the pi.dev logo block",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Pitris requires interactive mode", "error");
return;
}
await ctx.ui.custom((tui, _theme, _kb, done) => new PitrisComponent(tui, () => done(undefined)));
},
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment