Skip to content
kjkrolPublic

About

Modular game engine for Go build on top of goke ECS.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

gram

gram logo
Go Version GoDoc License Codecov Coverage Go Quality Check

gram is a modular game engine for Go. A game is a set of named Stages, each with its own entity-component world and its own Scenes; an engine drives the active Stage on the goke ECS, a tick and a picture a frame, and draws it on the GPU through WebGPU (gogpu, pure Go). Everything beyond the tick loop is a plugin: the built-in ones give a Stage a world of moving boxes, collisions, sight, a board with terrain, pathfinding and mouse selection, and a game adds its own the same way.

Features  •  Installation  •  Example  •  Demos  •  Model  •  Architecture  •  Benchmarks  •  Documentation

Design Goals

  • One extension point. Everything beyond the tick loop is a plugin.Plugin, built-in or yours. There is no registry and no lookup by name: a plugin that needs another takes it as a constructor argument.
  • A Stage owns its ECS. Each Stage gets a fresh world the moment it is entered, so a menu Stage sits idle with no gameplay entities until the player starts.
  • Behaviour is rules. What a game does about what a plugin finds is a rule, a sentence of who, when and what, run inside that plugin's own pass — written in ceremony, the library gram builds on. A rule is a role's, a role is played, and the engine hands it to the plugin catching its moment; one no plugin catches is an error.
  • Kinds say what an entity is. A kind is the list of components its entities carry, each constant or read from the entity's own row; it also tells save files what to expect.
  • Saves survive change. Persisted resources are matched by name, never by position.
  • Contract in the open, orchestration inside. What a game implements (game, plugin, render) is public and documented; only the engine that drives it is internal.

📦 Installation

go get github.com/kjkrol/gram

Prerequisites: Go 1.27+ and a GPU with a Vulkan, Metal or DirectX 12 driver; gogpu needs no cgo. Without a GPU the tests that draw skip themselves.

✨ Key Features

Capability Package What you get
Stages and Scenes game, game/stage Named Stages with their own ECS, defined a section at a time; Scenes as ui screens showing the world through cameras; a live Composition of what is shown and which Scene takes input
Plugins plugin The one extension contract; Installer.Hosts, where a plugin hands the engine the hosts that run the rules of its moments inside its own pass
Behaviour ceremony The library gram builds on, through its binding over goke (ceremony/goke): act.Branch[P]{Name, When, Then} a rule of a moment, plan.Loop[P]{Name, When, Then, Until, Timeout, Cooldown} a loop of a plan, every clause of either saying whom it is about (cond.Self.If(x), act.Other.Do(cmd)); roles a kind, a cell or a plugin plays; effects that hold for a while and turn the plugins' knobs; messages between plans; commands written as sentences, for entities found by name or group
World plugins/world Every entity's Base; the spatial index under stop, wrap or open edges; kinds and spawning before the game and during it; heights or planes
Motion plugins/motion, plugins/motion/steering Every entity moved each step, Moving and Leaving for rules; a Steering profile turned into heading and speed; Driven for a unit steered by hand
Collisions plugins/collision Collider to take part, Physics to bounce and be pushed apart, the solid ground of a board, Meeting and Struck for rules
Gravity plugins/gravity Bodies falling, standing on what bears them and leaping, along Z or across the plane, as the pull goes; Leap, Away and Landing for rules
Shooting plugins/bullet Shots as entities fired from a unit's muzzle or thrown in an arc, swept by collision; Landing, Resting and Blast for rules
Sight plugins/vision A Sight cone scanned into Sighted, cut by walls and dimmed by forests, over them by height; Sighting rules per observer
Conversations plugins/dialog Nodes of lines and answers from YAML, every unit with lines of its own; what an answer changes is the game's own
Board and navigation plugins/board, plugins/navigation Square or hex grid with terrain, ways and what stands on it, in a world with heights; a map laid out of a file; MoveOrder paths that re-route, onto a bridge's deck or under it
Bridges plugins/bridges Bridges built by one set of rules whoever builds them: a level's file, the map maker, a player with a bridge in hand
Light plugins/light The sun and the lamps as sources on entities, what each thing casts and takes; the shadows of the relief and of what stands still baked into the ground as the sun moves, the live ones laid over whatever ground is drawn
Topography plugins/topography A map in relief on the GPU: heights shaped, lit and shaded, every step's slope for the board's mobility, water glinting and running; the cameras' cage
Atmosphere plugins/atmosphere The calendar, the sky of the hour (the Earth's with the real stars, or a game's own of four parts), the climate and the weather, what falls and what it does to the board
Selection, players, cameras plugins/selection, plugins/players, plugins/cameras Click and marquee into Selected; players over the command handlers, their bindings and a list of keys (K); cameras made where a scene shows them, on a stick turned and leaned from straight down to isometric and flatter, orthographic or in perspective, kept out of what a map makes solid, fastened over a unit, behind it or inside it
Minimap plugins/minimap A small picture round a player's camera, turning with it or north up: the ground in miniature, a dot for each kind its figure marks, the camera's view outlined; a click on it takes the camera there
Telemetry plugins/telemetry The panel in the corner saying how the game runs — the frame rate, the ticks, where a frame's time goes, the entities, the game time, the plugins' and the game's own lines — in every Stage with a world, shown and hidden with ~
Persistence game.Persistence Save, load and list the active Stage's ECS and every tracked value by name
Rendering render, camera Everything drawn on the GPU (WebGPU, WGSL): sources drawn over one another by height in a shared depth buffer, the ground first and the marks last, Direct sources with shaders of their own, an atlas baked at Close

