A Python framework for building interactive arcade games that run entirely inside Google Slides.
Each reachable game state becomes one slide. Directional buttons are hyperlinks that jump between slides. Players open the presentation in Presentation mode and navigate with the on-screen arrows.
define game logic → BFS discovers all states → one slide per state
↓ ↓
BaseGame subclass builder.py Google Slides API
nav buttons wired with hyperlinks → play in Presentation mode (Ctrl+Shift+F5)
State spaces grow quickly when collectibles are involved (Pac-Man with n pellets has positions × 2ⁿ states). The package enforces a max_states cap (default 1 000) to catch runaway games before they hit the API.
pip install slide-gamesFollow these steps once per Google account.
- Open console.cloud.google.com.
- Click the project dropdown at the top-left → New Project.
- Give it any name → Create.
- In the left sidebar go to APIs & Services → Library.
- Search for Google Slides API → click it → Enable.
- Left sidebar → APIs & Services → OAuth consent screen.
- Select External → Create.
- Fill in App name (anything, e.g.
slide-games), User support email, and Developer contact email. - Click Save and Continue through all remaining steps until you reach the dashboard.
- Left sidebar → APIs & Services → Credentials.
- Click + Create Credentials → OAuth client ID.
- Application type: Desktop app → Create.
- In the confirmation dialog click Download JSON.
- Rename the downloaded file to
credentials.jsonand place it in the directory where you run your script.
The first time you call build_presentation() a browser tab opens asking you to sign in and grant access. After you approve, a token.json is saved alongside credentials.json so you won't be prompted again.
Never commit
credentials.jsonortoken.jsonto version control. Both are listed in.gitignoreby default.
from slide_games import build_presentation, MazeGame, generate_maze, DARK
maze = generate_maze(20, 16, algorithm="backtracker", seed=7)
url = build_presentation(MazeGame(maze), title="Maze", theme=DARK, max_states=700)
print(url)build_campaign links the win slide of each level to the starting position of the next, creating a seamless campaign.
from slide_games import build_campaign, MazeGame, generate_maze, DARK
levels = [
MazeGame(generate_maze(20, 16, algorithm="backtracker", seed=7)), # Easy
MazeGame(generate_maze(25, 20, algorithm="prim", seed=42)), # Medium
MazeGame(generate_maze(30, 24, algorithm="kruskal", seed=99)), # Hard
]
url = build_campaign(levels, title="Maze Quest", theme=DARK, max_states=1500)
print(url)Subclass BaseGame and implement three methods. The package handles state discovery, slide creation, rendering, and linking.
from slide_games import BaseGame, build_presentation
class MyGame(BaseGame):
def get_initial_state(self):
"""Return the starting state (any hashable object)."""
...
def get_transitions(self, state):
"""Return {direction: next_state_or_None} for all four directions.
Use None for blocked directions — the button is rendered but grayed out.
"""
return {
"up": ...,
"down": ...,
"left": ...,
"right": ...,
}
def is_terminal(self, state) -> bool:
"""Return True when the game is won (or lost)."""
...
url = build_presentation(MyGame(), title="My Game")Override render() to draw fully custom visuals using a pygame-like API. The D-pad buttons and win banner are still added automatically — keep content above y = NAV_RESERVED_Y (~820 px) to avoid overlap.
from slide_games import BaseGame, build_presentation, SCREEN_W, NAV_RESERVED_Y
from slide_games.gfx import Color, Rect, draw
class MyGame(BaseGame):
...
def render(self, surface, state) -> None:
# 1920 × 1080 virtual coordinate space
surface.fill(Color(10, 10, 40))
draw.rect(surface, Color(0, 200, 100),
Rect(state.x * 80, state.y * 80, 70, 70),
border_radius=10)
draw.text(surface, f"Score: {state.score}",
Rect(20, 20, 400, 50),
color=Color(255, 255, 255), font_size=28, bold=True)Available drawing functions:
| Function | Description |
|---|---|
surface.fill(color) |
Fill background |
draw.rect(surface, color, Rect(x,y,w,h), border_radius=0, width=0) |
Rectangle (filled or outline) |
draw.circle(surface, color, (cx,cy), radius, width=0) |
Circle (filled or outline) |
draw.line(surface, color, (x1,y1), (x2,y2), width=1) |
Line segment |
draw.lines(surface, color, points, width=1, closed=False) |
Multi-segment polyline |
draw.triangle(surface, color, rect, direction="up", width=0) |
Triangle — direction: "up" "down" "left" "right" |
draw.shape(surface, color, shape_type, rect, width=0) |
Any Google Slides built-in shape ("DIAMOND", "STAR_5", "HEXAGON", …) |
draw.progress_bar(surface, rect, value, max_value, fg_color, bg_color, border_radius=0) |
Filled progress bar |
draw.text(surface, text, rect, color, font_size=24, bold=False, italic=False, align="LEFT", vertical_align="MIDDLE") |
Text label |
Color supports arithmetic and conversion helpers: lerp(other, t), darken(f), lighten(f), with_alpha(a), grayscale(), complementary().
Rect helpers: scale_by(fx, fy), padded(px, py), clip(other), union(other), fit(other), clamp(other), contains(other), plus midpoint properties (midleft, midright, midtop, midbottom).
Vector2 provides a full 2D vector type: arithmetic operators, normalize(), dot(), cross(), distance_to(), lerp(), rotate(degrees), reflect(), from_polar().
See examples/snake_demo.py, examples/pacman_demo.py, and examples/sokoban_demo.py for complete rendering examples.
For games that use the default grid renderer, override these instead of render():
def get_cell_color(self, state, ch: str, is_player: bool) -> dict | None:
"""Return an RGB dict to override a cell's fill, or None for the theme default."""
if is_player:
return {"red": 1.0, "green": 0.0, "blue": 0.5}
return None
def get_cell_image_url(self, state, ch: str, is_player: bool) -> str | None:
"""Return a public image URL to overlay on a cell, or None."""
if is_player:
return "https://example.com/hero.png"
return None
def get_extra_shapes(self, state) -> list[dict]:
"""Return extra shapes to draw on top of the grid."""
return [
{"type": "ellipse", "x": 2.1, "y": 1.1, "w": 0.8, "h": 0.8,
"color": {"red": 1.0, "green": 0.0, "blue": 0.0}},
]
def show_win_banner(self, state) -> bool:
"""Return False to suppress the win banner (e.g. for loss states)."""
return self.pellets <= state.eaten and state.player != state.ghostbuild_presentation(
game, # BaseGame instance
title="Slide Game", # presentation title
theme=DARK, # Theme object
credentials_file="credentials.json",
verbose=True, # print progress to stdout
max_states=1_000, # hard cap — raises ValueError if exceeded
progress_callback=None, # fn(done, total, phase) — phase is "building" or "sending"
retry_callback=None, # fn(remaining_seconds) — called during rate-limit waits
) -> str # URL of the created presentationbuild_campaign(
games, # list[BaseGame] — one per level, in order
title="Slide Game",
theme=DARK,
credentials_file="credentials.json",
verbose=True,
max_states=1_000, # per-level cap
progress_callback=None,
retry_callback=None,
) -> str # URL of the created presentationThe win slide of each level includes a Next Level ► button that jumps to the next level's starting position. The final level's win slide shows only "YOU WIN!" with no next-level button.
def my_progress(done: int, total: int, phase: str) -> None:
# phase == "building" → done/total are state counts
# phase == "sending" → done/total are API request counts
print(f"{phase}: {done}/{total}")
def my_retry(remaining_seconds: int) -> None:
print(f"Rate limited — retrying in {remaining_seconds}s")
url = build_campaign(levels, progress_callback=my_progress, retry_callback=my_retry)The package exports 40+ named Color constants for convenience:
from slide_games import (
BLACK, WHITE, RED, GREEN, BLUE, YELLOW, CYAN, MAGENTA,
ORANGE, PURPLE, GRAY, LIGHT_GRAY, DARK_GRAY,
GOLD, SILVER, BROWN, PINK, HOT_PINK, NAVY, TEAL,
CORAL, SALMON, TOMATO, VIOLET, INDIGO, CRIMSON,
SKY_BLUE, SLATE_GRAY, MINT, TURQUOISE, # … and more
)| Name | Style |
|---|---|
DARK |
Dark navy/blue (default) |
PACMAN |
Classic black + blue walls |
RETRO |
Dark with orange walls |
Create a custom theme:
from slide_games import Theme
from slide_games.themes import rgb
MY_THEME = Theme(
name="my_theme",
background=rgb(10, 10, 30),
wall=rgb(80, 40, 120),
floor=rgb(20, 20, 50),
player=rgb(255, 100, 0),
goal=rgb(0, 220, 120),
pellet=rgb(200, 200, 200),
btn_active=rgb(80, 40, 120),
btn_inactive=rgb(40, 40, 60),
btn_text=rgb(255, 255, 255),
title_text=rgb(255, 200, 50),
win_text=rgb(255, 200, 50),
)| Game type | States | Notes |
|---|---|---|
| Maze, 16×16 | ~511 | passable cells only |
| Maze, 20×16 | ~639 | |
| Maze, 25×20 | ~999 | |
| Snake, 3×3 grid | ~517 | all self-avoiding walk prefixes |
| Snake, 4×4 grid | ~5 000 | grows quickly with grid size |
| Pac-Man, 9×7, 2 pellets | ~491 | player × ghost × pellet states |
| Pac-Man, 15×11, 2 pellets | ~2 700 | positions × 2² pellet states |
| Sokoban, 7×7, 1 box | ~500 | player × box positions |
| Sokoban, 7×7, 2 boxes | ~5 000 | player × box² configurations |
Pass a higher max_states when you know the count is safe. BFS state discovery runs locally and is fast; the slow part is uploading content to the Google Slides API.
The sending phase uploads slide content via batchUpdate calls to the Google Slides API (quota: 60 write calls/minute per user). The builder uses a global token-bucket rate limiter (≤50 calls/minute, shared across all concurrent build_campaign calls) and sends up to 5 batches concurrently (500 requests each) per demo. Requests retry automatically on rate-limit (429) and transient connection errors with exponential backoff.
For typical presentations (~500 states):
- Building phase (pure Python, BFS + rendering): a few seconds
- Sending phase (network-bound): roughly 1–3 minutes depending on API latency
python examples/run_all_demos.pyBuilds all four demos in parallel (~500 states each), with a live progress table that updates in real time. Each demo's URL is printed the moment it finishes.
| Demo | Grid / Level | ~States |
|---|---|---|
| Maze Quest | 16×16 maze | 511 |
| Snake | 3×3 grid | 517 |
| Pac-Man | 9×7 figure-8 maze, 2 pellets | 491 |
| Sokoban | 7×7, 1 box | 600 |
Individual demos:
python examples/maze_demo.py
python examples/snake_demo.py
python examples/pacman_demo.py
python examples/sokoban_demo.pypytest # run all tests
pytest --cov=slide_games # with coverage reportNo Google API credentials are required — all API calls are mocked.
Hosted builder — the biggest friction right now is the OAuth setup: users need to create a GCP project, enable the Slides API, download credentials.json, and do a browser consent flow. A hosted version (thin FastAPI backend, user signs in with Google OAuth via the hosted app's credentials) would reduce time-to-first-game from ~10 minutes to ~30 seconds. The rate-limiter and token-bucket are already built; they just need to be shared across users instead of per-user.
Async/concurrent API client — the current sending phase uses httpx synchronously inside a ThreadPoolExecutor. Replacing it with a fully async client (httpx.AsyncClient + asyncio) would remove the thread overhead and make the concurrency model explicit, which matters when building multiple presentations in the same process.
More game templates — the BaseGame abstraction is deliberately generic, but Tetris and 2048 are the two most-requested additions from the demos. Both have bounded state spaces at small grid sizes (4×4 Tetris ~10k reachable states; 2048 at 4×4 is tractable under max_states with some pruning). Adding them as built-in templates would show the framework's range without requiring users to implement game logic.
Multiplayer race mode — two players solving the same maze simultaneously, each controlling their own player dot. The state space is player_A_position × player_B_position, which squares the slide count — too large for arbitrary mazes — but for small mazes (≤ 8×8) it's feasible and would be a genuinely novel Google Slides experience.
Contributions are welcome. See CONTRIBUTING.md for dev setup, code style, and PR guidelines. To report a security vulnerability, see SECURITY.md.
Optional pre-commit hooks (ruff + mypy on every commit) — setup instructions in CONTRIBUTING.md.
See CHANGELOG.md for version history.
python -m build
twine upload dist/*