Skip to content

Instantly share code, notes, and snippets.

@boneskull
Last active December 13, 2025 07:17
Show Gist options
  • Select an option

  • Save boneskull/7df2dae1b11e387780bbfba5394cd18e to your computer and use it in GitHub Desktop.

Select an option

Save boneskull/7df2dae1b11e387780bbfba5394cd18e to your computer and use it in GitHub Desktop.
Asgard's Fall - Warpaint / Rune solver

This is a Python script for the bullet-heaven game, Asgard's Fall.

Given a list of nine large (hexomino) runes, it will tell you whether or not those runes will all fit in the Warpaint Grid, and print a solution.

image
#!/usr/bin/env python3
"""
Asgard's Fall - Warpaint Grid Checker
Check if a specific set of hexominos (runes) can fit in the Warpaint grid.
Uses backtracking to find a valid arrangement (if one exists).
Usage:
from can_fit_pieces import can_fit_pieces, print_solution, WARPAINT
pieces = ['algiz', 'mannaz', 'tiwaz', 'tiwaz', 'hagalaz',
'hagalaz', 'laguz', 'sowilo', 'sowilo']
result = can_fit_pieces(pieces)
if result:
print_solution(result, WARPAINT)
"""
from typing import Set, List, Tuple, Optional
from dataclasses import dataclass
Coord = Tuple[int, int]
Shape = Set[Coord]
BACKPACK_ASCII = """
## ##
########
########
########
######
######
######
######
####
""".strip('\n').split('\n')
def parse_grid(ascii_art: List[str]) -> Shape:
"""Convert ASCII art to set of (row, col) coordinates."""
coords = set()
for row, line in enumerate(ascii_art):
for col, char in enumerate(line):
if char == '#':
coords.add((row, col))
return coords
WARPAINT = parse_grid(BACKPACK_ASCII)
# =============================================================================
# PIECE DEFINITIONS
# =============================================================================
def parse_piece(ascii_art: str) -> Shape:
"""Parse a piece from ASCII art, normalized to origin."""
lines = ascii_art.strip('\n').split('\n')
coords = set()
for row, line in enumerate(lines):
for col, char in enumerate(line):
if char == '#':
coords.add((row, col))
return normalize(coords)
def normalize(shape: Shape) -> Shape:
"""Normalize shape so minimum row and col are 0."""
if not shape:
return shape
min_row = min(r for r, c in shape)
min_col = min(c for r, c in shape)
return frozenset((r - min_row, c - min_col) for r, c in shape)
def rotate_90(shape: Shape) -> Shape:
"""Rotate shape 90 degrees clockwise."""
return normalize({(c, -r) for r, c in shape})
def all_orientations(shape: Shape) -> List[Shape]:
"""Get all unique rotations of a shape (no reflections)."""
orientations = set()
current = shape
for _ in range(4):
orientations.add(frozenset(current))
current = rotate_90(current)
return [set(o) for o in orientations]
# ANSI color codes for terminal output
BOLD = "\033[1m"
COLORS = [
f"{BOLD}\033[91m", # Bold Red
f"{BOLD}\033[92m", # Bold Green
f"{BOLD}\033[93m", # Bold Yellow
f"{BOLD}\033[94m", # Bold Blue
f"{BOLD}\033[95m", # Bold Magenta
f"{BOLD}\033[96m", # Bold Cyan
f"{BOLD}\033[97m", # Bold White
f"{BOLD}\033[33m", # Bold Orange-ish
f"{BOLD}\033[35m", # Bold Purple
f"{BOLD}\033[36m", # Bold Teal
]
RESET = "\033[0m"
DIM = "\033[2m"
# Unicode rune characters for display
RUNE_GLYPHS = {
"naudiz": "ᚾ",
"tiwaz": "ᛏ",
"mannaz": "ᛗ",
"eiwaz": "ᛇ",
"algiz": "ᛉ",
"hagalaz": "ᚺ",
"laguz": "ᛚ",
"sowilo": "ᛋ",
"ingwaz": "ᛜ",
"isaz": "ᛁ",
"thurs": "ᚦ",
"jera": "ᛃ",
"gyfu": "ᚷ",
}
HEXOMINOS = {
"naudiz": parse_piece("""
#
##
##
#
"""),
"tiwaz": parse_piece("""
###
#
#
#
"""),
"mannaz": parse_piece("""
##
##
##
"""),
"eiwaz": parse_piece("""
##
#
#
##
"""),
"algiz": parse_piece("""
# #
###
#
"""),
"hagalaz": parse_piece("""
#
##
##
#
"""),
"laguz": parse_piece("""
#
##
##
#
"""),
"sowilo": parse_piece("""
#
##
##
#
"""),
}
TETROMINOS = {
"ingwaz": parse_piece("""
##
##
"""),
"isaz": parse_piece("""
#
#
#
#
"""),
"thurs": parse_piece("""
#
##
#
"""),
"jera": parse_piece("""
##
##
"""),
}
# Domino (2 cells)
DOMINOS = {
"gyfu": parse_piece("""
#
#
"""),
}
@dataclass
class Placement:
"""A placed piece on the grid."""
name: str
coords: Shape
def can_place(piece: Shape, at: Coord, grid: Shape) -> Optional[Shape]:
"""Check if piece can be placed at position, return placed coords or None."""
row_off, col_off = at
placed = {(r + row_off, c + col_off) for r, c in piece}
if placed <= grid:
return placed
return None
def can_fit_pieces(hex_names: List[str], grid: Shape = None) -> Optional[List[Placement]]:
"""
Check if a specific set of 9 runes can fit in the Warpaint grid.
Since 9 hexominos = 54 cells and the grid has 56 cells, a domino (gyfu)
will automatically be used to fill the remaining 2 cells.
Args:
hex_names: List of 9 hexomino names (e.g., ['mannaz', 'algiz', ...])
grid: The grid to fill (defaults to WARPAINT)
Returns:
List of placements if solution exists, None otherwise
Example:
can_fit_pieces(['algiz', 'mannaz', 'laguz', 'tiwaz', 'tiwaz',
'hagalaz', 'hagalaz', 'sowilo', 'sowilo'])
"""
if grid is None:
grid = WARPAINT.copy()
from collections import Counter
pieces_needed = Counter(hex_names)
def try_fit(remaining_grid, pieces_left, placements, domino_used=False):
if not remaining_grid:
return placements
target = min(remaining_grid)
# Try hexominos first
for name, count in pieces_left.items():
if count <= 0:
continue
base_shape = HEXOMINOS[name]
for orientation in all_orientations(base_shape):
for piece_cell in orientation:
offset = (target[0] - piece_cell[0], target[1] - piece_cell[1])
placed = can_place(orientation, offset, remaining_grid)
if placed:
new_pieces = pieces_left.copy()
new_pieces[name] -= 1
result = try_fit(remaining_grid - placed, new_pieces,
placements + [Placement(name, placed)], domino_used)
if result is not None:
return result
# Try domino (gyfu) if not yet used
if not domino_used:
for name, base_shape in DOMINOS.items():
for orientation in all_orientations(base_shape):
for piece_cell in orientation:
offset = (target[0] - piece_cell[0], target[1] - piece_cell[1])
placed = can_place(orientation, offset, remaining_grid)
if placed:
result = try_fit(remaining_grid - placed, pieces_left,
placements + [Placement(name, placed)], True)
if result is not None:
return result
return None
return try_fit(grid, pieces_needed, [])
def print_solution(solution: List[Placement], grid_shape: Shape = None):
"""Pretty-print the solution using Unicode rune characters with ANSI colors."""
if grid_shape is None:
grid_shape = WARPAINT
if not solution:
print("No solution found!")
return
max_row = max(r for r, c in grid_shape)
max_col = max(c for r, c in grid_shape)
# Create display grid with (glyph, color) tuples
display = [[(' ', '') for _ in range(max_col + 1)] for _ in range(max_row + 1)]
for r, c in grid_shape:
display[r][c] = ('·', DIM)
# Assign colors to each unique piece placement
piece_colors = {}
color_idx = 0
for placement in solution:
if id(placement) not in piece_colors:
piece_colors[id(placement)] = COLORS[color_idx % len(COLORS)]
color_idx += 1
# Place runes with colors
for placement in solution:
glyph = RUNE_GLYPHS.get(placement.name, '?')
color = piece_colors[id(placement)]
for r, c in placement.coords:
display[r][c] = (glyph, color)
print("\nSOLUTION:")
print("=" * ((max_col + 1) * 2 + 1))
for row in display:
line = '|'
for glyph, color in row:
line += f"{color}{glyph}{RESET} "
line = line.rstrip() + '|'
print(line)
print("=" * ((max_col + 1) * 2 + 1))
print("\nPieces used:")
piece_counts = {}
piece_color_map = {}
for p in solution:
if p.name not in piece_counts:
piece_counts[p.name] = 0
piece_color_map[p.name] = piece_colors[id(p)]
piece_counts[p.name] += 1
for name, count in sorted(piece_counts.items()):
glyph = RUNE_GLYPHS.get(name, '?')
color = piece_color_map[name]
print(f" {color}{glyph}{RESET} {name}: {count}")
total_hex = sum(1 for p in solution if p.name in HEXOMINOS)
total_tet = sum(1 for p in solution if p.name in TETROMINOS)
total_dom = sum(1 for p in solution if p.name in DOMINOS)
print(f"\nTotal: {total_hex} hexominos, {total_tet} tetrominos, {total_dom} dominos")
# =============================================================================
# MAIN - Example usage
# =============================================================================
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
# Use command-line arguments as piece names
pieces = sys.argv[1:]
# Validate piece names
valid_pieces = set(HEXOMINOS.keys())
invalid = [p for p in pieces if p not in valid_pieces]
if invalid:
print(f"❌ Unknown rune(s): {', '.join(invalid)}")
print(f"Valid runes: {', '.join(sorted(valid_pieces))}")
sys.exit(1)
if len(pieces) != 9:
print(f"⚠️ Warning: You provided {len(pieces)} runes (expected 9)")
print(f" 9 hexominos × 6 cells = 54 cells")
print(f" Grid has 56 cells, so gyfu (domino) fills the remaining 2")
print()
print(f"Testing: {' '.join(pieces)}")
result = can_fit_pieces(pieces)
if result:
print("✅ These runes fit!")
print_solution(result)
else:
print("❌ These runes cannot fit in the Warpaint grid.")
sys.exit(1)
else:
# No arguments - show help
print("Warpaint Grid Checker - Asgard's Fall")
print()
print("Usage: python can_fit_pieces.py <rune1> <rune2> ... <rune9>")
print()
print(f"Grid: {len(WARPAINT)} cells (9 hexominos + 1 domino)")
print()
print("Available runes (hexominos):")
for name in sorted(HEXOMINOS.keys()):
glyph = RUNE_GLYPHS.get(name, '?')
print(f" {glyph} {name}")
print()
print("Example:")
print(" python can_fit_pieces.py algiz mannaz laguz tiwaz tiwaz hagalaz hagalaz sowilo sowilo")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment