Introduction

Photon is a Game engine written in Python just for the fun of it. It uses libraries like PyImGUI, PyOpenGL and pyGLFW for a lot of graphical heavy-lifting.

The engine is based on OpenGL, and support for Vulkan is planned for future. Although the engine uses OpenGL it uses multi-threaded architecture and asynchronous programming to load assets.

Photon draws a lot of insperation from Hazel, and a lot of learning from The Cherno.

Visit the DevLog site here

Installation

Requirements

  • Python >=3.10

    The engine is based on Python 3.10.

    It is preferred that you use Python 3.10 or upgrade to a version supported by PyImGUI.

Installation Methods

You can install Photon using pip or by compiling from source (GitHub) or download precompiled binaries

1. Install via pip

This is the recommended method for most users.

pip install PhotonEngine
  • Dependency: Python

2. Compile from Source (GitHub)

Use this method if you want to contribute to Photon's development or explore its inner workings.

  • Requirement: Virtual Environment (venv)

    Install using:

    pip install venv
    
  1. Clone the repository:

    git clone --recursive https://github.com/ArnavChoudhary9/Photon
    
  2. Navigate to the project directory:

    cd Photon
    
  3. Initialize the repository:

    setup.bat
    
  4. Activate the virtual environment:

    venv\Scripts\activate
    
  5. Run Forge Editor:

    cd Forge
    python Forge.py
    

Congratulations! You have successfully run Forge Editor.

3. Running Precompiled binaries

Use this if you only want to run the editor and develope games.

Download the latest precompiled binary from GitHub.

To run the editor, navigate to the downloaded directory and run Forge.exe.

Using Photon

Now let us see all the important things you need to do to make your application with Photon.

File structure

Setup your project in following way.

Project
├── Photon
├── Project
|   ├── project.py
├── venv

Making an application

To make your first app using Photon Start by importing it in you Project.py file.

from Photon import *

Now start by defining a class inheriting from PhotonApplication

class YourApp(PhotonApplication):
    def OnStart(self) -> None: ...
    def OnUpdate(self, dt: float) -> None: ...
    def OnEnd(self) -> None: ...

Note that your subclass shoud overwrite the OnStart, OnUpdate and OnEnd methods. Also note that the dt will be in seconds.

Now you need to call the Main() method.

if __name__ == '__main__':
    Main()

Here's a simple example:

from Photon import *

class YourApp(PhotonApplication):
    def OnStart(self) -> None: pass
    
    def OnUpdate(self, dt: float) -> None:
        print("Hello, World")
        self.Close()
        
    def OnEnd(self) -> None: pass

if __name__ == "__main__":
    Main()

Logging

Logging is an essential feature of any game engine. Photon also provides the user with loggers that can be used by the users.

The loggers are multi-threaded and does not impact the performance that much.

The users are provided with a ClientLoggers[LoggerSupcription] class, You can add your own loggers to it and any logs pass to CLient Loggers will be passed along to these loggers.

A default logger with the name of the application subclass will already be added to ClientLoggers.

# Logging
ClientLoggers.Trace(msg)        # White
ClientLoggers.Debug(msg)        # Blue
ClientLoggers.Info(msg)         # Green
ClientLoggers.Warn(msg)         # Warn
ClientLoggers.Error(msg)        # Red
ClientLoggers.Critical(msg)     # Red BG with White text

# Adding to ClientLoggers
ClientLoggers.Subscribe(Logger(name))

Managing Files

Loading Files

The FileManager class offers a robust mechanism for loading files. When a file is loaded, it is cached for efficient reuse. The following example demonstrates how to load a file:

Example

from FileManager import FileManager

# Initialize FileManager
FileManager.INIT()

# Load a file
file_path = Path("/path/to/your/file")
file = FileManager.Load(file_path)

# Read data from the file
data = file.Read()
print(data)

Note you need to use pathlib.Path to specify paths

Storing Files in Cache

The file manager maintains a cache of files. Cached files are automatically cleared based on the cacheClearTime specified during initialization. To disable cache clearing, set cacheClearTime to -1.

Example

# Initialize with cache clearing disabled
FileManager.INIT(cacheClearTime=-1)

file1 = FileManager.Load(Path("/path/to/file1"))
file2 = FileManager.Load(Path("/path/to/file1")) # This will reload the file

This can be usefull in dev mode when your files change frequently.

The cache handler thread will periodically check for unused files and release them based on their last release time and usage.

