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
-
Clone the repository:
git clone --recursive https://github.com/ArnavChoudhary9/Photon -
Navigate to the project directory:
cd Photon -
Initialize the repository:
setup.bat -
Activate the virtual environment:
venv\Scripts\activate -
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)
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
- Layer Management:
AddLayer(layer): Adds a layer to the stack.AddOverlay(overlay): Adds an overlay to the stack.
- Lifecycle Handling:
OnStart(),OnUpdate(dt),OnStop(),Destroy()handle layer lifecycles.
- 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
- Event Categories:
- Defined using bit flags, allowing a single event to belong to multiple categories.
- Event Types:
- Predefined types like
WindowResize,KeyPressed,MouseMoved, etc.
- Predefined types like
- 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
- Events are first propagated to overlays in the
LayerStack. - If an overlay handles the event, propagation stops.
- 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
-
Subscription:
- Components register themselves as listeners for specific event types through the
CommunicationLayer.
- Components register themselves as listeners for specific event types through the
-
Publishing Events:
- Components broadcast events using the
CommunicationLayer, which forwards them to the event system.
- Components broadcast events using the
-
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:
- Creating and managing entities within the scene.
- Handling scene lifecycle events (start, update, stop).
- Managing entity components.
- 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 ifworkingDirdoes not exist.makeIfNotExist(bool, optional): IfTrue, creates the project directory if it doesn't exist. Defaults toTrue. IfFalseand the directory does not exist, an error is raised.
Raises:
AssertionError: Ifnameis empty and a new project is being created.AssertionError: IfmakeIfNotExistisFalseand the project directory does not exist.
Logic:
- Checks if the
workingDirexists. - If it doesn't exist and
makeIfNotExistisTrue, creates the directory and initializes the project as new. - If it doesn't exist and
makeIfNotExistisFalse, raises an error. - 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): TheSceneobject 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:
- Creates a default scene named "Scene".
- Registers the default scene with the project.
- Creates an empty entity within the default scene.
- Creates the "Assets", "Scenes", "Scripts", and "Builds" directories.
- Saves the initial project data to disk.
LoadProject(self) -> None
Loads an existing project from disk.
Logic:
- Reads the project file (
.pjproj) usingyaml.load. - Checks for Photon version mismatch and logs a warning if versions are different.
- Populates the project's
__Name,__SceneOrder, and__SceneRegistryfrom the loaded data. - Deserializes each scene using
SceneSerializer.Deserialize. - Creates the "Assets", "Scenes", "Scripts", and "Builds" directories if they don't exist.
Save(self) -> None
Saves the project data to disk.
Logic:
- Writes the project's
__Name,__SceneOrder, and__SceneRegistryto the project file (.pjproj) usingyaml.dump. - Serializes each scene using
SceneSerializer.Serializeand 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): TheSceneobject 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: TheSceneobject 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:
Pathobjects abstract away differences between operating systems, ensuring that paths work seamlessly on Windows, macOS, and Linux. - Rich Functionality:
Pathprovides 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
Pathobjects 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
- Avoid converting paths to strings unless absolutely necessary (e.g., for third-party library compatibility).
- When documenting your functions, explicitly state that parameters must be
Pathobjects.
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.Pathfor defining paths. You automatically importpathlib.Pathwhen importingPhoton.
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
CoreLoggerand the logger provided to the client.- Note: Any logger created by the user and subscribed to
ClientLoggerwill work as expected.
- Note: Any logger created by the user and subscribed to