Example

The smallest game that does something: one Stage with a torus of bouncing boxes, a collision plugin counting their contacts, and one Scene showing them through a camera from above, the telemetry panel over it (~ shows everything, then hides it). This is examples/start/minimal; run it with make demo-start-minimal.

package main

import (
	"image/color"
	"math/rand/v2"
	"time"

	"github.com/kjkrol/aabbworld"
	"github.com/kjkrol/aabbworld/geom"
	"github.com/kjkrol/ceremony/comp"
	"github.com/kjkrol/goke/v3"
	"github.com/kjkrol/gram"
	"github.com/kjkrol/gram/camera"
	"github.com/kjkrol/gram/entity/kind"
	"github.com/kjkrol/gram/game"
	"github.com/kjkrol/gram/game/stage"
	"github.com/kjkrol/gram/plugins/cameras"
	"github.com/kjkrol/gram/plugins/collision"
	"github.com/kjkrol/gram/plugins/motion"
	"github.com/kjkrol/gram/plugins/players"
	"github.com/kjkrol/gram/plugins/telemetry"
	"github.com/kjkrol/gram/plugins/world"
	"github.com/kjkrol/gram/render"
	"github.com/kjkrol/gram/ui"
)

const (
	screenWidth, screenHeight = 640, 480
	boxSize                   = 12
	boxCount                  = 300
)

type boxRow struct {
	pos world.Position
	vel world.Velocity
}

// box is the one kind: a bouncing square
type box struct{}

var _ world.Figure = box{}

func (box) Define(w *world.Plugin) {
	kind.Define[boxRow](w.Kinds(), BoxKind, kind.Spec{
		comp.Load(func(b boxRow) world.Position { return b.pos }),
		comp.Load(func(b boxRow) world.Velocity { return b.vel }),
		comp.Const(collision.Collider{}),
		comp.Const(collision.Physics{Restitution: 1}),
	})
}

func (box) Look(a *world.Atlas, w *world.Plugin) {
	a.Add(kind.Named[boxRow](w.Kinds(), BoxKind), boxSize, render.Solid(boxColor))
}

const (
	BoxKind    = "box"
	ArenaStage = "arena"
	ViewScene  = "view"
)

type arena struct {
	world     *world.Plugin
	motion    *motion.Plugin
	collision *collision.Plugin
	cameras   *cameras.Plugin
	players   *players.Plugin
	telemetry *telemetry.Plugin
	player    *players.Player
	stats     collision.ContactStats
}

func main() {
	props := game.Props{
		Title:        "gram minimal",
		ScreenWidth:  screenWidth,
		ScreenHeight: screenHeight,
		TargetTPS:    60,
	}
	a := &arena{}
	mainStage := stage.New(ArenaStage).
		World(world.Config{
			Space: world.SpaceCfg{
				Width:  screenWidth,
				Height: screenHeight,
				Edges:  aabbworld.Torus,
			},
			Entities: world.EntitiesCfg{MaxCount: boxCount, MinSize: boxSize, MaxSize: boxSize},
		}).
		Plugins(a.makePlugins).
		Figures(a.defineFigures).
		Players(a.definePlayer).
		Spawn(a.spawnUnits).
		Scenes(a.defineScenes).
		Shows(ViewScene, telemetry.SceneName).
		Update(a.update)
	gram.Run(game.New(props, mainStage))
}