Writing Files

Writing to files is done asynchronously to ensure performance. The FileManager.Write method schedules the write operation using a thread pool.

Example

# Write data to a file
file = File(Stream(), Path("/path/to/file"))
data_to_write = b"Some data to write"
file.Write(data_to_write)

# FileManager.Write will handle closing and writing to the disk
FileManager.Write(file)

Using Context Managers

Context managers ensure that resources like file streams are properly acquired and released, reducing the risk of resource leaks. The File, FileReader, and FileWriter classes all support context management.

FileReader Example

from FileManager import FileReader
from pathlib import Path

file_path = Path("/path/to/your/file")

with FileReader(file_path) as file:
    data = file.Read()
    print(data)

FileWriter Example

from FileManager import FileWriter
from pathlib import Path

file_path = Path("/path/to/your/file")

data_to_write = b"New data to write"

with FileWriter(file_path) as file:
    file.Write(data_to_write)

Benefits of Using Context Managers

  • Automatic Resource Management: Resources like file streams are automatically released when the block exits.
  • Error Handling: Any exceptions raised within the block are handled, and cleanup still occurs.
  • Cleaner Code: The syntax is concise and readable.

By following these guidelines and leveraging the built-in file handling features, developers can ensure efficient, safe, and clear file operations within the game engine.

Layers

Overview

The game engine's layer system provides a structured way to manage and organize rendering, updates, and event handling. The PhotonApplication supplies the user-defined application class with a LayerStack, enabling seamless integration of user-defined layers and overlays into the engine.


Layers

A Layer is an abstract base class that defines the essential lifecycle methods for any layer. Users inherit from this class to create custom layers. Layers include event handling and can be toggled on or off.

Key Features of a Layer

  • Initialization and Lifecycle:
    • OnInitialize(): Setup tasks when the layer is created.
    • OnStart(): Called when the layer becomes active.
    • OnUpdate(dt): Updates the layer logic every frame.
    • OnStop(): Called when the layer is stopped.
    • OnDestroy(): Cleanup tasks when the layer is destroyed.
  • Event Handling:
    • OnEvent(event): Processes events, returning whether the event was handled.

Example

from Layers import Layer

class GameLayer(Layer):
    def OnInitialize(self):
        print(f"Initializing {self.Name}")

    def OnStart(self):
        print(f"Starting {self.Name}")

    def OnUpdate(self, dt: float):
        print(f"Updating {self.Name} with dt={dt}")

    def OnStop(self):
        print(f"Stopping {self.Name}")

    def OnDestroy(self):
        print(f"Destroying {self.Name}")

    def OnEvent(self, event):
        print(f"Event {event.Name} handled in {self.Name}")
        return True

LayerStack

The LayerStack is responsible for managing the layers and overlays. It provides methods to add, update, and remove layers while ensuring the proper lifecycle management.

Key Features

  1. Layer Management:
    • AddLayer(layer): Adds a layer to the stack.
    • AddOverlay(overlay): Adds an overlay to the stack.
  2. Lifecycle Handling:
    • OnStart(), OnUpdate(dt), OnStop(), Destroy() handle layer lifecycles.
  3. Event Propagation:
    • Events are propagated through overlays first, followed by layers, in the order they were added.

Example

from LayerStack import LayerStack
from Layers import Layer

class MyApplication:
    def __init__(self):
        self.layer_stack = LayerStack()

    def Run(self):
        self.layer_stack.OnStart()
        self.layer_stack.OnUpdate(0.016)  # Example delta time
        self.layer_stack.OnStop()
        self.layer_stack.Destroy()

Events

Event Dispatching

The Event class and its derivatives manage the flow of events throughout the system. Events are categorized and dispatched using the EventDispatcher.

Key Components

  1. Event Categories:
    • Defined using bit flags, allowing a single event to belong to multiple categories.
  2. Event Types:
    • Predefined types like WindowResize, KeyPressed, MouseMoved, etc.
  3. EventDispatcher:
    • Maps event types to their respective handlers.

Example

from Event import Event, EventDispatcher, EventType

dispatcher = EventDispatcher()

def on_window_resize(event):
    print(f"Window resized: {event}")
    return True

dispatcher.AddHandler(EventType.WindowResize, on_window_resize)

# Simulating an event
event = WindowResizeEvent()
dispatcher.Dispatch(event)

