Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Pyxel retro game engine patterns - pixel art, game loops, sprite/tilemap, MML audio, resource management, and web deployment
.claude/skills/pyxel-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-07 | ✗→✓ | ▲ Improved | — | — |
| case-10 | ✓→✓ | = Same ✓ | — | — |
| case-22 | ✗→✗ | = Same ✗ | — | — |
| case-05 | ✗→✗ | = Same ✗ | — | — |
Patterns and best practices for building retro-style games with Pyxel — a Python game engine with deliberate retro constraints.
Pyxel enforces retro limitations by design:
| Constraint | Limit | |-----------|-------| | Colors | 16-color palette (customizable) | | Screen | Default 256x256 (configurable) | | Image banks | 3 banks (0-2), 256x256 each | | Tilemaps | 8 maps, 256x256 tiles each | | Sound channels | 4 simultaneous | | Sound/Music | 64 user-definable sounds, 8 musics | | Input | Keyboard + Mouse + Gamepad (up to 2) |
These constraints are features, not bugs. They force creative solutions and authentic retro aesthetics.
pythonimport pyxel class App: def __init__(self): pyxel.init(160, 120, title="My Game") # Load resources pyxel.load("assets.pyxres") # Initialize game state self.player_x = 72 self.player_y = 56 self.score = 0 # Start game loop pyxel.run(self.update, self.draw) def update(self): """Called every frame - handle input and game logic""" if pyxel.btnp(pyxel.KEY_Q): pyxel.quit() # Movement if pyxel.btn(pyxel.KEY_LEFT): self.player_x = max(self.player_x - 2, 0) if pyxel.btn(pyxel.KEY_RIGHT): self.player_x = min(self.player_x + 2, pyxel.width - 16) def draw(self): """Called every frame - render everything""" pyxel.cls(0) # Clear screen (color 0) pyxel.blt(self.player_x, self.player_y, 0, 0, 0, 16, 16, 0) pyxel.text(5, 4, f"SCORE: {self.score}", 7) App()
python# Button states pyxel.btn(key) # True while held pyxel.btnp(key) # True on press (with optional repeat) pyxel.btnr(key) # True on release pyxel.btnv(key) # Analog value (gamepad) # Mouse pyxel.mouse_x # Current X position pyxel.mouse_y # Current Y position pyxel.btnp(pyxel.MOUSE_BUTTON_LEFT) # Common pattern: 8-directional movement dx = pyxel.btn(pyxel.KEY_RIGHT) - pyxel.btn(pyxel.KEY_LEFT) dy = pyxel.btn(pyxel.KEY_DOWN) - pyxel.btn(pyxel.KEY_UP)
python# Primitives pyxel.cls(col) # Clear screen pyxel.pset(x, y, col) # Pixel pyxel.line(x1, y1, x2, y2, col) # Line pyxel.rect(x, y, w, h, col) # Filled rectangle pyxel.rectb(x, y, w, h, col) # Rectangle border pyxel.circ(x, y, r, col) # Filled circle pyxel.circb(x, y, r, col) # Circle border # Sprites (from image bank) pyxel.blt(x, y, img, u, v, w, h, colkey) # img: image bank (0-2) # u, v: source position in bank # w, h: size (negative = flip) # colkey: transparent color # Tilemap pyxel.bltm(x, y, tm, u, v, w, h, colkey) # Text pyxel.text(x, y, string, col)
pythonclass AnimatedSprite: def __init__(self, frames, speed=5): self.frames = frames # [(u, v, w, h), ...] self.speed = speed self.frame_index = 0 self.counter = 0 def update(self): self.counter += 1 if self.counter >= self.speed: self.counter = 0 self.frame_index = (self.frame_index + 1) % len(self.frames) def draw(self, x, y, img=0, colkey=0): u, v, w, h = self.frames[self.frame_index] pyxel.blt(x, y, img, u, v, w, h, colkey)
pythondef aabb_collision(x1, y1, w1, h1, x2, y2, w2, h2): """Axis-aligned bounding box collision""" return (x1 < x2 + w2 and x1 + w1 > x2 and y1 < y2 + h2 and y1 + h1 > y2) def point_in_rect(px, py, rx, ry, rw, rh): """Point inside rectangle""" return rx <= px < rx + rw and ry <= py < ry + rh
python# Define sounds using MML (Music Macro Language) pyxel.sounds[0].set( "e2e2c2g1 g1g1c2e2 d2d2d2g2 e2e2e2c2", # notes "p", # tones: t(riangle) s(quare) p(ulse) n(oise) "6", # volumes (0-7) "nnnf", # effects: n(one) s(lide) v(ibrato) f(adeout) 25 # speed ) # Play sound pyxel.play(ch, snd) # ch: channel (0-3), snd: sound index pyxel.playm(msc) # Play music (0-7) pyxel.stop(ch) # Stop channel (-1 for all)
python# Create resources with Pyxel Editor # Terminal: pyxel edit assets.pyxres # Load in code pyxel.load("assets.pyxres") # Or create programmatically pyxel.images[0].load(0, 0, "sprite_sheet.png") # Resource file contains: # - Image banks (sprites, backgrounds) # - Tilemaps (level layouts) # - Sounds (SFX) # - Music (BGM)
pythonclass SceneManager: def __init__(self): self.scenes = {} self.current = None def add(self, name, scene): self.scenes[name] = scene def switch(self, name): self.current = self.scenes[name] if hasattr(self.current, 'enter'): self.current.enter() def update(self): if self.current: self.current.update() def draw(self): if self.current: self.current.draw() # Usage class TitleScene: def update(self): if pyxel.btnp(pyxel.KEY_RETURN): scene_mgr.switch("game") def draw(self): pyxel.cls(0) pyxel.text(50, 50, "PRESS ENTER", pyxel.frame_count % 16)
bash# Package as standalone executable pyxel package APP_DIR STARTUP_SCRIPT # Convert to executable pyxel app2exe APP.pyxapp # Convert to HTML (browser-playable via WASM) pyxel app2html APP.pyxapp # The HTML output uses Pyodide/Emscripten WASM # Works in modern browsers without Python installed
update() and draw() fast (target 30fps default)pyxel.frame_count for timing instead of tracking your own counterpythonclass Particle: __slots__ = ['x', 'y', 'vx', 'vy', 'life', 'col'] def __init__(self, x, y): self.x = x self.y = y self.vx = pyxel.rndf(-1, 1) self.vy = pyxel.rndf(-2, 0) self.life = pyxel.rndi(10, 30) self.col = pyxel.rndi(8, 10) particles = [] # In update: spawn, move, remove dead # In draw: pyxel.pset(p.x, p.y, p.col)
pythonclass Camera: def __init__(self): self.x = 0 self.y = 0 def follow(self, target_x, target_y): self.x = target_x - pyxel.width // 2 self.y = target_y - pyxel.height // 2 # In draw: offset all positions by -camera.x, -camera.y
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-23 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 23 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.