func (a *arena) makePlugins(w *world.Plugin) {
	a.world = w
	a.motion = motion.NewPlugin(a.world)
	a.collision = collision.NewPlugin(a.world, a.motion).WithStats(&a.stats)
	a.cameras = cameras.NewPlugin(a.world, cameras.Config{})
	a.telemetry = telemetry.NewPlugin(a.world)
	a.players = players.NewPlugin(a.world, a.collision, a.cameras)
}

func (a *arena) definePlayer() error {
	a.player = a.players.Local("player")
	return a.player.Bind(a.players.Defaults()...)
}

func (a *arena) defineFigures() { a.world.Figures(box{}) }

func (a *arena) spawnUnits() {
	boxKind := kind.Named[boxRow](a.world.Kinds(), BoxKind)
	rng := rand.New(rand.NewPCG(1, 2))
	placement := world.NewGridPlacement(screenWidth, screenHeight, boxSize)
	entries := make([]kind.Entry, boxCount)
	for i := range entries {
		var vel world.Velocity
		vel.SetDelta(geom.NewVec(rng.Float32()*200-100, rng.Float32()*200-100))
		entries[i] = boxKind.Entry(boxRow{pos: placement.Place(i, boxCount), vel: vel})
	}
	a.world.Write().Seed(entries...)
}

func (a *arena) defineScenes(ctx game.Initializer) []game.Scene {
	m := &mainScene{arena: a}
	a.telemetry.With(a.stats.Reporter(&ctx.TPS().Ticks))
	return []game.Scene{ui.NewScene(ViewScene, m.screen()).Input(a.players.Handle)}
}

func (a *arena) update(ctx goke.RunCtx, d time.Duration) {
	a.world.RunPlan(ctx, d)
	a.motion.RunPlan(ctx, d)
	a.collision.RunPlan(ctx, d)
	a.cameras.RunPlan(ctx, d)
	a.players.RunPlan(ctx, d)
	ctx.Sync()
}

type mainScene struct {
	arena *arena
}

var (
	boxColor        = color.RGBA{R: 90, G: 200, B: 110, A: 255}
	backgroundColor = color.RGBA{R: 30, G: 30, B: 30, A: 255}
)

func (m *mainScene) screen() *ui.Element {
	a := m.arena
	view := render.NewFeed(a.cameras.New(cameras.FromAbove, camera.Config{}), a.world.Picture())
	return ui.Layers(
		ui.Blank().Fill(backgroundColor),
		ui.Image(view).Input(a.players.Through(a.player)),
	)
}

Demos

examples/motion/collision is the same idea at scale: thousands of colliding boxes at a fixed 120 TPS, saved on F5 and loaded on F9.

gokebiten-2306-50.mp4

Stats: 2306 colliding AABBs | 120 TPS | 50 collisions/tick
gokebiten-524-15.mp4

Stats: 524 colliding AABBs | 120 TPS | 15 collisions/tick

The demos are grouped by what they show, each group from the simplest to the richest. Every demo opens a window; what it shows is tested in the library's package that does it.

Start: the frame of a game

Demo Shows Run
start/minimal One Stage, one Scene, a world with collisions: the README example make demo-start-minimal
start/scenes A menu Stage switching into a gameplay Stage; a modal panel over the ticking world, open or closed as the game was saved make demo-start-scenes
start/split-screen Two players at one keyboard, each driving its own block through a camera of its own in its half of the screen; a minimap over them make demo-start-split-screen

Look: how entities and the sky are drawn

Demo Shows Run
look/animation A gallery of animations drawn frame after frame from sheets in game time (Shift+P freezes them), each figure labelled: a walker on a patrol stepping the way it goes in eight directions and braking, an idler bored by stages (blinking, stamping, looking about), a puller's hands on a lever, a lever swinging down and up make demo-look-animation
look/material Materials of the game's own WGSL in place of sprites: a ward pulsing on the clock and four embers circling it, drawn out of their own state, breathing standing and streaming a tail driven (WSAD); G calms the ward and F douses the embers into plain sprites for a while make demo-look-material
look/sky A sky of the game's own over a valley in relief, a day in a minute and a half: its own WGSL vault, turquoise by day and violet at night with a band shimmering along the stars' equator, the Earth's moon three times as big and orange, showing by day too, made-up stars; Tab turns to the perspective, Q and E turn it, F lays the stick down to the sky make demo-look-sky
look/light A flat board under a sun of the game's own, circling in a day and a half a minute: a tower's shadow baked into the ground and swinging with the sun, walkers casting soft patches, a hero (WSAD) casting the silhouette of its sprite, a lantern lighting the night and casting their shadows by it (switched on the settings screen, F10), lava that takes no shade make demo-look-light

