GhalaDev's picture
Upload 3 files
fbd50df verified
Raw History Blame Contribute Delete
13.7 kB
"""
AI BoardGame Forge
A Gradio web app that turns any theme into a mini card game with
AI-generated text and AI-generated artwork. Built for Hugging Face Spaces.
"""
import json
import random
import urllib.parse
from io import BytesIO
import requests
import gradio as gr
import torch
from PIL import Image, ImageDraw, ImageFont
from transformers import AutoModelForCausalLM, AutoTokenizer
# ------------------------------------------------------------
# Language model setup
# ------------------------------------------------------------
MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct"
print("Loading language model, this can take a minute on first startup...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype="auto",
device_map="auto",
)
print("Model loaded.")
def ask_llm(system_prompt, user_prompt, max_new_tokens=500):
"""Sends a system prompt and a user prompt to the model and returns its text reply."""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
prompt_text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(prompt_text, return_tensors="pt").to(model.device)
output_tokens = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=0.8,
pad_token_id=tokenizer.eos_token_id,
)
new_tokens = output_tokens[0][inputs["input_ids"].shape[1]:]
reply = tokenizer.decode(new_tokens, skip_special_tokens=True)
return reply.strip()
# ------------------------------------------------------------
# Game text generator (LLM-based, with a template fallback)
# ------------------------------------------------------------
SYSTEM_PROMPT = """You are a creative board game designer.
Given a theme, invent a simple, fun mini card game based on it.
Reply with ONLY valid JSON, no extra text and no markdown code fences, in exactly this format:
{
"title": "a catchy game title",
"description": "2 to 3 sentences describing how the game works",
"rules": "3 to 5 short, simple rules a beginner could follow, written as one string",
"cards": [
{"name": "card name", "type": "card type", "effect": "short special ability"},
{"name": "card name", "type": "card type", "effect": "short special ability"},
{"name": "card name", "type": "card type", "effect": "short special ability"},
{"name": "card name", "type": "card type", "effect": "short special ability"}
]
}
Keep everything short, simple, and beginner-friendly. Always include exactly 4 cards."""
def extract_json(text):
"""Pulls out the {...} portion of a string, in case the model added extra text around it."""
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1:
raise ValueError("No JSON object found in the model's reply.")
return text[start:end + 1]
def generate_game_with_llm(theme):
"""Asks the language model to design the game and returns it as a dictionary."""
user_prompt = f"Theme: {theme}\nDesign the card game now."
raw_reply = ask_llm(SYSTEM_PROMPT, user_prompt)
json_text = extract_json(raw_reply)
game_data = json.loads(json_text)
required_keys = {"title", "description", "rules", "cards"}
if not required_keys.issubset(game_data.keys()):
raise ValueError("Model reply is missing required fields.")
if len(game_data["cards"]) != 4:
raise ValueError("Model did not return exactly 4 cards.")
return game_data
# ---- Fallback generator (used only if the LLM output cannot be used) ----
TITLE_TEMPLATES = [
"{theme} Wars",
"The {theme} Chronicles",
"Escape from {theme}",
"{theme}: The Card Game",
"Rise of the {theme}",
"{theme} Showdown",
]
DESCRIPTION_TEMPLATES = [
"In this game, players take on the role of characters caught up in a {theme} scenario. Each player uses cards to outsmart their opponents and be the first to win.",
"Players compete in a world inspired by {theme}, drawing and playing cards to gain the upper hand. Strategy and a little luck decide the winner.",
]
RULES_TEMPLATE = """1. Each player starts with a hand of 4 cards.
2. Players take turns playing one card at a time.
3. When a card is played, follow the effect written on it.
4. The game ends when the deck runs out or a player reaches the target score.
5. The player with the most points, or the last one standing, wins the {theme} showdown."""
CARD_TYPES = ["Attack", "Defense", "Support", "Trick", "Special", "Boost", "Sabotage"]
NAME_PARTS = [
"Guardian", "Blade", "Shadow", "Spark", "Wanderer", "Sentinel",
"Rebel", "Trickster", "Champion", "Outcast",
]
EFFECT_POOL = [
"Draw 2 extra cards this turn.",
"Block the next attack played against you.",
"Steal one card from an opponent's hand.",
"Skip your opponent's next turn.",
"Double your score this round.",
"Gain +3 power for the rest of the game.",
]
def generate_game_template(theme):
"""Local, non-AI backup generator used only if the language model fails."""
theme_clean = theme.strip().title()
title = random.choice(TITLE_TEMPLATES).format(theme=theme_clean)
description = random.choice(DESCRIPTION_TEMPLATES).format(theme=theme_clean)
rules = RULES_TEMPLATE.format(theme=theme_clean)
chosen_types = random.sample(CARD_TYPES, 4)
cards = []
for card_type in chosen_types:
name = f"{theme_clean} {random.choice(NAME_PARTS)}"
effect = random.choice(EFFECT_POOL)
cards.append({"name": name, "type": card_type, "effect": effect})
return {"title": title, "description": description, "rules": rules, "cards": cards}
def generate_game(theme):
"""
Main entry point. Tries the language model first, and only falls back
to the template generator if the model's reply could not be used.
"""
try:
return generate_game_with_llm(theme)
except Exception as error:
print(f"LLM generation could not be used, falling back to templates. Reason: {error}")
return generate_game_template(theme)
# ------------------------------------------------------------
# AI artwork generator
# ------------------------------------------------------------
TYPE_COLORS = {
"Attack": {"bg": (178, 34, 34), "border": (255, 215, 0)},
"Defense": {"bg": (30, 60, 114), "border": (192, 192, 192)},
"Support": {"bg": (34, 87, 47), "border": (200, 255, 200)},
"Trick": {"bg": (75, 0, 130), "border": (218, 165, 32)},
"Special": {"bg": (139, 69, 19), "border": (255, 223, 0)},
"Boost": {"bg": (204, 85, 0), "border": (255, 255, 255)},
"Sabotage": {"bg": (20, 20, 20), "border": (139, 0, 0)},
}
def get_font(size, bold=True):
"""Loads a common system font. Falls back to a basic font if not found."""
font_names = (
["DejaVuSans-Bold.ttf", "Arial Bold.ttf", "arialbd.ttf"]
if bold
else ["DejaVuSans.ttf", "Arial.ttf", "arial.ttf"]
)
for name in font_names:
try:
return ImageFont.truetype(name, size)
except Exception:
continue
return ImageFont.load_default()
def make_placeholder_art(card):
"""A simple backup image used if the AI art request fails, or if the
LLM invented a card type that has no matching artwork style."""
colors = TYPE_COLORS.get(card["type"], {"bg": (100, 100, 100)})
img = Image.new("RGB", (512, 512), colors["bg"])
draw = ImageDraw.Draw(img)
font = get_font(40)
draw.text((40, 236), card["type"] + " Card", font=font, fill=(255, 255, 255))
return img
def generate_card_art(theme, card):
"""Builds an image prompt and fetches free AI art from Pollinations.ai."""
prompt = (
f"{theme} {card['name']}, {card['type']} character, "
f"fantasy trading card illustration, digital painting, detailed, vibrant colors, centered"
)
encoded_prompt = urllib.parse.quote(prompt)
seed = random.randint(1, 999999)
url = f"https://image.pollinations.ai/prompt/{encoded_prompt}?width=512&height=512&seed={seed}&nologo=true"
try:
response = requests.get(url, timeout=60)
response.raise_for_status()
return Image.open(BytesIO(response.content)).convert("RGB")
except Exception as error:
print(f"Could not fetch AI art for '{card['name']}', using a placeholder instead. Reason: {error}")
return make_placeholder_art(card)
# ------------------------------------------------------------
# Card designer
# ------------------------------------------------------------
def wrap_text(text, font, max_width, draw):
"""Breaks a string into multiple lines so it fits inside max_width pixels."""
words = text.split()
lines = []
current = ""
for word in words:
test_line = (current + " " + word).strip()
box = draw.textbbox((0, 0), test_line, font=font)
if box[2] - box[0] <= max_width:
current = test_line
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines
def draw_card(card, art_image, theme):
"""Composes the final card image: background, artwork, title bar, type badge, effect text.
Card type may come from the LLM and not match a preset type; TYPE_COLORS.get()
below falls back to a neutral gray frame in that case."""
width, height = 500, 700
colors = TYPE_COLORS.get(card["type"], {"bg": (60, 60, 60), "border": (255, 255, 255)})
card_img = Image.new("RGB", (width, height), colors["bg"])
draw = ImageDraw.Draw(card_img)
draw.rectangle([0, 0, width - 1, height - 1], outline=colors["border"], width=10)
title_font = get_font(28)
draw.rectangle([15, 15, width - 15, 80], fill=(0, 0, 0))
title_box = draw.textbbox((0, 0), card["name"], font=title_font)
title_x = (width - (title_box[2] - title_box[0])) / 2
draw.text((title_x, 25), card["name"], font=title_font, fill=(255, 255, 255))
art_box = (20, 90, width - 20, 430)
art_resized = art_image.resize((art_box[2] - art_box[0], art_box[3] - art_box[1]))
card_img.paste(art_resized, (art_box[0], art_box[1]))
draw.rectangle(art_box, outline=colors["border"], width=4)
type_font = get_font(20)
draw.rectangle([20, 440, 200, 480], fill=colors["border"])
draw.text((30, 448), card["type"], font=type_font, fill=(0, 0, 0))
effect_font = get_font(18, bold=False)
draw.rectangle([20, 500, width - 20, height - 40], fill=(255, 255, 255))
lines = wrap_text(card["effect"], effect_font, width - 60, draw)
y = 515
for line in lines:
draw.text((35, y), line, font=effect_font, fill=(0, 0, 0))
y += 24
footer_font = get_font(13, bold=False)
draw.text((25, height - 30), theme.title(), font=footer_font, fill=colors["border"])
return card_img
# ------------------------------------------------------------
# Full pipeline
# ------------------------------------------------------------
def forge_full_game(theme):
"""Full pipeline: theme -> LLM-generated game text -> AI artwork -> designed card images."""
print(f"Generating game for theme: {theme}")
game_data = generate_game(theme)
print(f"Game created: {game_data['title']}")
card_images = []
for i, card in enumerate(game_data["cards"], start=1):
print(f"Generating artwork for card {i} of 4: {card['name']}")
art = generate_card_art(theme, card)
card_img = draw_card(card, art, theme)
card_images.append(card_img)
print("Finished generating game and cards.")
return game_data, card_images
# ------------------------------------------------------------
# Gradio web app
# ------------------------------------------------------------
EXAMPLE_THEMES = [
"Space Cats",
"Ancient Pirates",
"Escaping a Corporate Office",
"Medieval Kingdom",
"Desert Survivors",
"Robot Uprising",
]
def build_game(theme):
"""Connected to the Generate button. Runs the pipeline and formats the output."""
if not theme or not theme.strip():
return "Please enter a theme first.", []
game_data, card_images = forge_full_game(theme)
info_text = (
f"## {game_data['title']}\n\n"
f"**Description:** {game_data['description']}\n\n"
f"**Rules:**\n\n{game_data['rules']}"
)
gallery_items = [
(img, card["name"]) for img, card in zip(card_images, game_data["cards"])
]
return info_text, gallery_items
with gr.Blocks(title="AI BoardGame Forge") as demo:
gr.Markdown("# AI BoardGame Forge")
gr.Markdown(
"Enter a theme, or select one of the example themes below, "
"then click Generate Game to create a mini card game with AI-generated text and artwork."
)
theme_input = gr.Textbox(
label="Theme",
placeholder="e.g. Space Cats, Ancient Pirates, Escaping a Corporate Office",
)
gr.Examples(
examples=EXAMPLE_THEMES,
inputs=theme_input,
label="Example themes",
)
generate_button = gr.Button("Generate Game")
game_info_output = gr.Markdown()
card_gallery_output = gr.Gallery(
label="Generated Cards",
columns=4,
height="auto",
)
generate_button.click(
fn=build_game,
inputs=theme_input,
outputs=[game_info_output, card_gallery_output],
)
if __name__ == "__main__":
demo.launch()