Chapter 0 What gamekit covers
You want to make a 2D game, but every time you open the pygame docs you spend an hour setting up a window and an event loop before anything moves on screen.
gamekit is the opposite. One class, a handful of methods, zero third-party dependencies — it runs on the standard library (tkinter for rendering and input, winsound for audio). You still get what pygame gives you: sprites, input, collisions, images, fonts, sound, shapes, timing, scenes. The code you write is about a third of the size.
Install it, then continue:
SHELLpip install gamekit
game.sprite() · @game.on_key · @game.on_collide · game.run()The pygame feature map
If you know pygame, this table is the cheat sheet. Every row is covered later in the tutorial.
| pygame | gamekit | Notes |
|---|---|---|
pygame.display.set_mode | Game(width=, height=) | add fps= |
display.set_caption | Game(title=) | |
event.get() / KEYDOWN | @game.on_key(Key.X) | fires once per press |
key.get_pressed() | game.is_key_down(Key.X) | hold-state inside on_update |
KEYUP | @game.on_key_up(Key.X) | |
MOUSEMOTION | @game.on_mouse_move | |
MOUSEBUTTONDOWN/UP | @game.on_mouse_down/up | |
mouse.get_pos() | game.mouse_x, game.mouse_y | |
Sprite + Group | game.sprite() + tag= | tags replace groups |
sprite.collide_rect | sprite.collides_with(other) | |
sprite.collide_circle | same call | circles auto-detect circles |
draw.rect | game.sprite(..., shape="rect") | |
draw.circle | game.sprite(..., shape="circle") | |
draw.line | game.line(...) | |
draw.polygon | game.polygon(...) | |
draw.ellipse | game.ellipse(...) | |
draw.arc | game.arc(...) | |
draw.lines | game.polygon(..., outline=...) | |
image.load | game.sprite("file.png") | PNG/GIF/BMP/PPM, no JPG |
transform.scale | sprite.set_scale(1.5) | |
transform.rotate | sprite.set_angle(45) | degrees |
transform.flip | sprite.flip(horizontal=True) | |
image.save | sprite.save_image("out.ppm") | PPM/GIF/BMP |
font.Font.render | game.text(...) + .set(...) | update, don't recreate |
mixer.Sound.play | game.sound("hit.wav").play() | WAV, Windows |
mixer.music | game.music("bgm.wav") | loops by default |
time.Clock.tick | fps=60 | handled for you |
time.get_ticks | game.time | seconds, not ms |
mask | pixel_collide(a, b) | pixel-perfect |
math.Vector2 | Vec2 | |
Rect | sprite.left/right/top/bottom | |
Color | Color, named constants | |
QUIT | automatic | closing stops the game |
joystick | — | no gamepad in stdlib |
camera | — | no webcam in stdlib |
Surface + blit | sprites or the on_draw hook |
play() prints a note and the game keeps
running. And there's no gamepad or webcam support — the standard library
has neither. For those two, keep pygame. For everything else on the table,
keep reading.Chapter 1 Your first window
PYTHONfrom gamekit import Game
game = Game(title="Hello", width=640, height=480, fps=60)
game.run()
Run python hello.py. A dark window opens and stays. That's
a game — the loop is already running, drawing the background 60 times a
second.
The Game object is the whole library. You create sprites
through it, register callbacks on it, and start everything with one
run() call.
Chapter 2 The game loop
Every game is a loop. Each pass does three things:
gamekit runs this loop for you at your fps. You write the
"update world" part — and even that is optional at first.
Three values you'll use constantly:
game.time— seconds since start (likepygame.time.get_ticks, but in seconds).game.dt— how long the last frame took, in seconds. Every update callback gets it.fps— loop iterations per second.
dt. Then 60 fps and 30 fps feel identical. gamekit's built-in
velocity does this for you.Chapter 3 Sprites
PYTHONfrom gamekit import Game
game = Game(title="Sprites", width=640, height=480)
# A red square, centre at (320, 240)
box = game.sprite(color="red", x=320, y=240, width=80, height=80)
# A gold circle
ball = game.sprite(color="gold", x=100, y=100, width=40, height=40, shape="circle")
game.run()
Coordinates work like pygame: (0, 0) is top-left, x grows right, y
grows down. x and y are the sprite's centre.
PYTHONplayer = game.sprite(color="skyblue", x=100, y=100, width=60, height=60,
shape="rect", tag="player", layer=0)
player.x, player.y # position (centre)
player.vx, player.vy # velocity, px/s
player.width, player.height # size
player.visible # True/False
player.layer # draw order, bigger on top
player.tag # label for groups
player.solid # takes part in collisions
Two shapes: "rect" (default) and "circle". A
circle sprite collides as a circle, not as a box. Image sprites ignore
shape — the image sets the size.
tag is your group. In pygame you'd make a Group
for bullets; here you tag every bullet and match them at once:
PYTHONenemies = game.find("enemy") # all sprites tagged "enemy"
Chapter 4 Movement
Set a velocity and the sprite moves itself. No manual
x += speed needed.
PYTHONgame = Game(width=640, height=480)
ball = game.sprite(color="gold", x=320, y=240, width=30, height=30, shape="circle")
ball.vx = 200 # 200 px/s right
ball.vy = 150 # 150 px/s down
ball.bounce = 1.0 # bounce off screen edges
game.run()
bounce takes 0 to 1. 0.5 loses half the
speed each bounce. Default 0 flies off screen forever.
Physics flags
PYTHONgame.gravity = 500 # px/s², only affects gravity_scale > 0
ball = game.sprite(color="cyan", x=320, y=100, width=20, height=20, shape="circle")
ball.gravity_scale = 1.0 # 0 = ignore gravity, 2.0 = falls twice as fast
ball.bounce = 0.8 # bounce off the floor
ball.friction = 0.5 # velocity decays
ball.keep_on_screen = True # stop at edges instead of bouncing
Pick one of bounce or keep_on_screen. One
bounces, the other stops.
Manual control
PYTHON@game.on_update
def tick(dt):
player.x += 300 * dt # constant rightward speed
player.x = game.mouse_x # follow the mouse
player.y = game.mouse_y
import math
player.x = 320 + math.cos(game.time * 2) * 150 # orbit
player.y = 240 + math.sin(game.time * 2) * 150
move(dx, dy) shifts relative, move_to(x, y)
jumps to a point, look_at(other) points at a sprite or a
(x, y) tuple.
Chapter 5 Input
Fire once
PYTHONfrom gamekit import Game, Key
@game.on_key(Key.SPACE)
def jump():
player.vy = -400
@game.on_key(Key.ESC)
def quit_game():
game.stop()
@game.on_key("a") # letters work as plain strings
def move_left():
player.vx = -300
Key constants: Key.SPACE, UP/DOWN/LEFT/RIGHT,
ENTER, ESC, TAB,
SHIFT/CTRL/ALT, F1–F12, plus
"a"–"z" and "0"–"9".
Hold state — the one you'll use most
PYTHON@game.on_key_hold(Key.LEFT)
def left():
player.vx = -300
@game.on_key_hold(Key.RIGHT)
def right():
player.vx = 300
@game.on_key_up(Key.LEFT) # fires on release
def stop_left():
player.vx = 0
Or check the held state inside on_update, which some
people find cleaner:
PYTHON@game.on_update
def tick(dt):
if game.is_key_down(Key.LEFT):
player.vx = -300
elif game.is_key_down(Key.RIGHT):
player.vx = 300
else:
player.vx = 0
Mouse
PYTHON@game.on_mouse_click # left click, fn(x, y)
def click(x, y):
print("clicked", x, y)
@game.on_mouse_move # fn(x, y)
@game.on_mouse_down # any button, fn(x, y, button)
@game.on_mouse_up # any release, fn(x, y, button)
@game.on_mouse_wheel # fn(delta, x, y)
Position is always readable: game.mouse_x,
game.mouse_y. A paddle that follows the mouse is three
lines:
PYTHON@game.on_update
def tick(dt):
paddle.x = game.mouse_x
Chapter 6 Collision
Collision detection is one question: do these two shapes overlap?
Check manually
PYTHONif player.collides_with(enemy):
print("hit")
if player.contains(x, y): # is a point inside?
print("pointer on player")
d = player.distance_to(enemy) # centre distance
Automatically, with a callback
@game.on_collide(a, b) fires once, the moment two
objects start touching — not every frame. That's what you want for
"ate a coin" or "bullet hit an enemy".
PYTHON# Two specific sprites
@game.on_collide(player, coin)
def collect(p, c):
c.remove()
# A sprite against a tag — every sprite tagged "coin"
@game.on_collide(player, "coin")
def collect(p, c):
c.remove()
# Tag against tag — how shooting works
@game.on_collide("bullet", "enemy")
def hit(bullet, enemy):
bullet.remove()
enemy.remove()
Bounce off something
PYTHON@game.on_collide(ball, brick)
def hit(ball, brick):
brick.remove()
ball.bounce_off(brick) # flips vx or vy based on which side hit
Pixel-perfect collision
PYTHONfrom gamekit import pixel_collide
if pixel_collide(player, coin): # both must be image sprites
coin.remove()
Rectangles lie at the corners. When that matters,
pixel_collide compares actual pixels: both images need a
non-transparent pixel at the same spot. No image? It falls back to a
rectangle check.
Jump and land
PYTHONgame.gravity = 600
player.gravity_scale = 1.0
ground = game.sprite(color="gray", x=320, y=580, width=640, height=40)
@game.on_collide(player, ground)
def land(p, g):
if p.vy > 0: # only land while falling
p.y = g.top - p.height / 2
p.vy = 0
Chapter 7 Text and fonts
PYTHONtitle = game.text("Score: 0", x=320, y=30, size=28, color="white",
bold=True, anchor="center")
x, y— positionsize,color— obviousfont— family, default has solid CJK supportbold=True,italic=True— both workanchor—"center"(default),"nw","n", …
The one habit that matters:
PYTHONscore = 0
score_text = game.text("Score: 0", x=320, y=30, size=28)
@game.on_collide(player, "coin")
def collect(p, c):
global score
score += 10
c.remove()
score_text.set("Score: %d" % score) # update in place
Calling game.text() every frame to "update" text creates
objects and runs slow. One object, one .set() per change.
Chapter 8 UI widgets
PYTHONgame.button("Start", x=320, y=300, width=180, height=52,
on_click=start_game) # fn(button)
def start_game(button):
button.text = "Running"
PYTHONhp = game.progress_bar(x=320, y=30, width=300, height=20,
value=100, max_value=100, color="lime")
hp.set_value(60) # converts to a ratio automatically
Buttons highlight on hover and fire on_click on release.
Progress bars are made for health bars, XP bars, and timers.
Chapter 9 Drawing shapes (pygame.draw)
Sprites are solid rectangles and circles. For a line, polygon, ellipse, or arc, use the shape functions. Shapes are managed like sprites — movable, layerable, removable — but they stay visual: shapes don't collide. If it needs to collide, make it a sprite.
PYTHON# Line from (x1, y1) to (x2, y2)
game.line(x1=100, y1=400, x2=300, y2=400, color="white", width=3)
# Polygon from a list of points
game.polygon([(400, 400), (440, 350), (480, 400)],
color="violet", outline=None)
# Ellipse (or circle) centred at (x, y)
game.ellipse(x=120, y=250, width=80, height=40, color="skyblue")
# Arc — start and extent in degrees, 0 = right, clockwise
game.arc(x=560, y=250, width=60, height=60,
start=0, extent=270, color="lime", width_px=3)
Every shape returns an object with x, y, vx, vy, visible, layer,
tag and remove(). Give a line a velocity and it
drifts. Give a polygon a tag and you can find it later.
Chapter 10 Images and animation
PYTHONplayer = game.sprite("player.png", x=320, y=240) # path as first arg
player.set_scale(1.5) # 1.5x
player.set_angle(45) # 45 degrees
player.flip(horizontal=True) # mirror it
player.set_image("other.png") # swap image
Supported formats: PNG, GIF, BMP, PPM. No JPG — tkinter can't read it. Rotation and non-integer scaling resample pixels on the fly: fine at load time for a few sprites, slow if you re-rotate a huge image every frame.
Save an image
PYTHONplayer.save_image("out.ppm") # pygame.image.save
PPM, GIF, and BMP work. PNG doesn't, because the standard library can't write it.
Frame animation
PYTHON# Two frames of a coin flashing, 4 fps, looping
coin = game.sprite("coin1.png", x=320, y=200)
coin.play(["coin1.png", "coin2.png"], fps=4, loop=True)
coin.stop_animation()
The example assets come from examples/make_assets.py,
which draws PNGs using only the standard library.
Chapter 11 Sound
PYTHON# Play once
hit = game.sound("hit.wav")
hit.play()
# Looping background music
music = game.music("bgm.wav") # same as game.sound(path, loop=True)
music.stop()
That's the whole API. The constraints are real, so repeat them:
- WAV only. The standard library decodes wav and nothing else.
- Windows only.
winsoundis Windows. Elsewhere,play()prints a note and continues. - One sound at a time. Music and effects can't overlap. For layered audio, pygame is the better tool.
Chapter 12 Particles
PYTHON# One-shot explosion
game.burst(x=320, y=240, count=40,
colors=("orange", "yellow", "red"), # picked at random
speed=(50, 260), # initial speed range, px/s
life=(0.4, 1.5)) # lifetime range, seconds
# A system you can trigger repeatedly
stars = game.particles(x=320, y=200, count=30, colors=("white", "cyan"))
stars.burst() # fire a batch
stars.burst(count=60) # or specify how many
A burst in a collision callback is the fastest way to make a hit feel good:
PYTHON@game.on_collide("bullet", "enemy")
def hit(bullet, enemy):
game.burst(enemy.x, enemy.y, count=20, colors=("orange", "yellow"))
bullet.remove()
enemy.remove()
Chapter 13 Scenes
A scene owns its sprites, text, buttons, and particles. Switching scenes swaps them out.
PYTHONfrom gamekit import Game, Scene
class MenuScene(Scene):
def on_enter(self, game): # build the scene here
game.text("My Game", x=320, y=200, size=48, bold=True)
game.button("Start", x=320, y=320, on_click=self.start)
def start(self, button):
self.game.switch_scene(GameScene())
def on_exit(self): # leave the scene here
self.clear()
class GameScene(Scene):
def on_enter(self, game):
self.player = game.sprite(color="cyan", x=320, y=400, width=50, height=50)
def on_update(self, dt):
...
game = Game(width=640, height=480)
game.switch_scene(MenuScene())
game.run()
on_entercreates everything;on_exitcleans up.- Collision callbacks are wiped on every scene switch — register the
ones you need inside
on_enter. Key, mouse, and update callbacks are global and survive. game.switch_scene(Scene)calls oldon_exit, then newon_enter.
Chapter 14 Vec2 and colors
PYTHONfrom gamekit import Vec2
v = Vec2(3, 4)
v.length() # 5.0
v.normalized() # unit vector
v2 = Vec2.from_angle(45, 10) # 10 px/s at 45 degrees
v.rotated(90) # rotate a vector
v.dot(other) # dot product
Pointing a bullet at the player:
PYTHONdirection = (Vec2(enemy.x, enemy.y) - Vec2(player.x, player.y)).normalized()
bullet.vx = direction.x * 300
bullet.vy = direction.y * 300
PYTHONfrom gamekit import RED, to_color, mix
sprite.color = RED # a named constant
sprite.color = (255, 100, 0) # an (r, g, b) tuple
sprite.color = "#ff6400" # a hex string
sprite.color = mix("red", "blue", 0.5) # halfway between
Any API that takes a color accepts all three forms.
Chapter 15 The full game: space shooter
Left/right to move, space to shoot (hold for auto-fire), kill enemies for points, don't get hit.
PYTHONimport random
from gamekit import Game, Scene, Key
W, H = 640, 700
class GameScene(Scene):
def __init__(self):
super().__init__("game")
self.score = 0
self.lives = 3
self.state = "play" # play / over
self.shoot_cd = 0.0 # fire cooldown
self.spawn_timer = 0.0 # enemy spawn timer
def on_enter(self, game):
self.game = game
game.bg_color = "#0a0e18"
self.player = game.sprite(color="#4ac0f0", x=W // 2, y=H - 60,
width=50, height=40)
self.player.keep_on_screen = True
self.score_text = game.text("Score 0", x=90, y=26, size=20, bold=True)
self.lives_text = game.text("Lives 3", x=W - 90, y=26, size=20, bold=True)
# Register collisions here — wiped on scene switch
game.on_collide("bullet", "enemy")(self._on_bullet_enemy)
game.on_collide("enemy", self.player)(self._on_enemy_player)
def on_update(self, dt):
if self.state != "play":
return
if self.game.is_key_down(Key.LEFT) or self.game.is_key_down("a"):
self.player.vx = -320
elif self.game.is_key_down(Key.RIGHT) or self.game.is_key_down("d"):
self.player.vx = 320
else:
self.player.vx = 0
if self.game.is_key_down(Key.SPACE): # hold to auto-fire
self.shoot()
self.shoot_cd -= dt
self.spawn_timer -= dt
if self.spawn_timer <= 0:
self.spawn_timer = 1.2
self.spawn_enemy()
for e in self.game.find("enemy"):
if e.y > H + 30:
e.remove()
def spawn_enemy(self):
self.game.sprite(color=random.choice(["#ff6b6b", "#ff9f43", "#c86bff"]),
x=random.randint(40, W - 40), y=-20,
width=36, height=30, tag="enemy").vy = random.randint(120, 220)
def shoot(self):
if self.shoot_cd > 0 or self.state != "play":
return
self.shoot_cd = 0.28
self.game.sprite(color="#ffd700", x=self.player.x, y=self.player.y - 26,
width=6, height=14, tag="bullet").vy = -520
def _on_bullet_enemy(self, bullet, enemy):
self.score += 10
self.score_text.set("Score %d" % self.score)
self.game.burst(enemy.x, enemy.y, count=16,
colors=("orange", "yellow", "white"))
bullet.remove()
enemy.remove()
def _on_enemy_player(self, enemy, player):
if self.state != "play":
return
self.lives -= 1
self.lives_text.set("Lives %d" % self.lives)
self.game.burst(player.x, player.y, count=20, colors=("cyan", "white"))
enemy.remove()
if self.lives <= 0:
self._game_over()
def _game_over(self):
self.state = "over"
for e in self.game.find("enemy"):
e.remove()
self.game.text("GAME OVER", x=W // 2, y=280, size=48, bold=True, color="#ff6b6b")
self.game.text("Score %d" % self.score, x=W // 2, y=340, size=26)
self.game.button("Play again", x=W // 2, y=410, width=160, height=48,
on_click=lambda b: self.game.switch_scene(GameScene()))
self.game.button("Menu", x=W // 2, y=480, width=160, height=48,
on_click=lambda b: self.game.switch_scene(MenuScene()))
def on_exit(self):
self.clear()
class MenuScene(Scene):
def on_enter(self, game):
game.bg_color = "#0a0e18"
game.text("SPACE SHOOTER", x=W // 2, y=240, size=52, bold=True, color="#4ac0f0")
game.text("Arrows / AD to move, space to shoot. Don't get hit.",
x=W // 2, y=310, size=16, color="#8899aa")
game.button("PLAY", x=W // 2, y=390, width=200, height=54,
on_click=lambda b: self.game.switch_scene(GameScene()))
game.button("QUIT", x=W // 2, y=470, width=140, height=42,
on_click=lambda b: self.game.stop())
def on_exit(self):
self.clear()
def main():
game = Game(title="Space Shooter", width=W, height=H, fps=60)
@game.on_key(Key.ESC)
def quit_game():
game.stop()
game.switch_scene(MenuScene())
game.run()
if __name__ == "__main__":
main()
examples/05_space_shooter.py,
run python examples/05_space_shooter.py. It uses sprites,
held-key input, tag collisions, particles, text, scenes, and buttons —
everything from the chapters above.Chapter 16 FAQ and gotchas
| Problem | Fix |
|---|---|
| Window closes instantly | Add an ESC handler, or end with input() to hold it open |
| Text updates are slow | You're calling game.text() in a loop. Create once, call .set() |
| A sprite doesn't move | Set vx/vy, or change x/y in on_update. Also check it's visible |
| Collision never fires | Both need solid=True and visible=True (both default). And on_collide fires once per entry |
| Text shows boxes | Custom font= can't render the glyph; use the default font |
| Image won't load | PNG, GIF, BMP, PPM only. No JPG. Check the path |
Performance
- A few hundred solid sprites run smooth. A few thousand drop frames — every frame redraws everything.
- Prefer tags over per-sprite checks.
- Rotating large images is expensive. Rotate at load time, not per frame.
- Reuse text objects.
Next steps
- Speed the enemies up over time.
- Turn the jump-and-land into a platformer.
- Use
game.everyfor countdowns and item respawns. - Publish your own game:
python -m buildthentwine upload— gamekit itself ships exactly that way.