gamekit · a 2D game library with zero third-party deps

pygame, minus the boilerplate

A full tutorial, 16 chapters, every snippet runs as-is

Chapter 0 What gamekit covers

An honest map from pygame to gamekit, before any code

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
Your code
sprites, rules, feel — the only layer you write
gamekit API
game.sprite() · @game.on_key · @game.on_collide · game.run()
Engine modules, all hand-written
core · sprites · physics · audio · ui · fx · utils
Standard library only
tkinter (render / input) · winsound (audio) · math / random / time
gamekit's stack (illustration). The bottom layers are hidden; you face a tiny API.

The pygame feature map

If you know pygame, this table is the cheat sheet. Every row is covered later in the tutorial.

pygamegamekitNotes
pygame.display.set_modeGame(width=, height=)add fps=
display.set_captionGame(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 + Groupgame.sprite() + tag=tags replace groups
sprite.collide_rectsprite.collides_with(other)
sprite.collide_circlesame callcircles auto-detect circles
draw.rectgame.sprite(..., shape="rect")
draw.circlegame.sprite(..., shape="circle")
draw.linegame.line(...)
draw.polygongame.polygon(...)
draw.ellipsegame.ellipse(...)
draw.arcgame.arc(...)
draw.linesgame.polygon(..., outline=...)
image.loadgame.sprite("file.png")PNG/GIF/BMP/PPM, no JPG
transform.scalesprite.set_scale(1.5)
transform.rotatesprite.set_angle(45)degrees
transform.flipsprite.flip(horizontal=True)
image.savesprite.save_image("out.ppm")PPM/GIF/BMP
font.Font.rendergame.text(...) + .set(...)update, don't recreate
mixer.Sound.playgame.sound("hit.wav").play()WAV, Windows
mixer.musicgame.music("bgm.wav")loops by default
time.Clock.tickfps=60handled for you
time.get_ticksgame.timeseconds, not ms
maskpixel_collide(a, b)pixel-perfect
math.Vector2Vec2
Rectsprite.left/right/top/bottom
ColorColor, named constants
QUITautomaticclosing stops the game
joystickno gamepad in stdlib
camerano webcam in stdlib
Surface + blitsprites or the on_draw hook
Two honest limits. Audio works on Windows only (winsound); elsewhere 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

Three lines and you have a game running
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

What actually happens 60 times a second

Every game is a loop. Each pass does three things:

1. Read input keys · mouse 2. Update world move · collide · score 3. Draw frame paint sprites on screen ↻ repeat every frame (fps times a second)
The game loop: read input → update world → draw frame

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 (like pygame.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.
The rule that keeps your game sane: think in pixels per second, never pixels per frame. Don't write "move 10 pixels every frame". Write "move 300 pixels per second" and multiply by dt. Then 60 fps and 30 fps feel identical. gamekit's built-in velocity does this for you.

Chapter 3 Sprites

The thing you see on screen
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

Velocity, gravity, friction, bounce — and manual control

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

Keys, hold state, and the mouse

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, F1F12, 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

Rectangles, circles, callbacks, and pixel-perfect

Collision detection is one question: do these two shapes overlap?

Overlap = hit Apart = no hit Circle: distance < radii sum = hit
Rect and circle collision (illustration)

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

Create once, update with .set()
PYTHONtitle = game.text("Score: 0", x=320, y=30, size=28, color="white",
                  bold=True, anchor="center")
  • x, y — position
  • size, color — obvious
  • font — family, default has solid CJK support
  • bold=True, italic=True — both work
  • anchor"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

Buttons with hover, progress bars with ratios
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)

Lines, polygons, ellipses, arcs

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

Load, scale, rotate, flip, save, animate
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

WAV effects and looping music
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. winsound is 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

gamekit's own extra: explosions and sparks
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

Menu, play, game over — as separate 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_enter creates everything; on_exit cleans 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 old on_exit, then new on_enter.

Chapter 14 Vec2 and colors

The math you'll actually do
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

Everything above, in one file

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()
Full file: 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

Check this before you debug for an hour
ProblemFix
Window closes instantlyAdd an ESC handler, or end with input() to hold it open
Text updates are slowYou're calling game.text() in a loop. Create once, call .set()
A sprite doesn't moveSet vx/vy, or change x/y in on_update. Also check it's visible
Collision never firesBoth need solid=True and visible=True (both default). And on_collide fires once per entry
Text shows boxesCustom font= can't render the glyph; use the default font
Image won't loadPNG, 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.every for countdowns and item respawns.
  • Publish your own game: python -m build then twine upload — gamekit itself ships exactly that way.
That's the whole library. Go make something.