A 2D game engine for PHP 8.4+, built on phasync and FFI, composited on the GPU with OpenGL 3.3 core. The design follows Starling for AS3: a display list on top of a batching painter on top of thin GPU primitives.
$engine = new Phame\Engine(800, 600, 'Hello');
$engine->run(function ($engine) {
$box = new Phame\Display\Quad(50, 50);
$engine->stage->addChild($box);
while (true) {
Phame\Async::tween($box, 1.0, ['x' => 700.0], Phame\Animation\Transitions::EASE_OUT_BOUNCE);
Phame\Async::tween($box, 1.0, ['x' => 0.0]);
}
});Coroutines are ordinary phasync coroutines. Async::awaitFrame() returns the frame's game-time delta in seconds; the global clock is Engine::current()->clock. Async::awaitFrame(), wait(), tween() and awaitEvent() suspend them without any per-frame polling cost, and everything still running when the game quits is cancelled (its finally blocks run).
There is no update(). A sequence is a coroutine, and a coroutine belongs to the object it is about:
$boss->go(function () use ($boss, $player, $stage) {
try {
while ($boss->hp > 0) {
Async::tween($boss, 1.5, ['x' => $player->x]); // chase
for ($i = 0; $i < 3; ++$i) { // burst fire
fireAt($boss, $player);
Async::wait(0.2);
}
$key = Async::timeout(2.0, fn () => Async::awaitKey()); // a KeyEvent, or null after 2 s of game time
}
} finally {
blowUp($boss); // runs when the boss is removed from the stage, too
}
});go()on a display object, aNode3Dor anyEventDispatcher(use a plainnew EventDispatcher()as a cancel group for a wave of enemies) starts a coroutine that ends when the object leaves the stage (aNode3D: leaves its parent), is disposed, orcancelCoroutines()is called;finallyblocks run. Reparenting removes first, so it ends them too.- Ownership follows the object, not the code: everything an object's coroutine starts (
Engine::go(),phasync::go(), race branches, library coroutines) belongs to that object, but$bullet->go()called by the player belongs to the bullet, so the bullet keeps flying when the player dies. Each object that usesgo()gets a spawner coroutine with a phasync frame of its own; its coroutines start within the same frame, right after the caller next waits. A coroutine that removes its own object finishes what it is doing; if it is still running at the start of the next frame, it is cancelled then (no exception is created for the usual case of removing yourself and returning).Async::cancelled()is a cheap check for loops that prefer to stop cleanly instead of being unwound by an exception. Async::wait,tween,awaitFrame(returns the frame's delta),awaitEvent($dispatcher, $type, $accept),awaitKey(Key::SPACE),awaitTouch($object, $phase)andrace(...$branches)/timeout($seconds, $fn, $default)suspend at no cost while waiting, in game time: pause andtimeScaleapply to every coroutine. A waiter gets the first matching event even if several arrive in one frame.Engine::isKeyDown(Key::LEFT)is the held-key state for continuous movement;Engine::simulateKey()presses keys for bots, replays and tests:$engine->go(function () use ($engine) { // hold left for a second $engine->simulateKey(Key::LEFT, true); Async::wait(1.0); $engine->simulateKey(Key::LEFT, false); });
$object->removeFromParent()takes an object off the display list (and ends its coroutines).- Wake only when needed:
velocityX/velocityYmake the engine move an object, andAsync::until($condition)lets the engine check a condition every frame without waking the coroutine; it returns the condition's value.$bullet->velocityY = -600; $hit = Async::until(fn () => $bullet->y < -10 ? true : $bullet->firstOverlap($enemies));Conditions must only test (collision boxes are cached while they run), and several coroutines can see the same hit in one frame. $a->overlaps($b)and$a->firstOverlap($group)compare screen boxes across parents, without allocating.Engine::pointer($x, $y, $phase)injects mouse/touch input the same waysimulateKey()injects keys.TileMapbuilds a level from a text map (one character per tile), draws only the visible tiles in one batch, and moves bodies with tile collision:$hit = $map->move($player, $dx, $dy)returns the sides it was stopped on, without tunnelling.isOnGround(),take($body, 'o')for pickups,tileAt(),setTile().
examples/shooter.php is a complete arena shooter (player, three enemy behaviours, waves, collisions, score, particles, screen shake, floating score text, pause, restart) with no entity lists and no update methods. examples/platformer.php does the same for a platformer: tile collision, coyote time, a moving platform, patrolling enemies, a following camera.
Each layer only knows the ones above it in this list.
| Layer | Namespace | Role |
|---|---|---|
| Platform | Phame\Platform |
SDL2 window, GL context, event queue. The only code that is platform specific. |
| GL | Phame\GL |
GL: the GL 3.3 core function table, loaded through a proc-address closure (it has no other dependency). Shader, Buffer, VertexLayout, VertexArray, Texture2D: one class per GL object. |
| Rendering | Phame\Rendering |
QuadBatch (8192 quads per draw call) and Painter (state stack, blend modes, batching). |
| Math, Pool | Phame\Math, Phame\Pool |
Vector3, Matrix4, IntRect: pooled, mutable, typed value objects (see Allocation). |
| Scene | Phame\Scene |
The 3D scene graph: Node3D, Camera, Mesh, Geometry, Material, and Scene3D, which embeds a 3D scene in the 2D display list. |
| Display | Phame\Display |
DisplayObject, DisplayObjectContainer, Sprite, Quad, Image, BitmapText, ParticleSystem, TileMap, MovieClip, Stage. |
| Filters | Phame\Filters |
Filter (in Rendering) and BlurFilter, GlowFilter, BloomFilter, ColorMatrixFilter. |
| Events, Animation | Phame\Events, Phame\Animation |
Bubbling events, touch, Juggler, Tween, DelayedCall, Transitions. |
| Engine, Async | Phame\Engine, Phame\Async, Phame\Clock |
The coroutine frame loop, the awaitable primitives, and the global clock ($engine->clock: time, delta, timeScale, paused, realDelta, frame). |
Scene3D is a display object. Put it in the stage like a Quad; it draws a lit, depth-tested scene into its rectangle, and 2D objects can sit above and below it. examples/cube.php shows it with coroutines driving the animation.
- Right-handed, y up, the camera looks down -z. Rotations are radians, applied to a point in the order z, x, y.
- Opaque meshes are drawn first, transparent ones (
Material::$alpha < 1) back to front without writing depth. Back faces are culled unlessMaterial::$doubleSided. - One directional light plus ambient (
Scene3D::$lightDirection,$lightColor,$ambient);Material::$unlitskips lighting. Vector properties such aslightDirectionare readonly references to pooled objects: change them in place,$scene->lightDirection->set(1, -1, -1). Geometrytakes interleaved position, normal and uv floats and 16 bit indices (at most 65536 vertices);box,planeandsphereare provided.Node3D::forward($v),right($v)andup($v)give a node's (or the camera's) axes in world space, for camera-relative movement.- Picking:
Scene3D::pick($x, $y)returns the nearest visibleMeshunder a point in the scene's local 2D coordinates (tested against bounding boxes, so a rotated mesh is a little generous);ray()fills an origin and direction for you.Mesh::worldBounds()andMesh::intersects()give the world-space box and box-vs-box overlap;Node3D::findByName()searches descendants. - A
Scene3Dis drawn into the screen-aligned box of its rectangle: do not rotate it, and its alpha is ignored.
examples/showcase.php uses everything below at once.
- Mesh shaders.
new Material(shader: new MeshShader($glsl)). Your GLSL definesvec4 shade()(straight RGBA) and optionallyvec3 displace(vec3 pos, vec3 normal, vec2 uv)for waves and wobble;uTime,sceneLight(n),sceneSpecular(n)andflatNormal()are provided. Extra uniforms go in theuniformsclosure. The standard material is itself aMeshShader(MeshShader::STANDARD).Geometry::plane($w, $d, $segments)subdivides for displacement. - Lighting. Up to 4
PointLights (colour, intensity, range) next to the sun, Blinn-PhongMaterial::$specular/$shininess,$emissive, and linear fog (Scene3D::$fogColor,$fogNear,$fogFar). - Render textures.
new RenderTexture($w, $h, $scale)then$rt->draw($displayObject);$rt->textureis an ordinary texture for anImageor a material, and may itself contain aScene3D(it has a depth buffer).readPixels()reads it back. Nesting works. - Filters.
$object->filter = new BlurFilter(6): the object and its children are drawn into a texture and that goes through the filter's passes. Write your own withnew Filter($glsl, $passes), where the GLSL definesvec4 filterPixel(vec2 uv)and may readuTexture,uOriginal,uTexelSize,uTime,uPass; return premultiplied colour. Filters work on aScene3Dtoo. A filtered object costs two or three texture-sized draws, so use them on a few objects, not on every sprite. - Particles.
ParticleSystemis a pooled, array-free emitter drawn as one batch:start(),burst($n),emissionRate,lifetime,speed,spread, gravity, start/end size, colour and alpha. - Animation.
TextureAtlas::fromSparrow($xml)(TexturePacker and friends) orTextureAtlas::grid($sheet, $w, $h, ['idle', 'walk'])(one animation per row:frames('walk_')), thennew MovieClip($atlas->frames('run_'), 12). It plays in game time; assignframesto switch animation, setloop = falseandAsync::awaitEvent($clip, Event::COMPLETE)to wait for the end. - Audio.
Sound::load('hit.wav')->play($volume)returns aVoice(stop(),volume,loop) that dispatchesCOMPLETE, soAsync::awaitEvent($voice, Event::COMPLETE)waits for it. Any WAV format is converted on load;Sound::tone(900, 0.06, 'square', 300)makes a beep without asset files. Mixing is done by SDL every frame (no extra native library); headless engines mix silently on game time. - Text.
BitmapTextdraws BMFont atlases (æøå included in the shipped DejaVu Sans). Make others withbin/make-font font.ttf 32 name.
Premultiplied alpha is the rule: every blend mode assumes the shader outputs premultiplied colour, and render textures are stored that way.
The hot paths allocate no arrays and no objects. Vector3, Matrix4 and IntRect have private constructors and live in pools: take one with Vector3::get(...), change it in place (operations return $this), and release() temporaries. A recycled instance holds stale values, so the factories reinitialise everything; releasing twice throws. Matrices returned by Node3D::getLocalMatrix() and getWorldMatrix() belong to the node: copy them if you need to keep them. The 2D Matrix and Rectangle still allocate and are not used per frame by the renderer.
SDL2 is the only native dependency, and GL entry points are resolved through SDL_GL_GetProcAddress, so no GL library is linked. The SDL library is found per OS (libSDL2-2.0.so.0, SDL2.dll, Homebrew or system libSDL2-2.0.0.dylib); set PHAME_SDL2 to a path to override. macOS gets the forward-compatible flag it requires for core profiles.
Only Linux has been tested. Headless rendering (tests, screenshots) uses SDL's offscreen video driver, which may not exist on other platforms, where the GL tests skip themselves.
Porting to another windowing library means replacing Platform\SDL and Platform\Window, and passing that library's proc-address function to new GL(...).
See MANUAL.md for measurements on a 2025 and a 2014 CPU; php examples/benchmark.php measures your own machine.
php examples/bench.php measures animated, textured sprites per frame on the machine running it. On the development machine (PHP 8.5, NVIDIA GPU) the cost is about 2.7 µs per sprite, which is roughly 5,000 sprites at 72 fps and 10,000 at 38 fps. The time is spent in PHP (matrix updates and vertex packing), not in the GPU.
On the development machine (2013-era CPU), pooled Matrix4::product takes 0.84 µs, invertFrom 1.5 µs and compose 0.66 µs; with opcache.enable_cli=1 opcache.jit=tracing they take 0.28, 1.4 and 0.40 µs. Enabling the JIT is the cheapest speed-up for 3D scenes with many nodes.
vendor/bin/phpunit. The GL tests render headless and assert on pixels read back from the GPU.
Clipping rectangles, trimmed atlas frames, shadow maps (needs depth textures), a model loader, skybox, OGG/MP3 decoding, gamepad and touch input.
- Straight-alpha textures with linear filtering can show a dark fringe at edges. Textures are premultiplied by the shader, not on load.
width/heightsetters change onlyscaleX/scaleY, as in Starling, which has surprising results for rotated objects.- The PNG decoder does not support interlaced images.