Event Flow in Layers

  1. Events are first propagated to overlays in the LayerStack.
  2. If an overlay handles the event, propagation stops.
  3. If not, the event is passed to the layers in the stack.

Example of Layer Handling Events

class CustomLayer(Layer):
    def OnEvent(self, event):
        if event.EventType == EventType.KeyPressed:
            print(f"Key pressed event handled by {self.Name}")
            return True
        return False

layer_stack = LayerStack()
layer_stack.AddLayer(CustomLayer("GameLayer"))
layer_stack.OnEvent(event)

By using the LayerStack and Event system, the engine provides a robust foundation for organizing application logic and handling events efficiently.

Communication Layer

Overview

The CommunicationLayer is a centralized intermediary that facilitates communication between components in an application. It leverages the event system to manage subscriptions and event propagation efficiently. By centralizing communication logic, it reduces the load on individual components and ensures scalability.


Key Component

CommunicationLayer

The CommunicationLayer provides an interface for components to subscribe to and publish events without directly interacting with each other.

Responsibilities

  • Centralized Event Management: Handles all event-related operations, reducing complexity in individual components.
  • Dynamic Subscription: Allows components to register themselves as listeners for specific event types.
  • Efficient Event Propagation: Ensures that events are dispatched to registered listeners in a streamlined manner.

Workflow

  1. Subscription:

    • Components register themselves as listeners for specific event types through the CommunicationLayer.
  2. Publishing Events:

    • Components broadcast events using the CommunicationLayer, which forwards them to the event system.
  3. Event Handling:

    • The event system propagates events to all registered listeners, ensuring efficient communication without direct dependencies between components.

Usage Examples

1. Initializing the Communication Layer

To begin using the CommunicationLayer, create an instance of it. This instance will act as the central hub for managing events.

from CommunicationLayer import CommunicationLayer

# Initialize the Communication Layer
communication_layer = CommunicationLayer()

2. Subscribing to Events

Components can subscribe to specific event types by providing a callback function that will handle the event when it is dispatched.

from Events import EventType

# Define a listener function for window resize events
def on_window_resize(event):
    print(f"Window resized: {event.width}x{event.height}")
    return True  # Returning True indicates the event was handled

# Subscribe to the WindowResize event
communication_layer.Subscribe(EventType.WindowResize, on_window_resize)

3. Publishing Events

Components can publish events using the PublishEvent method of the CommunicationLayer. These events will be dispatched to all registered listeners.

from Events import WindowResizeEvent

# Create a WindowResizeEvent
window_resize_event = WindowResizeEvent(width=800, height=600)

# Publish the event
communication_layer.PublishEvent(window_resize_event)

Output:

Window resized: 800x600

4. Handling Multiple Event Types

A single component can subscribe to multiple event types by registering different listener functions.

from Events import EventType, KeyPressedEvent

# Define a listener for key press events
def on_key_pressed(event):
    print(f"Key pressed: {event.key_code}")
    return True

# Subscribe to both WindowResize and KeyPressed events
communication_layer.Subscribe(EventType.WindowResize, on_window_resize)
communication_layer.Subscribe(EventType.KeyPressed, on_key_pressed)

# Publish a KeyPressedEvent
key_pressed_event = KeyPressedEvent(key_code=65)  # Example key code for 'A'
communication_layer.PublishEvent(key_pressed_event)

Output:

Key pressed: 65

5. Unsubscribing from Events (Optional)

If needed, components can unsubscribe from specific events by removing their listener functions from the dispatcher.

# Unsubscribe from WindowResize events (if supported in your implementation)
communication_layer.Unsubscribe(EventType.WindowResize, on_window_resize)

Advantages

1. Reduced Load on Components

By centralizing event handling, individual components are freed from managing complex communication logic, reducing their computational load.

2. Loose Coupling

The CommunicationLayer ensures loose coupling between components, enabling indirect communication without creating cyclic dependencies.

3. Scalability

The system is designed to support new components or events without requiring modifications to existing code, making it highly scalable.

4. Efficient Propagation

Listeners are notified only of events they have subscribed to, ensuring efficient use of resources.

Scene

Overview

The Scene class represents a scene in a game or application, managing entities and their components. It provides functionality for creating, duplicating, and destroying entities, as well as handling scene-specific operations.

Class: Scene

Properties

  • Name: str
    • Returns the name of the scene.
  • SceneUUID: UUID
    • Returns the unique identifier of the scene.
  • EntityRegistry: EntityRegistry
    • Returns the entity registry associated with the scene.
  • Entities: List[Entity]
    • Returns a list of all entities in the scene.

Methods

Constructor

  • __init__(self, name: str) -> None
    • Initializes a new Scene with the given name.

Entity Management

  • CreateEntity(self, name: str) -> Entity
    • Creates a new entity with a generated UUID.
  • CreateEntityWithUUID(self, name: str, uuid: UUID) -> Entity
    • Creates a new entity with a specified UUID.
  • DuplicateEntity(self, entity: Entity) -> Entity
    • Creates a copy of an existing entity.
  • DefferedDuplicateEntity(self, entity: Entity) -> None
    • Schedules an entity for duplication in the next update.
  • DestroyEntity(self, entity: Entity) -> None
    • Marks an entity for deletion in the next update.
  • GetEntitysWithComponent(self, component: Type[CTV]) -> List[Entity]
    • Returns a list of entities that have a specific component.

Scene Lifecycle

  • OnStart(self) -> None
    • Called when the scene starts.
  • OnUpdate(self) -> None
    • Called every frame to update the scene.
  • OnStop(self) -> None
    • Called when the scene stops.
  • OnUpdateEditor(self, dt: float) -> None
    • Called every frame in editor mode.
  • OnUpdateRuntime(self, dt: float) -> None
    • Called every frame during runtime.

Component Callbacks

  • OnComponentAdded(self, entity: Entity, component: CTV) -> None
    • Called when a component is added to an entity.
  • OnComponentRemoved(self, entity: Entity, component: CTV) -> None
    • Called when a component is removed from an entity.

Usage

The Scene class is central to managing game objects and their behaviors. It allows for:

  1. Creating and managing entities within the scene.
  2. Handling scene lifecycle events (start, update, stop).
  3. Managing entity components.
  4. Providing a structured approach to game/application organization.

Example

# Create a new scene
game_scene = Scene("Level 1")

# Create an entity
player = game_scene.CreateEntity("Player")

# Add components to the entity
player.AddComponent(HealthComponent, 100)
player.AddComponent(PositionComponent, x=0, y=0)

# Update the scene
game_scene.OnUpdate()

Project

This module defines the Project class, which manages the overall structure and data of a project within the Photon engine. It handles project creation, loading, saving, and scene management.

Project Class Overview

The Project class is responsible for:

  • Storing project-level information such as name, working directory, and scene organization.
  • Initializing new projects with default settings and directory structure.
  • Loading existing projects from disk, including scene data.
  • Saving project data to disk, including project settings and individual scenes.
  • Managing the scene registry and scene order within the project.

Class Details

__init__(self, workingDir: Path, name: str="", makeIfNotExist: bool=True) -> None

Initializes a new Project instance.

Parameters:

  • workingDir (Path): The directory where the project will be stored.
  • name (str, optional): The name of the project. Defaults to "". Must be provided if workingDir does not exist.
  • makeIfNotExist (bool, optional): If True, creates the project directory if it doesn't exist. Defaults to True. If False and the directory does not exist, an error is raised.

Raises:

  • AssertionError: If name is empty and a new project is being created.
  • AssertionError: If makeIfNotExist is False and the project directory does not exist.

Logic:

  1. Checks if the workingDir exists.
  2. If it doesn't exist and makeIfNotExist is True, creates the directory and initializes the project as new.
  3. If it doesn't exist and makeIfNotExist is False, raises an error.
  4. If the directory exists, loads the project data from the project file.

ProjectFile Property

Returns the Path to the project file (.pjproj).

Returns:

  • Path: The path to the project file.

AssetsLocation Property

Returns the Path to the project's "Assets" directory.

Returns:

  • Path: The path to the Assets directory.

ScenesLocation Property

Returns the Path to the project's "Scenes" directory (located inside the Assets directory).

Returns:

  • Path: The path to the Scenes directory.

ScriptsLocation Property

Returns the Path to the project's "Scripts" directory (located inside the Assets directory).

Returns:

  • Path: The path to the Scripts directory.

BuildLocation Property

Returns the Path to the project's "Builds" directory.

Returns:

  • Path: The path to the Builds directory.

GetSceneLocation(self, scene: Scene) -> Path

Returns the Path to a specific scene file within the project's "Scenes" directory.

Parameters:

  • scene (Scene): The Scene object for which to retrieve the location.

Returns:

  • Path: The path to the scene file.

InitProject(self) -> None

Initializes a new project with default settings and directory structure.

Logic:

  1. Creates a default scene named "Scene".
  2. Registers the default scene with the project.
  3. Creates an empty entity within the default scene.
  4. Creates the "Assets", "Scenes", "Scripts", and "Builds" directories.
  5. Saves the initial project data to disk.

LoadProject(self) -> None

Loads an existing project from disk.

Logic:

  1. Reads the project file (.pjproj) using yaml.load.
  2. Checks for Photon version mismatch and logs a warning if versions are different.
  3. Populates the project's __Name, __SceneOrder, and __SceneRegistry from the loaded data.
  4. Deserializes each scene using SceneSerializer.Deserialize.
  5. Creates the "Assets", "Scenes", "Scripts", and "Builds" directories if they don't exist.

Save(self) -> None

Saves the project data to disk.

Logic:

  1. Writes the project's __Name, __SceneOrder, and __SceneRegistry to the project file (.pjproj) using yaml.dump.
  2. Serializes each scene using SceneSerializer.Serialize and saves it to the appropriate location within the "Scenes" directory.

RegisterScene(self, scene: Scene) -> None

Registers a scene with the project, adding it to the scene registry and scene order.

Parameters:

  • scene (Scene): The Scene object to register.

GetScene(self, index: int) -> Scene

Retrieves a scene from the project by its index in the scene order.

Parameters:

  • index (int): The index of the scene in the scene order.

Returns:

  • Scene: The Scene object at the specified index.

Helper Classes and Functions

SceneSerializer

The SceneSerializer class (not directly part of the Project class but closely related) likely provides methods for serializing (saving) and deserializing (loading) Scene objects to and from files. It would handle the specific details of converting a Scene object's data into a storable format (e.g., YAML, JSON, or a binary format) and recreating the Scene object from that stored format.

Appendix

Paths

Overview

This game engine employs Python's pathlib.Path for all path-related operations. This decision was made to ensure consistency, clarity, and cross-platform compatibility when dealing with file system paths. Using Path instead of strings for paths eliminates ambiguity and provides a rich set of methods for file manipulation.

Why pathlib.Path?

  • Cross-Platform Compatibility: Path objects abstract away differences between operating systems, ensuring that paths work seamlessly on Windows, macOS, and Linux.
  • Rich Functionality: Path provides methods like .exists(), .is_file(), .mkdir(), etc., making file handling more intuitive and readable.
  • Safety and Consistency: By using Path, the engine avoids potential bugs caused by malformed or incorrect string-based paths.
  • Type Clarity: Requiring Path objects makes function signatures and code behavior more predictable.

Usage Guidelines

Input Requirements

All functions and methods in the engine that require a file path must use a pathlib.Path object. Strings representing paths are not supported and will raise an error if passed.

from pathlib import Path

def load_asset(asset_path: Path):
    if not asset_path.exists():
        raise FileNotFoundError(f"Asset not found: {asset_path}")
    # Proceed with loading the asset

Combining Paths

Use the / operator to combine paths:

from pathlib import Path

config_file = Path("/path/to/project") / "config.json"
print(config_file)  # Outputs: /path/to/project/config.json

Checking Path Properties

from pathlib import Path

path = Path("/path/to/file")
if path.exists() and path.is_file():
    print(f"The file {path} exists and is ready to use!")
else:
    print(f"The file {path} does not exist or is not a file.")

Developer Recommendations

  1. Avoid converting paths to strings unless absolutely necessary (e.g., for third-party library compatibility).
  2. When documenting your functions, explicitly state that parameters must be Path objects.

Error Handling

If a string is passed where a Path is expected, the engine will raise a TypeError:

TypeError: Expected a pathlib.Path object, got str instead.

To resolve this, ensure all paths are converted to Path objects:

from pathlib import Path

path = Path("/path/to/resource")

By enforcing the use of pathlib.Path, this game engine ensures a robust, consistent, and developer-friendly approach to file system interactions. Embrace Path for all your path-related needs!

The engine uses pathlib.Path for defining paths. You automatically import pathlib.Path when importing Photon.

Features

The engine contains a file (Forge/Core/Features.py) that can be used to toggle non-essential features of the engine.

  • INSTRUMENTATION – Used to measure engine performance.
  • LOGGING – Disables the CoreLogger and the logger provided to the client.
    • Note: Any logger created by the user and subscribed to ClientLogger will work as expected.