Rules: states, rules and commands

Demo Shows Run
rules/effect An ice witch turns the ground round her into snow and the lake into ice; it thaws behind her, a walker slips on it, a boat is frozen in, each kind in its own frozen look; F freezes whoever the cursor points at, J hastens the selected for two seconds make demo-rules-effect
rules/mechanisms Three commands, each a sentence saying what it does, whom it is for and who sets it off: a hero walked on WSAD; a lever he walks up to, pulled and pushed back at hand (Space), its colour its position; a plate stood on, a gate flipped (G); zoom by the wheel, = and - make demo-rules-mechanisms
rules/dialog Conversations read from YAML: the traveller greeted by the miller and the smith in a window, the answers changing the host's Regard, shown under it as Friend, Neutral or Enemy make demo-rules-dialog
rules/vision Entities keeping out of each other's way by sight, and a hunter living off the ones that fail make demo-rules-vision
rules/guard Guards on a plan (plan.New): patrol the yard, step aside for whoever blocks them long enough, rest where they arrive, run to the gate while the alarm (A) is on make demo-rules-guard

Board: boards and maps

Demo Shows Run
board/atlas A small flat board drawn from the game's own atlas, the water a material shimmering per pixel, a road laid as a way make demo-board-atlas
board/custom-map A board drawn by a map of the game's own (board.Plugin.WithMap) and walked by a mobility of its own (cell.Mobility): hills rising out of the grass, shaded by how high they lie; the walkers go round the steep and slow down climbing make demo-board-custom-map
board/flat Madeira on the simple map out of cmd/gramap: the island in its colours, rivers and roads as bands, bridges and fords standing on it, a day and the weather going by, a minimap north up make demo-board-flat
board/topography Madeira in relief, its true heights, a cell 333 m: lit by the sun, water running, roads laid as Transport Tycoon lays them, bridges on piers (Shift+B builds one), the ground shaped under the cursor; the camera on a stick turned (Q, E) and leaned (R, F) from straight down to isometric and flatter, orthographic or in perspective (Tab); a minimap turning with it, a click on it taking the camera there; the ground textured from photographs (make demo-board-topography) or painted, the light in bands (make demo-board-topography-ghibli); the hour, the date and the weather in a bar or apart by the corners (make demo-board-topography-widgets) make demo-board-topography

Navigation: units sent across a board

Demo Shows Run
navigation/square Units sent by a right click round a wall and over a river by a bridge, a wagon too wide for it going round by the ford; sight cones that stop at walls and fade in forests, and a hawk that flies over both make demo-navigation-square
navigation/hex The same on a hex board make demo-navigation-hex
navigation/spawn Walkers spawned under the cursor (N) hold their cells at once, the cells held outlined make demo-navigation-spawn

Motion: collisions, shots and gravity

Demo Shows Run
motion/collision Thousands of bouncing boxes of many kinds, hit overlays, telemetry, save and load make demo-motion-collision
motion/expanse A hundred thousand boxes on a torus of about 98 km: the world lives in bands round the camera, the settings screen (F10) shows the sectors by their bands or by what the camera sees and switches the bands off to weigh what they spare; a minimap of the boxes round the camera make demo-motion-expanse
motion/bullet A soldier on WSAD, the camera following him: Space fires a round the way he faces, Shift held and let go throws a grenade the further the longer held, in an arc that lies with a spark and bursts; the shots are the plugin's, the wounds and the fuse rules, effects and a plan make demo-motion-bullet
motion/platformer A hero in a side view under gravity: A and D walk, W jumps, G turns gravity over, a hard landing dazes make demo-motion-platformer

Model

Stage and Scene

A game.Game supplies Props (window, tick rate) and its Stages by name; game.New(props, stages...) is one. A Stage is one self-contained context, a menu or the gameplay, with its own goke ECS built fresh the moment Runtime.SwitchStage enters it: Init installs its plugins, Spawn seeds a fresh game, Loaded stands in for Spawn when the Stage is entered from a save, Update is one tick. A game defines a Stage a section at a time with game/stage, always in the same order; a chain out of order does not compile, a section the game has no use for is left out, and what is defined in the wrong section is refused:

stage.New(MeadowStage).
	World(worldCfg).                                   // the world…
	Plugins(s.makePlugins).                            // …and the plugins the arena makes over it
	Players(s.definePlayer).                           // the players, the plugins' default keys
	Effects(s.defineEffects).                          // the states
	Rules(s.defineRules).                              // the roles and their rules
	Commands(s.defineCommands).                        // what can be asked for
	With(dialog.Conversations(s.defineConversations)). // a plugin's own section, in its place
	Kinds(s.defineCells, s.defineKinds).               // the kinds of cells and of units
	Controls(s.bindKeys).                              // the game's own keys, each a command
	Spawn(s.spawn).                                    // a fresh game, out of its map
	Scenes(s.defineScenes).                            // the scenes and their cameras
	Update(s.update)                                   // the tick; hands back the game.Stage

Saving and loading are the engine's: commands like any other (game.Save, game.Load, game.Enter, game.Quit) bound to keys (F5, F9, Shift+Esc in every game, as the controls screen (K) lists them and F10 opens the settings) or to ui buttons, a Stage entered from its save from any other, a menu's Continue.

A Scene is one thing a Stage can show. Its screen is a ui tree: the world's picture, all its plugins draw, through a camera made beside it, render.NewFeed(s.cameras.New(cameras.FromAbove, camera.Config{}), s.world.Picture()), wired to the player who acts through it, ui.Image(feed).Input(s.players.Through(player)). The Stage's Scenes registry is static; the Composition over it is live: which Scenes are visible, in what order, and which is active, the topmost focusable one and the only one whose HandleEvents runs. Runtime (pause, quit, switch Stage, persistence, full screen) reaches a Stage and every Scene alike.

Plugins, rules and plans

A plugin's Install only queues ECS wiring, flushed in one ecs.Setup after the Stage's Init. What a game does about what a plugin finds is a rule of ceremony, the library of behaviour gram builds on, run in the pass of the plugin that catches its moment:

roles := s.world.Roles()
roles.Define(PreyRole)
roles.Define(PredatorRole, act.Branch[collision.Meeting]{
	Name: "caught",
	When: cond.Other.If(roles.Named(PreyRole)),
	Then: act.Other.Do(world.Despawn{}),
})
kind.Define[body](kinds, HunterKind, kind.Spec{…, ceremony.Plays(roles.Named(PredatorRole))})

reads as a sentence, who, when, what: a predator, meeting one playing prey, has it give itself a Despawn. Every clause says whom it is about: cond.Self the entity, cond.Other the other of the pair or the moment's subject, cond.At(board.Around(n)) the places round, cond.On(s.world) the world's own entity; and in what state, .If(x): under an effect, playing a role, as a word of the moment says (unit.Fallen(), vision.SeesNobody()), cond.Chance(p), cond.Always, or cond.Not of one; clauses join with And and Or. The steps say the same, act.Self.Put(e), act.Other.Do(cmd), act.At(board.Around(1)).Put(e), in order (act.Steps) or the first that does (act.OneOf). Nobody hands the rule to collision: once Init returns, the engine gives the rules of every role somebody plays to the plugin that catches their moment, and a rule none catches is an error. What an entity does over time is a plan of loops, plan.New(name, root), carried by its kind: a plan.Loop is a branch with an end — Until the fact a plugin tells it (cond.Self.If(navigation.Arrived{})), Timeout, Cooldown — choosing what to do (plan.OneOf) or doing one thing after another (plan.Steps). Rules and plans cast effects that hold for a while and give commands for their entity (plan.Self.Do(navigation.MoveTo{…})), the same a player gives. An effect turns the knobs a plugin gives, components it only reads, like steering.Steering or a cell's cell.Ground. A rule holds no Go code but its words; how entities are drawn is the one place rules are Go (world.Rule), declared on the world's atlas in a scene, where a kind's look under an effect is declared too (Slot.Under(effect, look)). A plugin ships no ready-made reactions: it gives moments and their words, and the game says what follows. The language is ceremony's doc/ceremony.md; how a game uses it over gram's plugins is doc/behaviour.md.

What a player wants is a command too, a command.Command (ceremony's): the plugin that defines its type (navigation.MoveTo, selection.Select) is a plugin.CommandHandler that drains its queue in its own pass; the players plugin carries what a player's bindings, an AI or a network issue, the world what the entities give themselves.

Roles and commands

A role is a behaviour an entity plays, mortal, hasty, a plate, not a group: s.world.Roles().Define(name, rules...) fires the rules for those playing it alone. Defining registers and hands nothing back: effects, roles, commands and kinds are defined under a name in the Stage's world and taken back with Named(name) wherever they are built on; a game keeps its names as constants, each with a suffix saying what it names (Ef, Cmd, Role, Plan, Kind, Cell). A kind plays roles through ceremony.Plays(roles...), a cell through its kind or alone, a plugin through its own Plays: a plugin is an entity of the world too (world.Self), so a state of the sky is an effect on the atmosphere, ceremony.Put(bloodMoon).On(s.atmosphere), read back by cond.On(s.atmosphere).If(bloodMoon). A command about an effect is a sentence: put it on (ceremony.Put), take it off (Take) or switch it (Toggle), for the entities bearing a name, those in a group, the world itself, the player's selected units or the one pointed at (On), set off by the entity named (By):

const (
	OpenEf       = "open"
	HasteEf      = "haste"
	MortalRole   = "mortal"
	HastyRole    = "hasty"
	PlateRole    = "plate"
	OpenWestCmd  = "open west"
	OpenEastCmd  = "open east"
	HastenCmd    = "hasten"
	ScoutKind    = "scout"
	TrapdoorKind = "trapdoor"
	PlateKind    = "plate"
)

fx, roles, cmds := s.world.Effects(), s.world.Roles(), s.world.Commands()

// Effects
fx.Define(OpenEf, effect.Spec{effect.Lasts(2 * time.Second),
	effect.Alter(func(f *cell.Floor) { f.Allows = 0 })})
fx.Define(HasteEf, effect.Spec{effect.Lasts(3 * time.Second),
	effect.Alter(func(st *steering.Steering) { st.MaxSpeed *= 2 })})

// Rules
roles.Define(MortalRole, act.Branch[unit.Standing]{
	Name: "fall in",
	When: cond.Self.If(unit.Fallen()),
	Then: act.Self.Do(world.Despawn{}),
})
roles.Define(HastyRole)
roles.Define(PlateRole, act.Branch[collision.Meeting]{
	Name: "press",
	When: cond.Self.If(cond.Always),
	Then: act.Self.Do(ceremony.Trigger{}),
})

// Commands
cmds.Define(OpenWestCmd, ceremony.Put(fx.Named(OpenEf)).On(ceremony.Group("west trapdoors")))
cmds.Define(OpenEastCmd, ceremony.Put(fx.Named(OpenEf)).On(ceremony.Group("east trapdoors")).
	By(ceremony.Named("plate")))
cmds.Define(HastenCmd, ceremony.Put(fx.Named(HasteEf)).
	On(s.selection.Selected(roles.Named(HastyRole))))

// Kinds
units.Define(ScoutKind, land, profile,
	ceremony.Plays(roles.Named(MortalRole), roles.Named(HastyRole)))
standing.Define(TrapdoorKind, comp.Const(cell.Floor{Allows: cell.Land}))
standing.Define(PlateKind, comp.Const(collision.Collider{}),
	ceremony.Plays(roles.Named(PlateRole)))

// Controls
s.player.Bind(
	control.Give(control.KeyPress{Key: control.Key1}, "Pull the west lever",
		cmds.Named(OpenWestCmd)),
	control.Give(control.KeyPress{Key: control.KeyJ}, "Hasten the selected scouts",
		cmds.Named(HastenCmd)))

// Spawn: the map; a row's yaml tags are the file's keys
type unitRow struct {
	Start cell.ID `yaml:"start"`
}
level, _ := s.board.Lay(mapFile, "map.yaml")
board.Spawn[standingRow](level, nil)
board.Spawn[unitRow](level, board.Sides{
	PlayerSide: {players.Give{To: s.player.ID}, selection.Allow{}}})
board:
  entities:
    - {kind: trapdoor, group: west trapdoors, at: [7, 2]}
    - {kind: plate, name: plate, at: [20, 14]}
units:
  - {kind: scout, side: player, start: [3, 14]}

1 opens every trapdoor in the group "west trapdoors" for two seconds under whoever stands there; the plate, met, sets off the command that names it; J hastens the selected scouts. An AI gives them as a key does (players.Issue). examples/rules/mechanisms is the whole program.

Kinds, spawning and saves

kind.Define[Row](world.Kinds(), "name", kind.Spec{...}) says what an entity is: each component comp.Const(v) (the same for all) or comp.Load(func(row Row) T) (read from that entity's row). A unit over a board is defined through board.NewUnits[Row](brd, shape, at), which derives its position and cell from the one point at reads off a row and runs the world's roster, what the plugins in the game bring by default and what they require. What stands still on the board, a lever, a trapdoor, a gate, is a kind of board.NewEntities. Spawn puts entries on the world's roster, or lays the game's map out of a file (board.Plugin.Lay: the board a character a cell, the relief in metres, roads and rivers along paths, the entities and the units, each a kind and the fields of its row tagged yaml); a fresh Stage spawns them, a loaded one does not. Mid-game the command world.Spawn{Entry} adds an entity the same way. Kinds tell save files every component type their entities carry, so a game's own state survives a save without being registered anywhere else.

Maps of the Earth

cmd/gramap makes such a map of any stretch of the Earth at any scale out of a recipe (cmd/gramap/madeira.yaml is Madeira's):

go run ./cmd/gramap -recipe cmd/gramap/madeira.yaml -out examples/board/topography/map.yaml
go run ./cmd/gramap -recipe cmd/gramap/madeira.yaml -out examples/board/flat/map.yaml -flat

The recipe says where, how many cells and how many metres a cell spans, what the ground is by its slope and height, how much drained land makes a brook, a stream and a river, and the roads as chains of named places. The heights come from the Terrain Tiles on AWS, fetched once into the user's cache. The rivers are carved into the ground, the roads routed and graded as Transport Tycoon lays them, with bridges over water and down valleys and fords where a river is shallow; with -flat the map has no heights.

Architecture

The packages form a strict acyclic graph; each imports only the layers below it. Every package has a doc.go describing what it brings. What is left to do is in doc/roadmap.md.

Package Responsibility
camera The contract of a view onto a world: screen conversion, culling, move and zoom, projections, fastening over an entity
control The input vocabulary: Binding, the triggers, Context, Keys, a player's Tool; the commands a binding builds are ceremony/command's
render Drawing: Renderer, the Composer over Sources and its Frame, Direct sources, Feed, Atlas, Font, the drawing rules a renderer runs every frame
plugin The extension contract: Plugin, Installer (with Hosts, the hosts of a plugin's moments), CommandHandler, Serializable, PostLoader, Populator, Made; plugin/section the parts of a Stage
entity What every entity carries: Base, Position, Velocity, Z, Layers, Eye, Lie; entity/kind what an entity is (Spec, Define, Of, the roster)
clock The tactical clock: game time as the sum of the simulation's steps, the pause (Shift+P), the tempo (] and [), the Moment of a step with At and Every
ceremony The library gram builds on, outside the module: how entities behave — cond the conditions, act the steps and the branch of a rule, plan the loops of a plan, effect the states and knobs, fact the facts and messages, roles, commands about effects and labels in its root, host the hosts a plugin runs rules with, command the queues and the carrier, tag and comp; the language in its doc/ceremony.md, how a game uses it over gram's plugins in doc/behaviour.md
plugins/world The foundation: the space under the edge rules, kinds and tags, Seed/Populate, Spawn, Despawn, the commands about effects, the carrier of commands, the entities' looks on the atlas; it runs the core's systems; view beside it
plugins/motion The entities moved: Moving with Still and Braking, Leaving and Outside past an open edge, Drift, UnitTemplate; steering the heading and speed reached gradually
game What a game implements and receives: Game, Stage, Scene, Scenes, Composition, Initializer, Runtime, Persistence; game/stage a Stage defined a section at a time
ui A scene's screen as a tree of elements: Layers, Columns/Rows, anchors, Panel, Label, Image of a feed, Window, Button giving commands, elements pinned to entities Under an effect or On a name, a Theme
plugins/collision Collision over the world's space: Collider, Physics, Sweep, the solid ground of a Field, Meeting and Struck
plugins/vision Sight cones into Sighted, Sighting rules, the views drawn
plugins/board A square or hex grid with terrain over the world: the Board, Layout and Map, NewUnits, NewEntities, a level out of a file (Lay); cell the vocabulary of a cell, unit of a unit on it, grid the topology, look how it is drawn, ground what its ground is to the others, water and network rivers and roads
plugins/atmosphere The sky over a world on the world's clock: calendar, sky, celestial, climate and its weather, air, precipitation, weathering, backdrop
plugins/topography A map in relief drawn on the GPU: the heights, the light and the shadows, the water, the commands shaping the ground, the cameras' cage; relief and painter its vocabulary
plugins/navigation MoveOrder paths across a board, re-routing as terrain changes, on a bridge's deck or under it; MoveTo a cell or an entity by name; a patrol as a plan over the unit's Round (Onward); the crowd as rules over Touch; routes drawn
plugins/driving Units driven by hand: Ahead, Back, Turn, Toward, Jump, Tank and Compass keys
plugins/gravity Bodies falling, standing and leaping: Pull, Body, Leap, Away, Landing
plugins/bridges Bridges: Types of them, a level's, a player's with a Tool in hand; where one may stand in bridges/site
plugins/bullet Shots fired and flown: Shoot, Shots, Flight, Landing, Resting, Burst into Blasts
plugins/selection Select into Selected, Hovered, Selected(roles...) and Pointed() as the targets of commands
plugins/dialog Conversations as data: Nodes from YAML, a unit's Script, the game's Changes and Conditions, Window
plugins/cameras The cameras a game looks through: Config, New, the Cage, Pan, Zoom, Follow, CenterOn, the views' View, Turn, Tilt, Ride, Keys
plugins/players Players over the command handlers: bindings into commands, Through and IssueAs for a ui scene, the list of keys; players/owner whose a unit is
plugins/telemetry The telemetry panel: standard lines, the plugins' reporters and the game's own lines, a scene of its own in every Stage with a world, ~
internal/engine The Engine: the window's loop, one active Stage, saves and loads
gram Run; the package you import. The root doc.go carries the concepts and the full package graph
camera ───► render ──► plugin ──► plugins/world ──► game ──► internal/engine ──► gram
control ────┘  ▲                  │  ▲
ceremony ──────┘                  ▼  │
      plugins/{cameras, collision, selection, vision} ──► plugins/board ──► plugins/driving ──► plugins/navigation
                                                            └──► plugins/{bullet, gravity, bridges, topography, atmosphere} ──► plugins/players

Outside the module: ceremony is the behaviour — rules, plans, effects, roles, commands, facts — which gram's plugins perceive for and carry out, with goke as its store; goke is the ECS every Stage runs on, aabbworld the space, collisions and line of sight under the world, gogpu the window, the loop and the GPU (WebGPU), hpastar the pathfinding.

⏱️ Performance

One tick of the built-in plugins on an Intel i5-8265U (see BENCHMARKS.md), 0 allocs/op throughout once warm, the median of 5 runs at v0.6.0:

Tick Scene Cost
World: move every entity, the space reading them in place 5,000 boxes, 20×20 each, on a 4000×4000 torus 36 µs
World + collisions 524 boxes, 20×20 each, covering 20% of a 1024×1024 torus 124 µs
World + collisions 8,388 boxes, 5×5 each, covering 20% of the same torus 2.7 ms
Vision: every observer scans its cone 500 observers, 60° cones of radius 200 232 µs
Navigation: walkers under orders, in the open / through one gap 1,000 walkers on a 64×64 board 3.0 / 10.5 ms

Deep dive: every scene, what each benchmark measures, and how to reproduce, in BENCHMARKS.md.

make bench

Relationship to goke and aabbworld

gram began as goke's Ebitengine example and was extracted so the ECS stays free of GUI dependencies; it has since left Ebitengine for WebGPU. The world, collisions and line of sight are aabbworld's; gram is where they meet an ECS and a screen.

📖 Documentation

Credits

  • The night sky's stars: the Yale Bright Star Catalogue, 5th revised edition (Hoffleit & Warren 1991), from the CDS, catalogue V/50 (plugins/atmosphere/celestial/stars_gen.go).
  • The moon's face: NASA's Scientific Visualization Studio, CGI Moon Kit (Lunar Reconnaissance Orbiter LROC data) (plugins/atmosphere/celestial/moon.png).
  • The animation demo's lever: "Lever and Button" on OpenGameArt (CC0), its two positions, with two frames between drawn after them (examples/look/animation/lever.png). Its figure (hero.png) is drawn in-house.
  • The heights of Madeira's maps and of every map cmd/gramap makes: the Terrain Tiles on AWS, Mapzen's Tilezen terrarium — SRTM, EU-DEM (Copernicus), GMTED2010, ETOPO1 and others; see their attribution.

License

MIT

About

Modular game engine for Go build on top of goke ECS.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages