Getting Started Download Markdown

Draw a 2D game

Use sprites for image artwork, vectors for shapes, and text for instructions and scores. This example draws all three and lets you collect a circle with keyboard movement.

Start with the 2d starter, which includes the controllers-topdown plugin:

karty new --yes --template 2d my-game

Keep the package's archive URL and checksum in karty.toml. The first build restores its source under ignored .karty/packages/controllers-topdown/. For an existing project, run karty package import controllers-topdown@0.0.1 first; see reusable packages.

Add a sprite

Create a project from the guide home. Download player.png and save it as assets/textures/player.png. The CLI imports that image and generates assets.TexturePlayer on the next build. Use the generated ID when creating the sprite.

Draw and play

Keep the project resolution at 960 by 540. Replace src/main.go with this scene. Change the imports if your project has a different name.

package main

import (
    "github.com/karty-game/controllers-topdown"
    "example.com/my-game/.karty/assets"
    "example.com/my-game/.karty/engine"
)

const speed = 180 // logical pixels per second

type Game struct {
    engine.Game
    player *engine.Sprite2D
    goal *engine.Shape2D
    label *engine.Text2D
    controller *controllers.Planar
    won bool
}

//go:wasmexport karty_register
func main() {
    options := controllers.DefaultTopDown2DOptions()
    options.Speed, options.RunSpeed = speed, speed
    controller, err := controllers.NewTopDown2D(options)
    if err != nil { panic(err) }
    game := &Game{controller: controller}
    engine.Run(engine.Hooks{
        OnStart: game.start,
        OnUpdate: game.update,
        OnKeyDown: func(key engine.Key) { game.key(key, true) },
        OnKeyUp: func(key engine.Key) { game.key(key, false) },
        OnInputReset: game.controller.Reset,
    })
}

func (game *Game) start() {
    // Draw the background behind the sprite, goal, and text.
    background := game.NewShape2D(engine.Rectangle(960, 540), engine.NewVec2(0, 0))
    background.SetFill(engine.RGBA(12, 21, 35, 255))
    background.SetLayer(-1)
    game.player = game.NewSprite2D(assets.TexturePlayer, engine.NewVec2(160, 280))
    game.goal = game.NewShape2D(engine.Circle(18), engine.NewVec2(760, 350))
    game.goal.SetFill(engine.RGBA(255, 200, 99, 255))
    game.label = game.NewText2D("WASD / arrows: collect the gold circle", engine.NewVec2(32, 24))
    game.label.SetFontSize(20)
    game.label.SetLayer(1)
}

func (game *Game) key(key engine.Key, down bool) {
    if key == engine.KeyR && down {
        game.controller.Reset()
        game.won = false
        game.player.Translate(160-game.player.Position().X, 280-game.player.Position().Y)
        game.goal.SetAlpha(1)
        game.label.SetText("WASD / arrows: collect the gold circle")
    }
    if control, ok := movementKey(key); ok { game.controller.Key(control, down) }
}

func (game *Game) update(engine.Frame) {
    if game.won { return }
    delta := game.controller.Step(controllers.Vec2{X: float64(game.player.Position().X), Y: float64(game.player.Position().Y)})
    dx, dy := float32(delta.X), float32(delta.Y)
    if dx != 0 || dy != 0 {
        x := min(max(game.player.Position().X+dx, 16), 944)
        y := min(max(game.player.Position().Y+dy, 80), 524)
        game.player.Translate(x-game.player.Position().X, y-game.player.Position().Y)
    }
    // Our game collects the goal within 32 pixels of its centre.
    dx = game.player.Position().X - game.goal.Position().X
    dy = game.player.Position().Y - game.goal.Position().Y
    if dx*dx + dy*dy < 32*32 {
        game.won = true
        game.goal.SetAlpha(0)
        game.label.SetText("Collected! Press R to restart")
    }
}
func movementKey(key engine.Key) (controllers.Control, bool) {
	switch key {
	case engine.KeyW: return controllers.Forward, true
	case engine.KeyS: return controllers.Backward, true
	case engine.KeyA: return controllers.Left, true
	case engine.KeyD: return controllers.Right, true
	case engine.KeyArrowUp: return controllers.ForwardAlt, true
	case engine.KeyArrowDown: return controllers.BackwardAlt, true
	case engine.KeyLeft: return controllers.LeftAlt, true
	case engine.KeyRight: return controllers.RightAlt, true
	case engine.KeyShiftLeft: return controllers.RunLeft, true
	case engine.KeyShiftRight: return controllers.RunRight, true
	}
	return 0, false
}

Run karty dev. Move with WASD or the arrow keys, reach the circle, then press R to try again. OnInputReset clears held keys when the game loses focus. The preset normalizes diagonal input and converts speed = 180 pixels per second into three pixels per fixed 60 Hz update. Camera following, pointer targets and zoom are available on the same preset when your game needs them.

Change the drawing

Positions use canvas pixels. The upper left is (0, 0); X grows right and Y grows down. Sprite and circle positions mark their centres. Rectangle and text positions mark their upper-left corners.

NewSprite2D draws the imported image. NewShape2D draws the rectangle and circle without image files. NewText2D draws the instruction, and SetText changes it when the goal is collected. Higher layers draw above lower ones.

Try another PNG, change the circle's radius or color, or move the goal. Use setters such as Translate, SetFill, and SetText to update existing entities instead of creating them again each frame.

A 2D sprite, vector goal, and text drawn by the example.
A 2D sprite, vector goal, and text drawn by the example.

Add feedback and share

Sound and video add effects or music. Build and share packages your game for the browser or desktop.

KartUI is optional when you need menus or reusable screen layouts. Its components work with both 2D and 3D games.