A modular 2D game engine written in Go, built on Ebitengine. Designed for small platform games with a data-driven, component-based architecture and a Lua scripting layer for game logic.
- Component system — Unity-style GameObjects with pluggable components (Transform, Spritesheet, Animator, PhysicsBody, Script, IntentBuffer, Block, Ball)
- Data-driven scenes — worlds are defined in YAML; no code changes required to add objects or tweak layouts
- Lua scripting — pure-Go Lua 5.1 VM (gopher-lua); write game logic in
.luafiles, no recompile needed - Physics — Box2D integration via box2d-go (kinematic, dynamic, and static bodies)
- Event bus — type-safe synchronous event bus for decoupled communication between systems
- Input bindings — configurable keyboard bindings; input actions emit intent events consumed by scripts
- Asset loading — sprites, spritesheets, fonts, and audio (WAV) loaded from the game root directory
- Flexible config — all engine settings (window, physics, input, assets) live in a single
config.yaml
Two example games exercise the engine:
games/demo1— a Knight platformer (Lua scripts): MoveA/D, DashShift, AttackJ/K, Debug overlayF3games/metalslug_demo— a Metal Slug-style run-and-gun demo (Python scripts), the actively developed one and the default gamemain.goruns: MoveA/D, JumpSpace, ShootJ, Debug overlayF3
Prerequisites: Go 1.24+
git clone https://github.com/diego3/frame.git
cd frame
go run . # runs games/metalslug_demo (the default)
go run . games/demo1/config.yaml # or pick a specific game's configThe engine (frameengine/) and the application layer (main.go, games/) are split into
separate trees, in preparation for eventually extracting frameengine/ into its own importable
Go module (see docs/frame_engine_migration_plan.md).
frameengine/ has no knowledge of any specific game; each game under games/ depends on it, never
the reverse.
frame/
├── main.go # Entry point (defaults to games/metalslug_demo)
├── main_wasm.go # WASM entry point (demo1 only)
├── frameengine/ # The engine
│ ├── application/
│ │ ├── config/ # Config loader (YAML)
│ │ ├── data/ # Scene YAML loader + component builders
│ │ ├── engine/ # Engine bootstrap (window, signal handling)
│ │ └── game/ # Game orchestrator (scene manager, input, loop)
│ ├── object/ # GameObject and all components
│ ├── physics/ # Physics interfaces + Box2D implementation
│ ├── script/ # Scripting backends (Lua via gopher-lua, Python via gpython)
│ ├── event/ + events/ # Type-safe event bus + event type definitions
│ ├── ports/ # Core interfaces (Scene, AssetLoader, UIRoot)
│ ├── process/ # Process manager (timed behavior)
│ ├── resource/ # Asset loader (images, fonts, audio)
│ └── view/
│ ├── input/ # Keyboard input → intent events
│ ├── scene/ # The generic WorldScene scene type + PhysicsSystem
│ ├── camera/ # Follow-camera
│ └── ui/ # UI widgets (Button, Container)
└── games/ # Application layer: each game is a frameengine consumer
├── demo1/ # Demo game (Lua scripts)
│ ├── config.yaml # Window, physics, input, asset config
│ ├── scenes/main_menu.yaml # Data-driven scene definition
│ └── scripts/ # Lua game logic scripts
└── metalslug_demo/ # Metal Slug demo (Python scripts)
├── config.yaml
├── scene.go # Embeds *scene.WorldScene, adds this demo's own rules
└── scripts/python/ # Python game logic scripts
Scenes are defined in YAML. Each object lists its components:
objects:
- name: knight
components:
- type: transform
x: 100
y: 200
- type: script
path: "scripts/knight_controller.lua"
- type: physics_body
body_type: kinematic
width: 50
height: 80
- type: spritesheet
name: idle
image: "assets/knight/_Idle.png"
frame_width: 120
frame_height: 80
cols: 10
fps: 8
- type: animator
current: idleScripts are Lua files attached to GameObjects via the script component. The engine calls update(dt) every frame and delivers input events via on_event(name, payload).
-- Entity API (self)
self.set_velocity(vx, vy)
self.play_animation("run")
self.current_animation() -- -> string
self.animation_finished() -- -> bool
self.get_intent("move_x") -- -> number
-- Engine API
engine.play_sound("assets/click.wav")
engine.switch_scene("main_menu")
engine.quit()
engine.emit("DashRequested", { speed = 10 })Input actions can be wired to script events in config.yaml:
script_events:
dash: "DashRequested"
attack: "AttackRequested"window:
width: 800
height: 600
title: "My Game"
resizable: true
physics:
gravity_y: 800
pixel_scale: 64 # pixels per meter
input:
move_left: A
move_right: D
dash: [ShiftLeft, ShiftRight]
assets:
font_path: "assets/font.ttf"
scene_path: "scenes/main_menu.yaml"Design decisions are documented as ADRs in docs/adr/:
- ADR-005 — Scripting: pure-Go Lua VM (gopher-lua), no CGo
- ADR-006 — Coding standards
- ADR-008 — Testing approaches
| Package | Purpose |
|---|---|
| ebitengine/ebiten | 2D rendering, window, audio |
| yuin/gopher-lua | Pure-Go Lua 5.1 VM (legacy scripting backend) |
| go-python/gpython | Pure-Go Python 3.4 VM (scripting backend for new games) |
| oliverbestmann/box2d-go | Box2D physics |
| gopkg.in/yaml.v3 | YAML config parsing |
See LICENSE.