wisp

package module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 11 Imported by: 0

README

Wisp Engine

A 2D pixel-art engine for games that TinyGo compiles to WebAssembly. It is for one person building one game: entities in a structure of arrays, a Canvas2D renderer, keyboard and mouse, a camera, and no dependencies at all.

Press M while your game runs and every number that decides how it feels becomes editable, on the canvas, without a rebuild. Nudge a knob, feel the difference, press C, and paste the result back into your source as Go.

When a game is for more than one person, the same engine runs on a server that decides what happened, and the browser sends only what the player is trying to do. The lab plays that way out of the box: two tabs, one world.

Install

go get github.com/andygeiss/wisp-engine

The import path ends in -engine; the package is called wisp.

30 seconds

package main

import "github.com/andygeiss/wisp-engine"

func main() {
	e := wisp.New(wisp.Config{})
	e.LoadImages("/static/img/hero.png")

	hero := e.Add(wisp.Sprite{
		Height: 32,
		State:  wisp.StateAnimated | wisp.StateAnimatedLoop | wisp.StateVisible,
		Width:  32, X: 320, Y: 180,
	})
	e.CamTarget, e.InputTarget = hero, hero

	e.Run(func(dt float64) {
		if e.Input.JustPressed(" ") {
			e.Impact(e.Feel.Heavy) // a hit stop and a shake, in one call
		}
	})
}

WASD or the arrow keys move the hero. M opens the tuning menu, F goes fullscreen.

Why a hit is not a number

The engine it grew out of wrote its feel at the call site:

engine.CamShakeMagnitude = 4.0
engine.CamShakeTime = 150
engine.HitStopRemaining = 70

Six places did that, with three different sets of numbers, and changing any of them cost a TinyGo rebuild. Wisp names the strengths instead:

e.Impact(e.Feel.Light)  // a connect
e.Impact(e.Feel.Medium) // a solid hit
e.Impact(e.Feel.Heavy)  // a finisher

The numbers behind those three live in Settings, which the menu edits while the game runs.

Sprite sheets, either way round

Say nothing and you get the grid: every frame is the sprite's own size, an animation is a row, and Animation.FrameDuration times all of them. It costs nothing to state and it cannot describe anything else.

Hand it an Aseprite export and frames may be any size, last different lengths, and play forwards, backwards or back and forth:

sheet, err := wisp.ParseSheet(exported)
e.Sheets = []wisp.Sheet{sheet}   // one per image, in LoadImages order
e.Play(hero, "walk")

ParseSheet is a hand-written scanner, not encoding/json: TinyGo supports reflection only partly, so the reflective path costs tens of kilobytes of a module under a size gate and fails as a reflect panic in a browser on a build every gate called green. It reads the six fields the engine needs and steps over the rest, so a newer Aseprite is not a breaking change, and it checks what it read — every rectangle inside the image, every tag naming frames that exist, every duration a real number of milliseconds.

GridSheet builds the grid convention as the same Sheet, so the two are one code path with two ways in. That equivalence is a test against a real export, kept under testdata/aseprite/, which is what says a game can move to a sheet without its sprites moving.

A PixelLab spritesheet is the grid with names attached — one row of eight standing poses, then one row per animation and direction — so a game builds its Sheet with GridSheet and names the tags, the way internal/lab does, and pays for no parser at all.

The art

The lab's art is generated by PixelLab and named by ID in assets/pixellab.txt, one asset a line: a kind, the PixelLab ID and the name the files get. make art runs cmd/art, which downloads each ID's export and writes what the engine reads into web/static/img:

Kind What PixelLab serves What is written
character a uniform-grid spritesheet and its layout JSON, zipped the sheet cropped to the figure, and the layout rewritten to describe the cropped file
tileset a 4x4 sheet of sixteen Wang tiles and its metadata both, byte for byte as served
object a prop on a transparent canvas the PNG cropped to the prop

No token is needed: the URLs are keyed by the asset's UUID. The output is a function of the account's assets and nothing else, so it is committed, a fresh clone runs, and a second make art changes nothing.

The rows of a character's sheet are sorted too: the rotations first, then every animation by name, its directions in PixelLab's order. PixelLab's own export orders the animations by their IDs, so a regenerated walk would move every row under it and the constants that name the rows would be wrong.

The crop is the point. PixelLab centres a figure in a cell it does not fill, and the engine takes a sprite's rectangle for its hit box and its draw order, so an uncropped hero would sort late and collide on air. Every cell of a sheet is cut by the same rectangle, the union of every opaque pixel on it, so the frames keep their places relative to each other.

The tilesets are not in Wang-index order, and their metadata is what says which tile is which. internal/lab carries the table — the corner index NW*8 + NE*4 + SW*2 + SE to the tile's place on the sheet — and a test derives it from the committed metadata again, the way the lab's sheet constants are checked against the committed layout JSON. A regenerated asset that changed shape fails a test rather than a game.

To add an asset: generate it with PixelLab's MCP, add its line to the manifest, run make art, write the constants the lab needs, and run the tests. The lab's own art — a ranger in eight directions with a walk, a strike and an idle, a fox that walks, two chained tilesets and four props — cost 46 generations on 2026-09-10.

Run the lab

The lab is the engine's own playground: a scene that exists to be tuned, measured, and played by two people at once.

make wasm   # needs TinyGo and wasm-opt
make run    # http://127.0.0.1:8080/

Open the page in two tabs and each tab is a player in one world. The server behind make run runs that world at 30 ticks a second and decides what happened; a tab sends only what its player is trying to do, and draws what comes back one server tick behind, blended between two ticks.

Key What it does
WASD move — the arrow keys work too
Q strike: removes every bouncer whose hit box overlaps yours, when the server says it does
E spawn ten bouncers around you, up to the server's cap
R dash: four times as fast for 300 ms
1 2 3 a light, medium and heavy impact, on your own screen only
M the tuning menu · H the metrics overlay · F fullscreen

The three skills cool down for one, three and five seconds, and the cooldowns live on the server: a key held down does nothing until the server says so. The bars at the bottom right are the server's copy of them, and the line above the keys says what the wire costs — the round trip, the bytes a second, the size of a snapshot, how many snapshots were waiting when the tick ran, which is one when all is well, and how often the tab had to wait for one, apply two to catch up, or let a spare go that had lasted a second. Nudging World.Speed in one tab's menu changes nothing in the world, because the tab does not simulate it.

Add ?solo to the URL — http://127.0.0.1:8080/?solo — for the scene on your machine alone, which is the one the ramp measures:

Key What it does
] [ spawn and remove 100 sprites — with Shift, 1000
0 clear them
B ramp: add sprites until the frame time breaks, then hold the count
1 2 3 a light, medium and heavy impact
M the tuning menu · H the metrics overlay
P pause · N reset the scene · F fullscreen

B is the answer to "how many sprites before it drops below 60?" — it walks the count up and stops at the first frame the 99th percentile cannot carry. That number is the evidence for whether this renderer needs replacing.

The server

make run starts cmd/serve on 127.0.0.1:8080: the page, the module and the world, over one origin. Its flags each default to an environment variable, and go run ./cmd/serve -h is the whole contract:

Flag Variable Default What it is
-addr ADDR 127.0.0.1:8080 where to listen
-dir WEB_DIR web the tree to serve
-tick TICK 30 world ticks a second, 1 to 120
-players PLAYERS 8 players at once
-bouncers BOUNCERS 1000 the most bouncers at once
-crowd CROWD 100 the bouncers the world starts with
-log-level LOG_LEVEL info debug, info, warn or error

A tab that stops reading is closed rather than waited for, so one slow tab cannot slow the world, and a tab that sends more than 120 messages a second is closed too. The socket speaks RFC 6455 and a ten-message binary protocol, both written by hand in internal/, for the reason the sheet scanner is.

Inside the tuning menu
Key What it does
↑ ↓ move · Enter opens a group, or flips a switch
← → one step · Shift ten · Ctrl a tenth
Backspace reset this knob · with Shift, everything
C copy the whole settings value to the clipboard, as Go
T fire a test impact, so you can feel a change without playing

Two tools the gates do not need:

brew install tinygo-org/tools/tinygo   # 0.42 or newer, for Go 1.27
brew install binaryen                  # wasm-opt

Built and measured with TinyGo 0.42.0 (LLVM 22.1.4) and binaryen 132. Neither
is in the baseline's `VERSIONS.md`, so those two numbers live here.

Commands

make          # every gate against the working tree
make ci       # the same gates against the last commit
make art      # download the manifest's PixelLab assets into web/static/img
make wasm     # compile the lab and copy the browser artefacts
make run      # start the lab server: the page, the module and the world
make test     # go test -race -shuffle=on ./...
make build    # release-shaped binaries in bin/
make fmt      # goimports + go fix
make clean    # rm -rf bin/

Baseline deviations

This project follows the engineering baseline. Five of its rules are waived here.

  • No main package (project-types/library.md) — waived 2026-09-09 by Andy, widened 2026-09-10. A feel engine is judged by feel, so it ships the playground that proves it. Scoped to cmd/lab, which only builds for js/wasm; cmd/serve, the lab's server — the page, the module and the world over one WebSocket — which is never deployed until a milestone says otherwise; and cmd/art, the build-time tool that turns the PixelLab manifest into the art the lab serves, which runs on a developer's machine and nowhere else. The library imports none of them, and go list -deps . shows only the standard library.
  • Every mutation is a POST route (checklists/web-application.md) — waived 2026-09-09 by Andy. The world's mutations arrive over the WebSocket at GET /ws, as intents. The rule protects the no-JS fallback of a plain form, and a canvas client has no such fallback to protect. Scoped to /ws; every other route is a GET that changes nothing.
  • No hand-written JavaScript (stack/html.md) — waived 2026-09-09 by Andy. A browser can only start a WebAssembly module from a script, and the module's own size and the GPU's name are not reachable from Go. Scoped to web/static/js/: wasm_exec.js is TinyGo's runtime glue, copied by make wasm and never edited, and wasm_app.js is a 119-line loader. No build step, no npm; the policy below is updated to match.
  • CSP script-src carries 'wasm-unsafe-eval' (patterns/security-headers.md) — waived 2026-09-09 by Andy. Browsers refuse to compile WebAssembly under default-src 'self' alone. It allows WASM compilation only; 'unsafe-eval' and 'unsafe-inline' stay banned. Scoped to the csp constant in cmd/serve/main.go.
  • make check has two lines the baseline's does not (stack/makefile.md rule 1) — waived 2026-09-09 by Andy. GOOS=js GOARCH=wasm go vet on the engine and the lab, because the host vet never sees a js && wasm package, and a size check on the built module. ci runs check, so both are in both.

Conformance notes, for the reader who checks the boxes:

  • Nothing is embedded, and that is the rule rather than a deviation from it. project-types/library.md says a library ships no main package, no embedded assets and no CLI; patterns/go-project-layout.md, which does want a binary carrying its own web/, says in its own opening line that it is the layout for a web application. So cmd/serve reading the tree named by -dir is compliance twice over — and it is also what makes editing the stylesheet and reloading the whole loop.
  • The version is a digest of the served tree, not debug.ReadBuildInfo. The lab's assets are on disk rather than embedded, so the binary's identity says nothing about them. treeVersion in cmd/serve/main.go hashes what is actually served, which is what the baseline's rule — two builds with different assets must not share a version string — is really asking for.
  • context.Context is not the first parameter of Engine.Run, the only call that blocks. The browser owns the frame loop's lifetime and nothing on the Go side can cancel it; Engine.Stop is the handle the rule is asking for.
  • RFC 6455 and the wire are hand-written, in internal/ws and internal/wire, for the reason the sheet scanner is: the standard library has no WebSocket, the dependency rule refuses one, and the client is TinyGo, which pays for reflection in kilobytes and fails at it in a browser.
  • The socket's deadlines are set after the hijack. http.Server's read, write and idle timeouts stop seeing a hijacked connection, so ws.Accept clears the ones net/http left and the server sets its own: a read deadline of twice the ping interval, refreshed by every frame, and a write deadline per write. That is how the timeout rule is met on a connection the server's timeouts cannot reach, and it is WriteTimeout replaced, not widened — the 30 seconds still cover the page and the tree.
  • Shutdown cancels first and waits after. srv.Shutdown never drains a hijacked connection, so RegisterOnShutdown closes every socket with 1001 and hub.Wait() after Shutdown waits for their goroutines. That is the background-work pattern's shape.
  • No errgroup. golang.org/x/sync is how the baseline writes background work and a dependency in a module whose feature is having none. Two goroutines under one signal context and a wait for each do what it would; run in cmd/serve/main.go is those twenty lines.
  • make art and make wasm are rule-3 targets: the recurring commands the gates cannot run.
  • No htmx: the page has no hypermedia interaction, so the script would do nothing.
  • No ops listener, no pprof and no /healthz. The lab server never deploys — the server that ships is a milestone of its own — and profiling the game means the browser's own tools.
  • devicePixelRatio is deliberately unhandled — see DESIGN.md.
  • TinyGo decides which Go the engine can be built with, so the Go pin cannot move ahead of it. 0.42 was the first release to accept 1.27.

License

MIT.

Documentation

Overview

Package wisp is a 2D pixel-art engine for games that TinyGo compiles to WebAssembly.

It is small on purpose: entities in a structure of arrays, a Canvas2D renderer, keyboard and mouse, a camera, and no dependencies at all. If you can read Go you can read the whole engine in an afternoon.

New is the entry point. Engine.Run takes the frame loop from there:

e := wisp.New(wisp.Config{})
e.LoadImages("/static/img/hero.png")
hero := e.Add(wisp.Sprite{
	Height: 32, State: wisp.StateVisible, Width: 32, X: 320, Y: 180,
})
e.CamTarget, e.InputTarget = hero, hero
e.Run(func(dt float64) {
	if e.Input.JustPressed(" ") {
		e.Impact(e.Feel.Heavy)
	}
})

Tuning

Every number that decides how a game feels lives in one Settings value, embedded in the engine. Press M while the game runs and a menu appears on the canvas that edits it, remembers it across reloads, and copies it back out as Go source you paste into your game. That is what the engine is for: a feel is found by trying it, and a rebuild between tries is too slow.

A hit is three named strengths rather than numbers at the call site:

e.Impact(e.Feel.Light)  // a connect
e.Impact(e.Feel.Heavy)  // a finisher

Entities

An entity is an index into every slice at once, so a game reaches into e.X[i] instead of calling a getter. Engine.Add fills a slot and Engine.Delete empties one and keeps it for the next entity; nothing allocates once the slices have grown, which is what keeps the wasm module small and the frame steady.

Keeping the slot is what makes an index worth holding: a delete moves nothing, so an index still means the entity it meant last frame. Iterate to Engine.Slots and skip the holes with Engine.Live; Engine.Count is how many entities there are, which is a smaller number. A slot is reused eventually, so an index that has to outlive a delete — one in a save, or in a packet — travels as an ID and is resolved back with Engine.Index.

The engine owns state bits 0 to 14 — StateVisible and friends, and StateRemote for an entity that somebody else moves — and a game adds its own above bit 15. Engine.RowForState maps a state to the sprite sheet row that draws it, so the engine never has to know what an "attack" is. Engine.Facing is how many ways an entity turns: 2 by default, a left row and a right row, or 4 or 8, which is how the rotations of a top-down sprite key that map.

Sheets

A sprite sheet can be described two ways and the engine draws both the same. The grid convention needs no description at all — every frame is the entity's own size, an animation is a row, and AnimationSettings says how long a frame lasts — and it is what a game gets by saying nothing.

The other is what Aseprite exports: a rectangle and a duration per frame, and named ranges over them. ParseSheet reads that export, and the result goes in Engine.Sheets, one per loaded image:

sheet, err := wisp.ParseSheet(exported)
e.Sheets = []wisp.Sheet{sheet}
e.Play(hero, "walk")

Frames may then be any size, last different lengths, and play forwards, backwards or back and forth. GridSheet builds the grid convention as the same Sheet, so the two are one code path rather than two renderers to keep in step, and a game moving from one to the other does not move its sprites.

Time

A frame and a tick are not the same thing. Engine.Run calls the update it was given once per frame, on however long the frame took: read the keys there, spawn things there, draw a HUD there. The world moves in Engine.Simulate, which runs at TimeSettings.TickRate a second with the same dt every time, so how far anything goes stops depending on how fast the machine drawing it is:

e.Simulate = func(dt float64) {
	if e.Input.Down("z") {
		e.X[hero] += 0.2 * dt // the same distance on any display
	}
}

A frame pays for whole ticks and carries the remainder, so a frame may run two ticks or none. Everything the player only looks at — the camera, the shake, the animations — stays on the frame, because none of it has to agree with anybody.

That the world moves in steps is not something a player should be able to see, so the draw blends each entity between the tick that has run and the one that has not. Engine.DrawPos is where a sprite is on screen and e.X[i] is where the rules say it is; they are up to a tick apart, and anything a game draws over an entity wants the first one. A game that jumps an entity somewhere rather than moving it there says so with Engine.Place, or the jump is blended like any other.

Platforms

The browser half is behind a js and wasm build tag. Everything else builds and tests anywhere, against a headless stand-in, so go test and go vet see the whole API on a developer's machine.

Nothing here is safe for concurrent use. The browser calls the frame loop on one goroutine, which is the only way it is meant to be driven.

Example

A game creates one engine, fills it with entities, and hands the frame loop over. Run blocks until Stop, so main is one call.

package main

import (
	"github.com/andygeiss/wisp-engine"
)

func main() {
	e := wisp.New(wisp.Config{})
	e.LoadImages("/static/img/hero.png")

	hero := e.Add(wisp.Sprite{
		Height: 32,
		State:  wisp.StateAnimated | wisp.StateAnimatedLoop | wisp.StateVisible,
		Width:  32, X: 320, Y: 180,
	})
	e.CamTarget, e.InputTarget = hero, hero

	e.Run(func(dt float64) {
		if e.Input.JustPressed(" ") {
			e.Impact(e.Feel.Heavy)
		}
	})
}

Index

Examples

Constants

View Source
const (
	StateAnimated = uint64(1 << iota)
	StateAnimatedLoop
	StateAutoHide
	StateFaceDown
	StateFaceLeft
	StateFaceRight
	StateFaceUp
	StateIdle
	StateMove
	StateMoveDown
	StateMoveLeft
	StateMoveRight
	StateMoveUp
	StateVisible
	// StateRemote marks an entity that somebody else moves — a server, a
	// replay. The engine keeps its row in step with its state bits but never
	// moves it and never rewrites its pose, so a client can hold the move bits
	// a server sent, for the camera's look-ahead, without the entity walking
	// off on its own. It is bit 14, and it comes last rather than in
	// alphabetical order so that every bit before it keeps the number it had.
	StateRemote
)

The engine's state bits. It reads the ones it knows; a game adds its own above bit 15, which leaves bit 15 spare for the engine to grow into.

View Source
const KeyNone = "-"

KeyNone turns off a key an engine would otherwise claim. Give it to Config.MenuKey to ship a game with no tuning menu, or to Config.FullscreenKey to keep F for yourself.

MaskMove is the four bits that say an entity is being pushed somewhere.

MaskPose is the six bits that make a pose: the four facings, idle and moving. A game usually starts its Engine.RowMask with it. At the default Engine.Facing of 2 the vertical facings are never set, so the keys of a two-row sheet's map are the four bits they always were.

Variables

View Source
var (
	// ErrSheetTooLarge means the input is over maxSheetBytes.
	ErrSheetTooLarge = errors.New("wisp: sheet input too large")
	// ErrSheetSyntax means the bytes are not JSON, or are JSON that is not an
	// Aseprite json-array export.
	ErrSheetSyntax = errors.New("wisp: malformed sheet")
	// ErrSheetTooDeep means the nesting is over maxSheetDepth.
	ErrSheetTooDeep = errors.New("wisp: sheet nested too deeply")
	// ErrSheetTooMany means there are more frames or tags than a sheet may
	// hold.
	ErrSheetTooMany = errors.New("wisp: too many frames or tags in sheet")
	// ErrSheetRange means a number is not finite, a frame's rectangle leaves
	// the image, or a tag names frames that do not exist.
	ErrSheetRange = errors.New("wisp: sheet value out of range")
)

The failures a caller can branch on. Everything the parser rejects comes back wrapped around one of these, so a game can tell "this is not the file I meant" from "this file is too big to be one".

Functions

This section is empty.

Types

type AnimationSettings

type AnimationSettings struct {
	// CullOffscreen stops an entity outside the view from advancing its
	// animation. It saves work in a crowded world, and it means an enemy walks
	// on screen mid-stride instead of from frame zero.
	CullOffscreen bool
	// FrameCount is how many frames a grid sheet puts in one row.
	FrameCount int
	// FrameDuration is how long one frame lasts, in milliseconds.
	FrameDuration float64
}

AnimationSettings decides how sprite animations run when a sheet does not say otherwise.

type AudioSettings

type AudioSettings struct {
	// MusicVolume scales the music, on top of Volume.
	MusicVolume float64
	// PitchJitter randomises playback speed by up to this fraction, so the
	// same hit sound does not sound identical twenty times in a row.
	PitchJitter float64
	// SfxVolume scales the sound effects, on top of Volume.
	SfxVolume float64
	// Voices is how many copies of one sound may play at once. One copy means
	// three monsters dying together make one sound.
	Voices int
	// Volume scales everything.
	Volume float64
}

AudioSettings decides how loud things are and how much they vary.

type CameraSettings

type CameraSettings struct {
	// DeadzoneHeight is how far the target may move up or down before the
	// camera follows, in pixels.
	DeadzoneHeight float64
	// DeadzoneWidth is the same across.
	DeadzoneWidth float64
	// Lookahead pushes the camera this many pixels ahead of the way the target
	// is moving, so you see where you are going.
	Lookahead float64
	// Smoothing is how fast the camera catches up with its target, per
	// second: 0 snaps to it, 8 is a soft follow, 30 is almost a snap. The
	// engine closes the gap by 1-exp(-Smoothing*dt), so the feel does not
	// change between a 60 Hz screen and a 144 Hz one.
	Smoothing float64
	// Snap rounds the camera to whole pixels. Off, tile edges show seams.
	Snap bool
}

CameraSettings decides how the camera follows its target.

type Config

type Config struct {
	// FullscreenKey toggles fullscreen. Empty means "f". [KeyNone] leaves
	// fullscreen out, so a game may use F for itself.
	FullscreenKey string
	// Height of the drawing surface, in pixels. 0 means 360.
	Height int
	// MenuKey opens and closes the tuning menu. Empty means "m". [KeyNone]
	// leaves the menu out, which is what a shipped game wants: with no menu
	// the engine also stops reading and writing the browser's storage.
	MenuKey string
	// Mount is the CSS selector the canvas is appended to. Empty means
	// "main", falling back to the document body.
	Mount string
	// Width of the drawing surface, in pixels. 0 means 640. 640x360 scales to
	// 1080p and 4K by whole numbers, so nothing blurs.
	Width int
}

Config is what New needs. Every field has a working default, so wisp.New(wisp.Config{}) gives a 640x360 engine with the tuning menu on M and fullscreen on F.

type DebugSettings

type DebugSettings struct {
	// ShowHitBoxes outlines every entity's hit box, the rectangle
	// [Engine.BoundingBox] returns, over the scene. It is where the rules have
	// the entity rather than where its sprite is drawn, so with
	// [RenderSettings.Interpolate] on the box runs up to a tick ahead of the
	// sprite — that gap is the blend made visible. An invisible entity's box
	// is drawn too, because an unseen collider is what somebody switching this
	// on is looking for.
	ShowHitBoxes bool
	// ShowMetrics draws the frame-time and memory overlay.
	ShowMetrics bool
}

DebugSettings turns the engine's own overlays on.

type Direction

type Direction uint8

Direction is the order a Tag plays its frames in. It matches Aseprite's own three, and it is spelled the way the export spells it.

const (
	Forward Direction = iota
	Reverse
	PingPong
)

The play orders. Forward is the zero value, so a Tag nobody set a direction on plays the way almost every animation does.

type Engine

type Engine struct {
	Settings

	// Alpha is the opacity, 0 to 1.
	Alpha []float64
	// FrameOffset is the animation frame showing now.
	FrameOffset []int
	// FrameTime is the milliseconds spent on the current frame.
	FrameTime []float64
	// ImageColumn is the sheet column of the sprite's first frame.
	ImageColumn []int
	// ImageIndex is which loaded image, in [Engine.LoadImages] order.
	ImageIndex []int
	// ImageRow is which animation is playing: a row on the grid convention, a
	// tag's index when the image has a [Sheet]. RowForState overwrites it for
	// entities whose state has a bit in RowMask, and [Engine.Play] writes the
	// tag it looked up.
	ImageRow []int
	// PrevX is where the entity was when the tick running now began, and
	// PrevY the same. [Engine.DrawPos] is what reads them, to place a sprite
	// between two ticks; [Engine.Place] is how a game writes them, for an
	// entity that jumps somewhere rather than travelling there.
	PrevX []float64
	PrevY []float64
	// ScreenSpace draws the entity in canvas pixels, past the camera.
	ScreenSpace []bool
	// SpeedFactor multiplies World.Speed for this entity.
	SpeedFactor []float64
	// SpriteHeight is the drawn height in pixels.
	SpriteHeight []float64
	// SpriteWidth is the drawn width in pixels.
	SpriteWidth []float64
	// State is the entity's state bits. The engine reads the ones it knows; a
	// game adds its own above bit 15.
	State []uint64
	// X is the world x of the sprite's centre.
	X []float64
	// Y is the world y of the sprite's centre.
	Y []float64
	// Z is the draw layer. Lower draws first.
	Z []int

	// Facing is how many ways [Engine.Move] turns an entity: 2, 4 or 8, and 0
	// means 2. At 2 only the horizontal axis turns it, which is what a sheet
	// with a left row and a right row wants, and what every game had until
	// now. At 4 a move sets one of the four facing bits; at 8 a diagonal sets
	// two, one from each axis, so the eight rotations of a top-down sprite
	// key a RowForState map through [MaskPose]. Both halves of a networked
	// game set the same value, because the server is what turns a player.
	Facing int
	// RowForState maps a state, masked by RowMask, to the sheet row that draws
	// it. A game fills it once; the engine only looks things up, so it never
	// has to know what an "attack" is.
	RowForState map[uint64]int
	// RowMask picks the bits RowForState is keyed by. Start it with [MaskPose]
	// and add the game's own action bits.
	RowMask uint64

	// Sheets describes the loaded images: one entry per image, in the order
	// [Engine.LoadImages] took them. A game fills it once, from [ParseSheet]
	// or [GridSheet]. An image with no entry — or a zero Sheet, or one with no
	// tags — draws on the grid convention instead, so a game that never had a
	// sheet never needs one. See [Sheet].
	Sheets []Sheet

	// CamTarget is the entity the camera follows, or -1 for none.
	CamTarget int
	// InputTarget is the entity the movement keys drive, or -1 for none.
	InputTarget int

	// CamX is the world x of the view's left edge, before the shake. Read it
	// for parallax; writing it lasts until the next frame.
	CamX float64
	// CamY is the world y of the view's top edge, before the shake.
	CamY float64
	// CamShakeX is the shake offset used when drawing, in pixels. It does not
	// move the world, so a hit box never moves with a shake.
	CamShakeX float64
	// CamShakeY is the vertical shake offset.
	CamShakeY float64

	// Height of the drawing surface in pixels. Read it; do not write it.
	Height float64
	// Width of the drawing surface in pixels. Read it; do not write it.
	Width float64

	// Input is the keyboard and the mouse. Ask it Down, JustPressed or
	// JustReleased; nothing here ever needs clearing by hand.
	Input Input

	// Paused holds the world still. The scene still draws and the game still
	// gets its update with a dt of 0, so it can show a menu and read the keys
	// that leave it, but nothing moves and no animation advances.
	Paused bool

	// RenderUI draws screen-space content after the entities: a HUD, a score,
	// a menu. Use [Engine.Rect] and [Engine.Text]. The engine's own tuning
	// menu draws after this, so it stays on top.
	RenderUI func()

	// Simulate moves the game's own world, and runs at the fixed rate
	// [TimeSettings.TickRate] sets rather than once per frame. Put movement,
	// physics and anything a rule depends on here: it is called with the same
	// dt every time, so the same input twice gives the same answer twice,
	// which is what a replay — and one day a rollback — needs.
	//
	// Read held keys here with [Input.Down]. An edge belongs in the update
	// given to [Engine.Run], which runs once per frame: a frame can carry two
	// ticks or none, so a JustPressed read here fires twice or not at all.
	Simulate func(dt float64)
	// contains filtered or unexported fields
}

Engine is one game's world: its entities, its camera, its input, and the Settings that decide how all of it feels.

Entities live in a structure of arrays. Every attribute is its own slice and an entity is an index into all of them, so a game reaches into e.X[i] instead of calling a getter, and adding an entity allocates nothing once the slices have grown. An index keeps meaning the same entity until that entity is deleted; see ID for a name that outlives one.

Settings is embedded, so the knob you nudge while tuning is short — e.Feel.Heavy.ShakeMagnitude — and the whole set is still one value the menu can save, reset and print.

Use New. An Engine is not safe for concurrent use: the browser calls the frame loop on one goroutine, which is the only way it is meant to be driven.

func New

func New(cfg Config) *Engine

New creates an engine with the default settings and no entities. It touches no browser API; the canvas appears when Engine.Run starts.

func (*Engine) Add

func (e *Engine) Add(s Sprite) (index int)

Add adds an entity and returns its index. The sprite is drawn centred on (X, Y).

The index is stable: it keeps meaning this entity until the entity is deleted. It is reused after that, so an index held across a delete needs an ID instead — see Engine.IDOf.

func (*Engine) AddTilemap

func (e *Engine) AddTilemap(m Tilemap)

AddTilemap adds one visible entity per tile. Tiles do not move and carry no state bits, so they cost a draw and nothing else.

func (*Engine) BoundingBox

func (e *Engine) BoundingBox(i int) (l, t, r, b float64)

BoundingBox returns the left, top, right and bottom edge of entity i's hit box: the sprite shrunk by WorldSettings.HitBoxMargin on all four sides, so sprites have to visibly overlap before they collide. A margin past half the sprite gives a box of zero size, which never collides.

It panics when i is not an entity, which is a programmer error.

func (*Engine) Count

func (e *Engine) Count() int

Count returns how many entities there are.

It is not the range to iterate. A deleted entity leaves its slot behind, so the arrays can be longer than the count — use Engine.Slots for the range and Engine.Live to skip the holes.

func (*Engine) Delete

func (e *Engine) Delete(i int)

Delete removes entity i and keeps its slot for the next Engine.Add. No other index moves, so a game — or a packet — may hold an index across a delete of somebody else.

The slot is emptied rather than compacted away: it becomes a 0x0 invisible entity with no state bits, which the update and the draw skip on the tests they already do. Deleting the same entity twice does nothing the second time, so a game holding an index does not have to track whether it already used it. CamTarget and InputTarget are cleared to -1 when they point here.

It panics when i is outside the arrays, which is a programmer error.

func (*Engine) DrawPos added in v0.3.0

func (e *Engine) DrawPos(i int) (x, y float64)

DrawPos returns where entity i is drawn this frame: its own position, blended back toward where it stood when the current tick began.

The simulation moves in whole ticks and a frame lands between two of them. Reading e.X[i] straight into the draw therefore shows the world in tick-sized steps — a frame that carries two ticks or none is a pop of about two pixels at the default speed, which is a third of the frames on a 144 Hz display. Blending is what spends the frame's own position on it.

It costs up to one tick of lag, because it draws between two positions the simulation has already been rather than guessing at one it has not. A guess is wrong every time something turns around, and a game full of wrong guesses is worse than a game a sixtieth of a second behind.

It is the drawn position and nothing else. Collision, the camera's bounds and every rule read e.X[i], which is where the simulation actually is. A game drawing its own thing over an entity — a health bar, a name — wants this one, or it trails the sprite it is labelling by a tick.

It panics when i is outside the arrays, which is a programmer error.

func (*Engine) Failed

func (e *Engine) Failed() []string

Failed returns the asset paths that did not load. It is empty when everything arrived. Check it once Engine.Loading turns false: a missing file used to leave the engine on "Loading..." for ever.

func (*Engine) HasCollision

func (e *Engine) HasCollision(i, j int) bool

HasCollision reports whether the hit boxes of entities i and j overlap.

func (*Engine) HitStop

func (e *Engine) HitStop(duration, scale float64)

HitStop slows the world to scale for duration milliseconds. A scale of 0 is a freeze; 0.1 is slow motion. The longer of the running stop and this one wins, so a second hit during a freeze cannot cut it short. A duration of 0 or less cancels the stop that is running.

func (*Engine) IDOf

func (e *Engine) IDOf(i int) ID

IDOf returns the ID of entity i, or the zero ID when i holds no entity.

func (*Engine) Impact

func (e *Engine) Impact(i ImpactSettings)

Impact applies one hit's worth of feel: a hit stop and a shake, in the strength the game asked for.

e.Impact(e.Feel.Heavy)

The numbers live in Settings, so the call site carries none and the menu can change how every heavy hit in the game lands.

Example

A hit is a named strength, not a number at the call site. Change what Heavy means and every finisher in the game changes with it.

package main

import (
	"fmt"

	"github.com/andygeiss/wisp-engine"
)

func main() {
	e := wisp.New(wisp.Config{})
	e.Impact(e.Feel.Heavy)

	fmt.Println(e.Stopped(), e.Shaking())
}
Output:
true true

func (*Engine) Index

func (e *Engine) Index(id ID) int

Index returns the index id names, or -1 when that entity is gone — deleted, or cleared by Engine.Reset. A slot reused since is gone too, which is the whole reason an ID is not just an index.

func (*Engine) Live

func (e *Engine) Live(i int) bool

Live reports whether slot i holds an entity. It is false for a deleted slot and for an index outside the arrays, so it is safe to ask about anything.

func (*Engine) LoadImages

func (e *Engine) LoadImages(paths ...string)

LoadImages starts loading each image. Engine.Loading stays true until every image and sound has arrived or failed.

func (*Engine) LoadSounds

func (e *Engine) LoadSounds(paths ...string)

LoadSounds starts loading each sound. The index of a sound is its position in this call, which is what Engine.PlaySound takes.

func (*Engine) Loading

func (e *Engine) Loading() bool

Loading reports whether images and sounds are still arriving. A game shows its own loading screen while it is true; the engine draws one too.

func (*Engine) Menu

func (e *Engine) Menu() bool

Menu reports whether the tuning menu is open. A game that draws its own overlay can use it to get out of the way.

func (*Engine) Move added in v0.3.0

func (e *Engine) Move(i int, dx, dy float64)

Move steers entity i by an axis. dx and dy are each negative, zero or positive, and only the sign counts: a value of 100 pushes exactly as hard as a value of 1, so an axis that arrived from somewhere untrusted needs no clamping first. It sets the move bits the next Engine.Tick moves the entity by, and turns the entity to face the way it is going, one of Engine.Facing ways — unless an action outside the pose bits, an attack or a dash, is running, so a sprite does not turn mid-swing.

The engine calls it every tick for Engine.InputTarget, with the movement keys. A server calls it for every player with the axis that player sent, and a game calls it to drive anything else the way the keys drive the player. The bits hold until the next call, so once a frame is enough.

It panics when i is outside the arrays, which is a programmer error.

func (*Engine) PauseMusic

func (e *Engine) PauseMusic()

PauseMusic stops the music where it is. Engine.PlayMusic picks it up from there, which is what a pause menu wants.

func (*Engine) Place added in v0.3.0

func (e *Engine) Place(i int, x, y float64)

Place puts entity i at (x, y) without drawing the way there.

A sprite that jumps — a respawn, a teleport, the far side of a door — is a jump the draw cannot tell from a very fast tick, so Engine.DrawPos would smear it across the gap for a tick. Writing e.X[i] moves the entity; this moves the entity and forgets where it was.

It panics when i is outside the arrays, which is a programmer error.

func (*Engine) Play

func (e *Engine) Play(i int, name string) bool

Play starts the animation named name on entity i, from its first frame, and reports whether the sheet had it.

It writes the tag's index into Engine.ImageRow, which is the same place RowForState writes a row: an animation is an animation, whether a name or a state bit picked it. Nothing happens and it returns false when the entity's image has no sheet, or the sheet has no such tag — a game may then fall back to a row it does know.

Play does not make the entity animate. Set StateAnimated, and StateAnimatedLoop for one that repeats, the way the grid path does.

func (*Engine) PlayMusic

func (e *Engine) PlayMusic(index int, volume float64)

PlayMusic starts sound index looping, or does nothing if it is already playing, so a game may call it every frame.

func (*Engine) PlaySound

func (e *Engine) PlaySound(index int, volume float64)

PlaySound plays sound index once. A re-trigger takes the next voice, so hitting three monsters in one frame is three sounds rather than one.

func (*Engine) Playing

func (e *Engine) Playing(i int) string

Playing returns the name of the animation entity i is on, or "" when its image has no sheet or the row is not a tag.

func (*Engine) Rect

func (e *Engine) Rect(x, y, w, h float64, color string)

Rect fills a rectangle in screen space. Call it from Engine.RenderUI; a menu needs a backdrop, and the engine draws no shapes of its own.

func (*Engine) Reset

func (e *Engine) Reset()

Reset removes every entity, keeping the capacity the slices have grown to. A game calls it once per scene. It leaves the settings and the camera alone.

Every ID handed out before it stops resolving, so a stale one cannot come back to life as an entity in the new scene.

func (*Engine) Run

func (e *Engine) Run(update func(dt float64))

Run creates the canvas, starts the frame loop, and blocks until Engine.Stop is called. update runs once per frame with the time the world moved by, before the engine moves entities and draws.

It blocks so a game's main is one call: keeping the Go runtime alive used to be the caller's job and is the engine's now.

func (*Engine) SetWorldSize

func (e *Engine) SetWorldSize(width, height float64)

SetWorldSize sets the area the camera may show. A world smaller than the canvas is centred. New sets it to the canvas size.

func (*Engine) Shake

func (e *Engine) Shake(magnitude, duration float64)

Shake throws the camera up to magnitude pixels away for duration milliseconds, fading out along ShakeSettings.Decay. A stronger shake replaces a weaker one; a weaker one is ignored while one is running. A duration of 0 or less cancels the shake that is running.

func (*Engine) Shaking

func (e *Engine) Shaking() bool

Shaking reports whether a shake is still running.

func (*Engine) Slots

func (e *Engine) Slots() int

Slots returns the length of every entity array, which is the range to iterate:

for i := range e.Slots() {
	if !e.Live(i) {
		continue
	}
	...
}

It only ever grows while entities exist, because a deleted entity's slot is kept for the next one. Engine.Reset returns it to zero.

func (*Engine) Stats

func (e *Engine) Stats() Stats

Stats returns what the last two seconds cost.

func (*Engine) Step

func (e *Engine) Step(elapsed float64)

Step advances the world by one frame.

elapsed is the real milliseconds since the last frame. The game's update, given to Engine.Run, is called with the time the world actually moved by, which is 0 while the world is paused or held by a hit stop — so a game that moves things by dt stops for free, and a game that reads keys still reads them.

The simulation does not run on that time. Step spends it on whole Engine.Tick calls of a fixed length and carries the remainder, so how far anything moves stops depending on how long the frame took. Everything the player only looks at — the camera, the shake, the animations, the menu — stays on the frame, because it has nothing to agree with anybody about.

Engine.Run calls Step once per frame. A test calls it directly.

Example

Step advances the world by one frame, spending the frame's time on whole simulation ticks. A hit stop holds the world, so a game that moves things by dt stops for free.

package main

import (
	"fmt"

	"github.com/andygeiss/wisp-engine"
)

func main() {
	e := wisp.New(wisp.Config{})
	i := e.Add(wisp.Sprite{
		Height: 32, State: wisp.StateVisible | wisp.StateMoveRight, Width: 32,
	})

	// A tick is 1000/TickRate ms — 16.6 at the default 60 — so a 20 ms frame
	// pays for one and carries the remainder into the next.
	e.Step(20)
	moved := e.X[i]

	e.Impact(e.Feel.Heavy)
	e.Step(20)

	fmt.Println(moved > 0, e.X[i] == moved)
}
Output:
true true

func (*Engine) Stop

func (e *Engine) Stop()

Stop ends the frame loop and releases the callbacks the browser holds. It is safe to call more than once, and safe to call from update.

func (*Engine) StopMusic

func (e *Engine) StopMusic()

StopMusic stops the music and rewinds it.

func (*Engine) Stopped

func (e *Engine) Stopped() bool

Stopped reports whether a hit stop is still holding the world.

func (*Engine) Text

func (e *Engine) Text(x, y float64, text, color, font, align string)

Text draws text in screen space. Call it from Engine.RenderUI.

func (*Engine) Tick

func (e *Engine) Tick(dt float64)

Tick advances the simulation by exactly dt milliseconds: the game's Engine.Simulate first, then the engine's own movement.

Engine.Step calls it as often as the frame's time paid for, which is what a game wants. Call it directly to run the simulation on a clock that is not this machine's — a server's tick, or a replay of one whose input you already have — which is why it takes the step instead of reading a clock.

type FeelSettings

type FeelSettings struct {
	Heavy  ImpactSettings
	Light  ImpactSettings
	Medium ImpactSettings
	Shake  ShakeSettings
}

FeelSettings is the impact of a hit, in three strengths a game picks from, plus the shake curve all three share.

A game says which strength something is worth — e.Impact(e.Feel.Heavy) — and never writes a number at the call site. The numbers live here, where the menu can reach them.

type Frame

type Frame struct {
	// Duration is how long this frame shows, in milliseconds.
	Duration float64
	// H is the frame's height on the image.
	H float64
	// W is the frame's width on the image.
	W float64
	// X is the frame's left edge on the image.
	X float64
	// Y is the frame's top edge on the image.
	Y float64
}

Frame is one frame: where it is on the image, and how long it shows.

The rectangle is in image pixels, which is why a packed or trimmed sheet works without the engine knowing it is one. Duration is milliseconds.

type ID

type ID uint64

ID names one entity for as long as it lives, and never names another one.

An index is a position in the entity arrays. It is stable — a delete leaves its slot where it is — but it is not unique for ever, because the next Add reuses the slot. An ID carries the generation of the entity that filled it as well, so an ID left over from the entity before resolves to nothing rather than to a stranger.

Hold an ID when the entity has to survive being deleted by somebody else: across frames, between a game and its server, inside a packet. Do the work through the index it resolves to, which is what every array is keyed by, and resolve once rather than in a loop.

The zero ID names nothing.

type ImpactSettings

type ImpactSettings struct {
	// HitStopDuration is how long the world holds still, in milliseconds.
	HitStopDuration float64
	// HitStopScale is how fast time runs while it holds: 0 is a freeze, 0.1 is
	// slow motion, 1 does nothing.
	HitStopScale float64
	// ShakeDuration is how long the shake lasts, in milliseconds.
	ShakeDuration float64
	// ShakeMagnitude is how far the camera may be thrown, in pixels.
	ShakeMagnitude float64
}

ImpactSettings is one hit's worth of feel: how long time stops, and how hard the screen shakes.

type Input

type Input struct {
	// Alt, Ctrl, Meta and Shift are the modifier keys, as of the last event.
	Alt   bool
	Ctrl  bool
	Meta  bool
	Shift bool

	// MouseDown is true while a mouse button is held on the canvas.
	MouseDown bool
	// MouseX and MouseY are the pointer in world coordinates.
	MouseX float64
	MouseY float64

	// Started turns true on the first key or click and stays true. Browsers
	// refuse to play sound before it, so a game waits for it.
	Started bool
	// contains filtered or unexported fields
}

Input is the keyboard and mouse as of this frame.

A key is named the way the browser names it: one lower-case character for a letter or digit ("w", "3"), and the DOM name for everything else ("ArrowLeft", "Enter", "Escape", "Tab"). Space is " ".

Input.Down answers "is it held", Input.JustPressed answers "did it go down this frame". The second one is why a game no longer has to clear a key by hand after acting on it.

func (*Input) AnyDown

func (in *Input) AnyDown(keys ...string) bool

AnyDown reports whether any of the keys is held.

func (*Input) Down

func (in *Input) Down(key string) bool

Down reports whether key is held right now.

It answers false for every key while the tuning menu is open. The menu takes the keyboard so a game cannot be steered underneath it; the game's own logic keeps running, which is what lets a camera be judged while it is tuned.

func (*Input) JustPressed

func (in *Input) JustPressed(key string) bool

JustPressed reports whether key went down during this frame. It is true for exactly one frame, so a game never has to clear a key by hand.

func (*Input) JustReleased

func (in *Input) JustReleased(key string) bool

JustReleased reports whether key came up during this frame.

func (*Input) Key

func (in *Input) Key(key string, isDown bool)

Key records a press or a release. The browser half calls it; a test calls it to drive the engine without a browser.

func (*Input) MoveAxis added in v0.3.0

func (in *Input) MoveAxis() (dx, dy float64)

MoveAxis returns the direction the movement keys are asking for, each component -1, 0 or 1. WASD and the arrow keys both work, so a player who reaches for either is right. It answers 0, 0 while the tuning menu is open, like every other read of the keys.

Engine.Move takes it, which is how the engine drives Engine.InputTarget. A client sends it to a server as what the player is trying to do.

func (*Input) SetModifiers

func (in *Input) SetModifiers(alt, ctrl, meta, shift bool)

SetModifiers records the modifier keys an event arrived with. The browser half calls it; a test calls it to drive the menu's coarse and fine steps.

type RenderSettings

type RenderSettings struct {
	// Background is the CSS colour the canvas is cleared to. Empty clears to
	// transparent and lets the page show through.
	Background string
	// Interpolate draws each entity between the tick that has run and the one
	// that has not, so a frame carrying two ticks or none does not show a pop.
	// It costs the draw a little arithmetic and shows the world up to one tick
	// in the past. Off, every sprite sits exactly where the simulation put it,
	// and moves in the steps the simulation moved it in.
	//
	// It smooths what [Engine.Simulate] moves. An entity moved from the update
	// [Engine.Run] was given is already frame-accurate, and blending drags it
	// backwards instead — which is one more reason movement belongs in a tick.
	Interpolate bool
	// PixelSnap rounds sprite positions to whole pixels.
	PixelSnap bool
	// Smoothing turns on the canvas's own interpolation. Pixel art wants it
	// off; leaving it on is what makes a scaled sprite look blurry.
	Smoothing bool
}

RenderSettings decides how the picture is drawn.

type Settings

type Settings struct {
	Animation AnimationSettings
	Audio     AudioSettings
	Camera    CameraSettings
	Debug     DebugSettings
	Feel      FeelSettings
	Render    RenderSettings
	Time      TimeSettings
	World     WorldSettings
}

Settings is every knob that decides how a game feels. One value, one place: Defaults fills it, the menu edits it while the game runs, and Settings.GoLiteral hands it back as Go source to paste into your game.

Every field is a number or a bool, which is what lets one menu edit all of them without knowing what any of them mean.

func Defaults

func Defaults() Settings

Defaults returns the settings a new engine starts with. They are the numbers the game in game-jam-template was balanced around, so a game that changes nothing plays the way that one does.

func (Settings) GoLiteral

func (s Settings) GoLiteral() string

GoLiteral returns the settings as Go source: a complete wisp.Settings composite literal, aligned the way gofmt would align it, ready to paste into a game.

Every field is there, so pasting it over an old one leaves nothing behind — a partial literal would silently zero every knob it omitted. Settings.Render Background is the one field it does not print, because it is a colour rather than a number and the menu cannot edit it.

Example

GoLiteral is what the tuning menu's copy key puts on the clipboard: the whole settings value as Go source, ready to paste into a game.

package main

import (
	"fmt"
	"strings"

	"github.com/andygeiss/wisp-engine"
)

func main() {
	s := wisp.Defaults()
	s.Feel.Heavy.ShakeMagnitude = 14

	src := s.GoLiteral()
	for line := range strings.Lines(src) {
		if strings.Contains(line, "ShakeMagnitude") && strings.Contains(line, "14") {
			fmt.Print(strings.TrimSpace(line))
			break
		}
	}
}
Output:
ShakeMagnitude:  14,

func (Settings) MarshalText

func (s Settings) MarshalText() ([]byte, error)

MarshalText writes the settings as one name=value line per knob. It is what the browser remembers between reloads.

It is not JSON: encoding/json needs reflection, and reflection is what makes a TinyGo binary large enough to notice.

func (*Settings) UnmarshalText

func (s *Settings) UnmarshalText(text []byte) error

UnmarshalText reads what MarshalText wrote, over the top of whatever the settings already hold.

It never fails. A name it does not know is skipped, a name that is missing keeps its current value, and every value is clamped to its own range — so an old save, a hand-edited one, or a Time.Scale of 1e308 typed into the browser's console cannot leave a game unplayable.

type ShakeSettings

type ShakeSettings struct {
	// Decay is the shape of the fade: 1 falls off in a straight line, 2 drops
	// fast then lingers, 0.5 holds then stops.
	Decay float64
	// Frequency is how many times a second the camera is thrown somewhere new.
	// Below the frame rate it reads as a rumble, above it as noise.
	Frequency float64
	// IgnoresHitStop keeps the shake moving while a hit stop holds the world
	// still, so a freeze still feels violent.
	IgnoresHitStop bool
	// Snap rounds the shake to whole pixels, like the camera.
	Snap bool
}

ShakeSettings shapes every shake, whatever set it off.

type Sheet

type Sheet struct {
	// Frames is every frame on the image, in the order the export lists them.
	Frames []Frame
	// H is the image's height, as the export measured it. The parser checks
	// every frame against it, so a rectangle can never point off the image.
	H float64
	// Tags is the named animations. [Engine.ImageRow] indexes it.
	Tags []Tag
	// W is the image's width, as the export measured it.
	W float64
}

Sheet is a parsed sprite sheet: every frame on one image, and the named animations over them.

Attach one per image through Engine.Sheets. The zero Sheet means the grid convention, so an image nobody described keeps drawing the way it always did.

ParseSheet and GridSheet both return a Sheet the draw can trust, and they are the two ways to get one. A Sheet built by hand must hold up the same promise — every tag's From and To index Frames — because the draw reads a frame per entity per pass and checking it there would be a check per sprite per frame. Breaking it panics, the way an out-of-range index into any of the entity arrays does.

func GridSheet

func GridSheet(cols, rows int, w, h, ms float64) Sheet

GridSheet builds the grid convention as a Sheet: cols frames of w by h pixels per row, rows rows, every frame lasting ms milliseconds.

It exists to prove the two descriptions are one. A game on the grid does not need it — leaving Engine.Sheets empty draws the same pixels — but a game moving to Aseprite can build the sheet it already had and compare.

The tags are the rows, in order and unnamed, because a grid says nothing about what a row is for. Naming them is what the export adds.

func ParseSheet

func ParseSheet(data []byte) (Sheet, error)

ParseSheet reads an Aseprite sprite sheet export and returns the Sheet it describes.

Produce the input with Aseprite's own exporter:

aseprite -b hero.aseprite --sheet hero.png --sheet-type rows \
  --data hero.json --format json-array --list-tags

The format must be json-array and the tags must be listed; without --list-tags an export carries no animations and every frame is one nameless run. Fields ParseSheet does not know are skipped, so a newer Aseprite is not a breaking change.

The returned Sheet is checked, not merely parsed: every frame's rectangle is inside the image, every tag names frames that exist, and every duration is a real number of milliseconds. A game may attach the result to Engine.Sheets without looking at it.

Example
package main

import (
	"fmt"

	"github.com/andygeiss/wisp-engine"
)

func main() {
	// In a game this is the file Aseprite exported, fetched next to the PNG.
	const export = `{
	  "frames": [
	    {"frame": {"x": 0, "y": 0, "w": 32, "h": 32}, "duration": 100},
	    {"frame": {"x": 32, "y": 0, "w": 32, "h": 32}, "duration": 100}
	  ],
	  "meta": {
	    "size": {"w": 64, "h": 32},
	    "frameTags": [{"name": "walk", "from": 0, "to": 1, "direction": "forward"}]
	  }
	}`

	sheet, err := wisp.ParseSheet([]byte(export))
	if err != nil {
		panic(err)
	}

	e := wisp.New(wisp.Config{})
	e.Sheets = []wisp.Sheet{sheet}
	hero := e.Add(wisp.Sprite{
		Height: 32, Width: 32,
		State: wisp.StateAnimated | wisp.StateAnimatedLoop | wisp.StateVisible,
	})
	e.Play(hero, "walk")

	fmt.Println(len(sheet.Frames), sheet.Tags[0].Name, e.Playing(hero))
}
Output:
2 walk walk

func (*Sheet) Lookup

func (s *Sheet) Lookup(name string) int

Lookup returns the index of the tag named name, or -1 when the sheet has no such animation.

It is a scan, because a sheet holds a handful of tags and a map would cost more to build than it ever saves. Resolve a name once and keep the index if a game plays the same animation every frame.

type Sprite

type Sprite struct {
	// Alpha is the opacity, 0 to 1. Because an entity added invisible is
	// almost always a mistake, 0 here means 1; set e.Alpha[i] afterwards for
	// an entity that really should start transparent.
	Alpha float64
	// Column is the sheet column the sprite starts at. The animation frame is
	// added to it, so one sheet holds every animation of every sprite.
	Column int
	// Height of the sprite in pixels. It is also the sheet's cell height.
	Height float64
	// Image is which loaded image, in [Engine.LoadImages] order.
	Image int
	// Row is the sheet row, which is one animation.
	Row int
	// ScreenSpace draws the sprite in canvas pixels, past the camera. Use it
	// for a HUD.
	ScreenSpace bool
	// SpeedFactor multiplies [WorldSettings.Speed] for this entity. 0 means 1.
	SpeedFactor float64
	// State is the entity's state bits.
	State uint64
	// Width of the sprite in pixels. It is also the sheet's cell width.
	Width float64
	// X is the world x of the sprite's centre.
	X float64
	// Y is the world y of the sprite's centre.
	Y float64
	// Z is the draw layer. Lower draws first.
	Z int
}

Sprite describes an entity at the moment it is added. Its zero value is a 0x0 invisible entity at the origin, so fill in what matters and leave the rest.

type Stats

type Stats struct {
	// Budget is one frame's worth of milliseconds, measured from the display
	// rather than assumed: 16.7 at 60 Hz, 8.3 at 120.
	Budget float64
	// DrawMs is how long the last frame spent drawing.
	DrawMs float64
	// Drawn is how many sprites the last frame actually painted, after the
	// camera threw the off-screen ones away.
	Drawn int
	// Dropped counts frames that took more than one and a half budgets.
	Dropped int
	// Entities is how many entities exist, drawn or not.
	Entities int
	// FPS is the frame rate over the window.
	FPS float64
	// FrameMs is the last frame, wall to wall.
	FrameMs float64
	// GoHeapBytes is what the Go heap holds. Under TinyGo this is a subset of
	// what ordinary Go reports; the overlay says so rather than guessing.
	GoHeapBytes uint64
	// GPU names the graphics card, or says why it cannot.
	GPU string
	// JSHeapBytes is the JavaScript heap. Chromium only; 0 elsewhere.
	JSHeapBytes uint64
	// Load is the share of the frame budget the main thread spent working,
	// 0 to 1 and beyond. It is the closest honest thing to processor load.
	Load float64
	// Late is the frame time, in milliseconds, at which a frame counts as
	// missed: [Stats.Budget] times a tolerance. Compare a percentile against
	// this rather than against Budget, which half of a healthy window exceeds
	// by construction. 0 when the budget is not known yet.
	Late float64
	// LongFrames counts frames the browser itself reported as too long.
	// Chromium only; -1 where the browser does not offer it.
	LongFrames int
	// P50Ms and P99Ms are the middle and the near-worst frame in the window.
	// P99 is the number that decides whether a game feels smooth; an average
	// hides exactly the frames a player notices.
	P50Ms float64
	P99Ms float64
	// SortMs is how long the last frame spent putting the entities in draw
	// order. It is separate from [Stats.DrawMs] because the two answer
	// different questions: the sort is a function of how many entities exist
	// and a different renderer would not change it, while the draw is what a
	// different renderer would replace.
	SortMs float64
	// UpdateMs is how long the last frame spent on the world.
	UpdateMs float64
	// WasmBytes is the module's own size, which is the number this engine
	// exists to keep small.
	WasmBytes int
	// WasmMemoryBytes is the WebAssembly linear memory the module holds. It
	// only ever grows, and it grows in 64 KiB pages.
	WasmMemoryBytes uint64
}

Stats is what the last two seconds cost. Engine.Stats returns it, and the overlay draws it.

Three numbers a person asks for are missing, and they are missing because no browser offers them: processor load, graphics-card load, and the graphics card's own frame time. What is here instead is the honest neighbour of each — how much of the frame budget the main thread spent, and how much work was asked of the renderer.

type Tag

type Tag struct {
	// Direction is the play order.
	Direction Direction
	// From is the first frame of the animation.
	From int
	// Name is what the animation is called in Aseprite. [Engine.Play] takes
	// this. A grid sheet has no names, so its tags carry an empty one.
	Name string
	// To is the last frame of the animation, and it is included.
	To int
}

Tag is one named animation: a run of frames and the order to play them in.

From and To are both inclusive indices into Sheet.Frames, which is how Aseprite writes them. A tag of one frame has From equal to To.

type Tilemap

type Tilemap struct {
	// Height of one tile in pixels.
	Height float64
	// Image is which loaded image the tileset comes from.
	Image int
	// Rows and Cols are the shape of the map.
	Cols int
	Rows int
	// TilesetCols and TilesetRows are the shape of the tileset image, in
	// tiles. A tile index outside them leaves the cell empty.
	TilesetCols int
	TilesetRows int
	// Tiles is one tile index per cell, row by row. A negative index leaves
	// the cell empty, and a short slice fills what it can.
	Tiles []int
	// Width of one tile in pixels.
	Width float64
	// Z is the draw layer every tile lands on. A floor belongs below the
	// entities that stand on it: inside one layer the draw order is by
	// baseline, so a tile sharing a layer with an actor draws over it
	// whenever the tile sits lower down the screen.
	Z int
}

Tilemap describes a grid of tiles to add as entities, one per cell.

type TimeSettings

type TimeSettings struct {
	// FullscreenDebounce is the shortest gap, in milliseconds, between two
	// fullscreen toggles. Browsers refuse a burst of requests.
	FullscreenDebounce float64
	// MaxStep caps one frame's elapsed time, in milliseconds. Without it a tab
	// left in the background teleports everything when it comes back.
	MaxStep float64
	// Scale multiplies every frame's elapsed time. Below 1 is slow motion.
	Scale float64
	// TickRate is how many times a second the simulation runs, apart from the
	// frame rate. A higher rate costs more and reacts sooner; a lower one is
	// cheaper and coarser.
	//
	// Two machines simulating one world have to agree on it, so a networked
	// game sets it from the server rather than from a saved menu.
	TickRate float64
}

TimeSettings decides how fast the world runs.

type WorldSettings

type WorldSettings struct {
	// HitBoxMargin shrinks every hit box by this many pixels on all four
	// sides, so sprites have to visibly overlap before they collide.
	HitBoxMargin float64
	// Speed is the base movement speed, in pixels per millisecond.
	Speed float64
}

WorldSettings decides how entities move and touch.

Directories

Path Synopsis
cmd
art command
The art: what PixelLab generated, downloaded by ID and reshaped for the engine.
The art: what PixelLab generated, downloaded by ID and reshaped for the engine.
lab command
The lab: one scene that exists to be measured and tuned.
The lab: one scene that exists to be measured and tuned.
serve command
Command serve hosts the lab: one page and the web tree, under the same security headers and the same cache contract a deployed game gets, and the world the lab plays in, over GET /ws.
Command serve hosts the lab: one page and the web tree, under the same security headers and the same cache contract a deployed game gets, and the world the lab plays in, over GET /ws.
internal
lab
Package lab is what the lab's client and its server agree on: the world's size, the art and its sheets, the three skills and their cooldowns, the bouncers and the bounce that moves them, the floor and the edge of the world.
Package lab is what the lab's client and its server agree on: the world's size, the art and its sheets, the three skills and their cooldowns, the bouncers and the bounce that moves them, the floor and the edge of the world.
pixellab
Package pixellab reads what PixelLab exports: the layout JSON beside a character's spritesheet, and the metadata JSON beside a tileset's sheet.
Package pixellab reads what PixelLab exports: the layout JSON beside a character's spritesheet, and the metadata JSON beside a tileset's sheet.
replica
Package replica keeps a copy of the server's world in an engine: the server's slots mapped to the engine's indices, the server's messages in the order they arrived, and the depth rule that decides how many of them one tick applies.
Package replica keeps a copy of the server's world in an engine: the server's slots mapped to the engine's indices, the server's messages in the order they arrived, and the depth rule that decides how many of them one tick applies.
wire
Package wire is the lab's network protocol: the messages a client and the server exchange, and their bytes.
Package wire is the lab's network protocol: the messages a client and the server exchange, and their bytes.
ws
Package ws is RFC 6455 for the lab's server: Accept turns an HTTP request into a WebSocket, Dial is the client half for tests and bots, and a Conn moves binary messages over either.
Package ws is RFC 6455 for the lab's server: Accept turns an HTTP request into a WebSocket, Dial is the client half for tests and bots, and a Conn moves binary messages over either.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL