Skip to main content

A wrapper around Pygame that makes it easier to use.

Project description

Sky Engine

Python 3.14

Makes pygame (or rather, pygame-ce, more specifically) less painful to use. More like a wrapper than an engine. Fully typed with basedpyright.

Theoretically cross-platform, but mostly tested on Windows. May have some window manager weirdness on Linux, specifically when it comes to fullscreening.

Quick Start

Due to the engine's many defaults, only 2 lines of code are required to get started. This opens an 800x600 window, centered on the main monitor, with a black background:

from sky import App

App().mainloop()

To modify the app's defaults, including the default window's properties, one may use the spec argument:

from sky import App, AppSpec, Vector2, WindowSpec
from sky.colors import CRIMSON

app = App(
    spec=AppSpec(
        window_spec=WindowSpec(title="My Window", size=Vector2(400, 400), fill=CRIMSON)
    )
)

app.mainloop()

For a headless App, one may simply set window_spec to None, or use the AppSpec.headless() classmethod.

Sky provides users many Hooks that may contain callbacks to be executed whenever the Hook is triggered. They can be used as decorators, which makes for particularly elegant code:

from pygame import draw

from sky import App, WindowSpec
from sky.colors import ALICE_BLUE, CRIMSON

app = App(spec=WindowSpec(fill=CRIMSON))


@app.on_setup
def setup() -> None:
    print("This will run as the app starts.")


@app.pre_update
def pre_update() -> None:
    print("This will run every frame.")


@app.window.on_render
def on_render1() -> None:
    print(
        "This will also run every frame, but is tied to a certain Window. Use this for rendering!"
    )


@app.on_render
def on_render2() -> None:
    print("Alternatively, use the alias `app.on_render`.")
    draw.aacircle(app.window.surface, ALICE_BLUE, app.window.center, 32)


@app.on_cleanup
def cleanup() -> None:
    print("This will run as soon as the app finishes running.")


app.mainloop()

Note: although Sky itself doesn't favor any particular module or form of rendering, we will use pygame.draw for examples, as it comes bundled with pygame. For examples that perform hardware rendering using other libraries, see the examples folder.

Hooks may also have their execution cancelled. Example:

from sky import App, Hook
from sky.utils import discard

app = App()

some_event = Hook(cancellable=True)


@some_event
def some_event1() -> None:
    print("This will print.")
    some_event.cancel()


@some_event
def some_event2() -> None:
    print("This will not print.")


app.on_setup += lambda: discard(some_event.invoke())

app.mainloop()

Combining Hooks, Specs, and rendering, we can create two windows with differently colored backgrounds that render differently colored circles to their surfaces:

from pygame import draw

from sky import App, AppSpec, Color, Window, WindowSpec
from sky.colors import CRIMSON, DODGER_BLUE
from sky.utils import discard

app = App(spec=AppSpec.headless())  # no default window since we'll add our own

window1 = app.windowing.add_window(spec=WindowSpec(title="Window 1", fill=CRIMSON))
window2 = app.windowing.add_window(spec=WindowSpec(title="Window 2", fill=DODGER_BLUE))


def render_to(window: Window, color: Color) -> None:
    window.on_render += lambda: discard(
        draw.aacircle(window.surface, color, window.center, 32)
    )


render_to(window1, DODGER_BLUE)
render_to(window2, CRIMSON)


app.mainloop()

To allow for interactions by grabbing user input, users may utilize the mouse and keyboard services. With them, we can render a circle to the screen that moves according to the player's WASD input, and changes size with the right and left mouse buttons:

from pygame import draw

from sky import App, Key, MouseButton, WindowSpec
from sky.colors import ALICE_BLUE, CRIMSON

app = App(spec=WindowSpec(fill=CRIMSON))

pos = app.window.center
speed = 2
radius = 32


@app.on_render
def render() -> None:
    global pos
    pos += app.keyboard.get_movement_2d((Key.a, Key.d), (Key.w, Key.s)) * speed
    draw.aacircle(app.window.surface, ALICE_BLUE, pos, radius)


@app.mouse.on_mouse_button_downed
def change_radius(button: MouseButton) -> None:
    global radius
    radius += -1 if button == MouseButton.right else 1


app.mainloop()

This isn't the only way to grab input, however. One may also check for a key's or button's state every frame, using the State checking methods is_downed, is_pressed and is_released. Here's the example shown above, but using those functions instead:

from pygame import draw

from sky import App, MouseButton, State, WindowSpec, Key
from sky.colors import ALICE_BLUE, CRIMSON

app = App(spec=WindowSpec(fill=CRIMSON))

pos = app.window.center
speed = 2
radius = 32


@app.on_render
def render() -> None:
    global pos, radius

    if app.mouse.any(State.downed):
        radius += -1 if app.mouse.is_downed(MouseButton.right) else 1

    pos += app.keyboard.get_movement_2d((Key.a, Key.d), (Key.w, Key.s)) * speed
    draw.aacircle(app.window.surface, ALICE_BLUE, pos, radius)


app.mainloop()

Using globals for everything, like we did with pos, speed and radius, is bad practice. Using Components, the engine's fundamental object type, used to represent anything in a game, we can once again rewrite the example above, packaging those values into a single object:

from dataclasses import dataclass, field
from typing import override

from pygame import draw

from sky import App, Component, Vector2, WindowSpec
from sky.colors import ALICE_BLUE, CRIMSON

app = App(spec=WindowSpec(fill=CRIMSON))


@dataclass
class Player(Component):
    pos: Vector2 = field(default_factory=lambda: app.window.center)
    speed: float = 2
    radius: int = 32

    @override
    def update(self) -> None:
        self.pos += app.keyboard.get_movement_2d(("a", "d"), ("w", "s")) * self.speed

        if app.mouse.any("downed"):
            self.radius += -1 if app.mouse.is_downed("right") else 1

        draw.aacircle(app.window.surface, ALICE_BLUE, self.pos, self.radius)


app.add_component(Player)
app.mainloop()

Every method that accepts a Key, MouseButton or State also accepts a str version of those values. As such, app.mouse.is_downed(MouseButton.right) and app.mouse.is_downed("right") are the same. Methods that accept Key and MouseButton also accept ints, as pygame has constants that represent every key. With that, app.keyboard.is_downed(Key.a), app.keyboard.is_downed("a") and app.keyboard.is_downed(pygame.K_a) are all equivalent.

Since Player has defaults for all of its constructor parameters, we may pass the type directly into add_component, letting the app instance it for us. Alternatively, if one is building a singleplayer game, or has some sort of "game controller" class that contains shared logic or data, they may use the @app.singleton_component decorator, making the class declaration look like this:

@app.singleton_component  # has to come before @dataclass
@dataclass
class Player(Component):
    ...

The decorator immediately instances the class, and adds it to the app. It also makes the decorated class a singleton, and as such any subsequent instantiations will always refer to the same object:

assert Player() is Player()  # passes

The engine supports hot reloading for Components, meaning they can have their attributes changed and updated during runtime. To enable hot reloading, one must first add the HotReload module to their App's spec:

from sky.modules import HotReload

app = App(spec=AppSpec(modules=[HotReload]))

Then, to mark a Component as hot reloadable, simply use the hot_reloadable subclass argument:

class Player(Component, hot_reloadable=True): ...

Alternatively, one may use the hot_reloadable class decorator.

@hot_reloadable
class Player(Component): ...

Note that hot reloading simply changes a Component's internal type reference (__class__), meaning it changes its methods, descriptors and inner classes, but it does not change its attributes (__dict__). As such, members set in __init__ or start will not be updated unless those methods are executed again, which they normally won't be. With that in mind, to use hot reloading for prototyping, one must type values directly into arguments. For example, when writing something like draw.aacircle(app.window.surface, self.color, self.position, self.radius), one should simply write BLACK directly into the color parameter, as opposed to modifying self.color in the initialization function, as that function won't be called again after the Component's initialization.

Notably, these examples use app.mouse and app.keyboard, which are InputManagers included by default in every window. InputManagers run every frame, grabbing input from a given window, using the Windowing Service. Services are objects that handle a certain portion of functionality for the engine, also updating their data every frame. By default, the engine offers 4 Services:

  • Events (handles pygame events)
  • Windowing (handles windowing)
  • Chrono (handles time-related data)
  • Executor (handles coroutines)

Every window handles its own inputs, and as such has their own instance of a given InputManager, normally Keyboard and Mouse, which are included by default. Accessing app.keyboard, for instance, returns a reference to the main window's keyboard input manager, serving as a shorthand for app.windowing.main_window.keyboard.

Users may add their own Services by subclassing the Service class, and using the add_service method:

from typing import override

from sky import App, Service

app = App()


class SomeService(Service):
    @override
    def update(self) -> None:
        print("Runs every frame!")


app.add_service(SomeService())
app.mainloop()

So far, we've used methods that run either at the start, or at every frame. But many games require more granular control over timing, using delays, loops and animations. Coroutines are the engine's way of handling such tasks.

from sky import App, Coroutine, Color
from sky.colors import CRIMSON, DODGER_BLUE
from sky.utils import animate

app = App()


@app.on_setup
def lerp_color() -> Coroutine:
    for t in animate(duration=3, step=lambda: app.chrono.deltatime):
        app.window.fill_color = Color(CRIMSON.lerp(DODGER_BLUE, t))
        yield None  # same as WaitForFrames(1)


app.mainloop()

This feature based on Unity's coroutines. See their documentation for their version of the feature, done in C#.

Hooks can automatically detect Coroutines, calling app.executor.start_coroutine when triggered instead of simply calling the decorated generator function.

Earlier, we called add_component directly on our App instance. Doing this actually calls add_component on the most recently added Scene, the engine's way of organizing many components into separate collections for easier management. Multiple Scenes may be loaded at once, as games usually contain portions that act differently from others, but run in parallel, such as the level and user interface.

In our case, the most recently added Scene is simply the default scene, as we haven't added any others. Here's an example that does not create a default scene, and instead adds two scenes, with each rendering a differently colored circle:

from dataclasses import dataclass
from typing import override

from pygame import draw

from sky import App, AppSpec, Color, Component, Scene, Vector2
from sky.colors import BLUE, RED

app = App(spec=AppSpec.sceneless())  # no default scene since we'll add our own


@dataclass
class Circle(Component):
    pos: Vector2
    color: Color

    @override
    def update(self) -> None:
        draw.aacircle(app.window.surface, self.color, self.pos, 50)


app.load_scene(
    red_scene := Scene.from_components(
        [Circle(app.window.center + Vector2(100, 0), BLUE)]
    )
)
app.load_scene(
    blue_scene := Scene.from_components(
        [Circle(app.window.center - Vector2(100, 0), RED)]
    )
)

app.keyboard.add_keybindings(
    a=lambda: app.toggle_scene(blue_scene), b=lambda: app.toggle_scene(red_scene)
)

app.mainloop()

Yet another way of handling user input is using Keybindings. Their constructor provides exact control over the binding, accepting multiple keys with possibly differing activation States to allow for complex key combinations. A simpler way of adding keybindings, however, is using the Keybinding.make method, that simply takes a key and an action as arguments. add_keybindings is a method that uses **kwargs to create a mapping of key to action, simplifying the process further.

This README covers most of the engine's main features, but one may dig through the source code and extra examples to learn more. Do note that this project is in heavy active development and breaking changes occur constantly, so don't use it for anything serious.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sky_engine-0.0.2.tar.gz (50.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sky_engine-0.0.2-py3-none-any.whl (55.4 kB view details)

Uploaded Python 3

File details

Details for the file sky_engine-0.0.2.tar.gz.

File metadata

  • Download URL: sky_engine-0.0.2.tar.gz
  • Upload date:
  • Size: 50.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.3.3 CPython/3.14.0 Windows/10

File hashes

Hashes for sky_engine-0.0.2.tar.gz
Algorithm Hash digest
SHA256 5cf28bfbfdc0aa3b5dc366596ed24cf5b66c40d3dd1a6bbac08bc5e01fd3007c
MD5 14b8a0058aad47ab3e166bcd0121dadd
BLAKE2b-256 20e4b52ed8d9605c949e0ccf9bee52c9296f871810e877f5730d1b55b6198352

See more details on using hashes here.

File details

Details for the file sky_engine-0.0.2-py3-none-any.whl.

File metadata

  • Download URL: sky_engine-0.0.2-py3-none-any.whl
  • Upload date:
  • Size: 55.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/2.3.3 CPython/3.14.0 Windows/10

File hashes

Hashes for sky_engine-0.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 bbd2aef6f23a7670bd5c39a0291fe1ea506be3ef5e6e6802944f7b301e3711fa
MD5 b2eed43f97daa498a33901cc1f4d9b1d
BLAKE2b-256 b1596dab9eb071419664387f309418fabcb75bab8dad0dba4cd9abdee7948d06

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page