Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ asobi deploy prod game/
| `asobi logout` | Clear stored credentials |
| `asobi whoami` | Show current session info (including the active game) |
| `asobi init [dir]` | Scaffold a minimal working starter Lua game |
| `asobi init [dir] --template <name>` | Scaffold a genre starter (`arena`, `chat`, `turn-based`, `world`) or fetch a full demo (`defold`, `godot`, `unity`, `backend`) |
| `asobi games` | List your tenant's games (marks the active one) |
| `asobi use <slug>` | Set the active game (persisted in `~/.asobi`) |
| `asobi create <name> [--size xs\|s\|m\|l] [--game <slug>]` | Create an environment |
Expand Down Expand Up @@ -120,11 +121,29 @@ asobi deploy prod lua --game ctf # override for one command
```bash
asobi init mygame # scaffold lua/match.lua + README.md
cd mygame
asobi dev # run it locally on :8084 (Docker, no login)
asobi login
asobi use <game>
asobi deploy prod lua
```

Pick a genre starter instead of the plain scaffold - each writes a runnable
`lua/match.lua` shaped for that style of game:

```bash
asobi init mygame --template arena # real-time movement + combat
asobi init mygame --template chat # a real-time chat room
asobi init mygame --template turn-based # server-enforced turn order
asobi init mygame --template world # a persistent shared world
```

To see a full client + backend project instead, use a demo template
(`defold`, `godot`, `unity`, or `backend`) - these fetch a pinned repo:

```bash
asobi init mygame --template defold
```

## Ephemeral deploys (CI)

For CI integration tests, use `--ephemeral` to create a fresh isolated env
Expand Down
77 changes: 45 additions & 32 deletions cmd/asobi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ Usage:
asobi whoami Show current credential info
asobi init [dir] Scaffold a starter Lua game
asobi init [dir] --template <name>
Scaffold a runnable project:
defold|godot|unity (client demo) or backend (server only)
Scaffold a runnable project. Genre starter:
arena|chat|turn-based|world (server Lua), or a full
demo: defold|godot|unity (client) / backend (server)
asobi dev [--port N] [--dir <lua>]
Run a local backend for your Lua game (Docker)
asobi games List your tenant's games
Expand Down Expand Up @@ -700,7 +701,7 @@ func cmdUse() {

func cmdInit() {
args := os.Args[2:]
engine, args := extractFlag(args, "--template")
tmpl, args := extractFlag(args, "--template")

dir := "."
for _, a := range args {
Expand All @@ -715,41 +716,27 @@ func cmdInit() {
}
}

if engine != "" {
t, ok := template.Get(engine)
if !ok {
fatal("unknown template %q; available: %s", engine, strings.Join(template.Names(), ", "))
// A genre name scaffolds server Lua locally; an engine name fetches a full
// demo repo. Everything else is an error listing both.
if tmpl != "" && !scaffold.IsGenre(tmpl) {
if _, ok := template.Get(tmpl); ok {
initFromTemplate(tmpl, dir)
return
}
fmt.Printf("Fetching the %s template into %s ...\n", t.Name, dir)
created, err := template.Fetch(engine, dir)
if err != nil {
fatal("init: %v", err)
}
fmt.Printf("Scaffolded the %s game template in %s\n", t.Name, dir)
for _, f := range created {
fmt.Printf(" created %s\n", f)
}
fmt.Println("\nNext steps:")
n := 1
step := func(s string) { fmt.Printf(" %d. %s\n", n, s); n++ }
if dir != "." {
step("cd " + dir)
}
if engine == "backend" {
step("docker compose up -d (asobi_lua image + Postgres on :8084)")
step("Open README.md - it covers the managed (asobi deploy) vs local (compose) paths and adding modes in lua/config.lua.")
} else {
step("Open README.md - it walks through starting the backend and opening the project in " + t.Name + ".")
}
return
fatal("unknown template %q; genre starters: %s; full demos: %s",
tmpl, strings.Join(scaffold.Genres(), ", "), strings.Join(template.Names(), ", "))
}

created, err := scaffold.Init(dir)
created, err := scaffold.Init(dir, tmpl)
if err != nil {
fatal("init: %v", err)
}

fmt.Printf("Scaffolded a starter Asobi game in %s\n", dir)
if tmpl == "" {
fmt.Printf("Scaffolded a starter Asobi game in %s\n", dir)
} else {
fmt.Printf("Scaffolded a %s starter in %s\n", tmpl, dir)
}
for _, f := range created {
fmt.Printf(" created %s\n", f)
}
Expand All @@ -759,13 +746,39 @@ func cmdInit() {
if dir != "." {
step("cd " + dir)
}
step("asobi login")
step("asobi dev (run it locally on http://localhost:8084 - Docker, no login)")
step("asobi login (then deploy to Asobi Cloud)")
step("asobi use <game> (list yours: asobi games)")
step("asobi create <env> (e.g. asobi create prod)")
step("asobi deploy <env> lua")
step("Connect a client - Defold quickstart: https://github.com/widgrensit/asobi-defold")
}

func initFromTemplate(engine, dir string) {
t, _ := template.Get(engine)
fmt.Printf("Fetching the %s template into %s ...\n", t.Name, dir)
created, err := template.Fetch(engine, dir)
if err != nil {
fatal("init: %v", err)
}
fmt.Printf("Scaffolded the %s game template in %s\n", t.Name, dir)
for _, f := range created {
fmt.Printf(" created %s\n", f)
}
fmt.Println("\nNext steps:")
n := 1
step := func(s string) { fmt.Printf(" %d. %s\n", n, s); n++ }
if dir != "." {
step("cd " + dir)
}
if engine == "backend" {
step("docker compose up -d (asobi_lua image + Postgres on :8084)")
step("Open README.md - it covers the managed (asobi deploy) vs local (compose) paths and adding modes in lua/config.lua.")
} else {
step("Open README.md - it walks through starting the backend and opening the project in " + t.Name + ".")
}
}

func cmdDev() {
args := os.Args[2:]
portStr, args := extractFlag(args, "--port")
Expand Down
187 changes: 70 additions & 117 deletions internal/scaffold/scaffold.go
Original file line number Diff line number Diff line change
@@ -1,148 +1,101 @@
// Package scaffold writes a starter server-Lua game into a directory from one of
// a few genre templates (a real-time arena, a chat room, a turn-based game, a
// persistent world, or a plain default). The templates are embedded, so a
// scaffold is offline and instant - unlike the engine demos in package template,
// which fetch a full client+backend repo.
package scaffold

import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)

const matchLua = `-- match.lua - a minimal server-authoritative Asobi game mode.
-- The engine loads this file as the "default" game mode and drives the
-- callbacks below. See https://github.com/widgrensit/asobi_lua for the
-- full game.* API (economy, leaderboards, storage, spatial, ...).
//go:embed all:templates
var templates embed.FS

-- Players needed before a match starts. Required.
match_size = 2
// defaultGenre is scaffolded when Init is called with an empty genre (plain
// `asobi init` with no --template).
const defaultGenre = "basic"

-- init builds the initial match state.
function init(config)
return { players = {}, tick_count = 0 }
end

-- join runs when a player enters the match.
function join(player_id, state)
state.players[player_id] = { x = 0, y = 0 }
return state
end

-- leave runs when a player disconnects.
function leave(player_id, state)
state.players[player_id] = nil
return state
end

-- handle_input applies a client message to the state.
function handle_input(player_id, input, state)
local player = state.players[player_id]
if player and input then
player.x = (player.x or 0) + (input.dx or 0)
player.y = (player.y or 0) + (input.dy or 0)
end
return state
end

-- tick advances the simulation (~10 times per second).
function tick(state)
state.tick_count = (state.tick_count or 0) + 1
return state
end

-- get_state returns the view sent to a given player.
function get_state(player_id, state)
return state
end
`

const readmeMd = `# My Asobi game

A minimal server-authoritative multiplayer game running on
[Asobi](https://github.com/widgrensit/asobi).

The game logic lives in ` + "`lua/match.lua`" + `. The engine loads it as the
` + "`default`" + ` game mode and drives the callbacks ` + "`init`, `join`, `leave`," + `
` + "`handle_input`, `tick` and `get_state`. `match_size`" + ` sets how many players
are needed to start a match.

## Run it locally

No account, no keys - runs the asobi_lua image + Postgres in Docker:

` + "```bash" + `
asobi dev
` + "```" + `

Edit ` + "`lua/`" + ` and it hot-reloads. API + WebSocket on http://localhost:8084.

## Deploy to Asobi Cloud

Managed hosting - this needs a login:

` + "```bash" + `
asobi login
asobi use <game> # list yours: asobi games
asobi create <env> # e.g. asobi create prod
asobi deploy <env> lua
` + "```" + `

## Multiple game modes

To run more than the ` + "`default`" + ` mode, add a ` + "`lua/config.lua`" + ` manifest
mapping mode names to files:

` + "```lua" + `
return {
default = "match.lua",
ranked = "ranked.lua"
// Genres returns the selectable genre names, sorted (e.g. arena, basic, chat,
// turn-based, world).
func Genres() []string {
entries, err := templates.ReadDir("templates")
if err != nil {
return nil
}
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names
}
` + "```" + `

Clients pick a mode with ` + "`matchmaker.add {mode = \"ranked\"}`" + `.

## A full backend example

Want the whole picture - Docker Compose, the mode manifest, all the
` + "`ASOBI_*`" + ` env vars in one runnable repo?

` + "```bash" + `
asobi init mybackend --template backend
` + "```" + `

## Connect a client

Defold quickstart: https://github.com/widgrensit/asobi-defold
`

type file struct {
path string
content string
// IsGenre reports whether name is a known genre template.
func IsGenre(name string) bool {
_, err := fs.Stat(templates, "templates/"+name)
return err == nil
}

// Init scaffolds a minimal working server-Lua game into dir. It returns the
// list of created file paths (relative to dir). It refuses to overwrite any
// existing target file, in particular an existing lua/match.lua.
func Init(dir string) ([]string, error) {
files := []file{
{filepath.Join("lua", "match.lua"), matchLua},
{"README.md", readmeMd},
// Init scaffolds the genre's starter game into dir. An empty genre means the
// default starter. It returns the created file paths (relative to dir) and
// refuses to overwrite any existing target file, in particular lua/match.lua.
func Init(dir, genre string) ([]string, error) {
if genre == "" {
genre = defaultGenre
}
root := "templates/" + genre
if !IsGenre(genre) {
return nil, fmt.Errorf("unknown template %q; available: %s", genre, strings.Join(Genres(), ", "))
}

type file struct{ rel, content string }
var files []file
err := fs.WalkDir(templates, root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
content, err := templates.ReadFile(p)
if err != nil {
return err
}
rel := filepath.FromSlash(strings.TrimPrefix(p, root+"/"))
files = append(files, file{rel, string(content)})
return nil
})
if err != nil {
return nil, fmt.Errorf("read %s template: %w", genre, err)
}

for _, f := range files {
full := filepath.Join(dir, f.path)
full := filepath.Join(dir, f.rel)
if _, err := os.Stat(full); err == nil {
return nil, fmt.Errorf("%s already exists; refusing to overwrite", full)
}
}

var created []string
for _, f := range files {
full := filepath.Join(dir, f.path)
full := filepath.Join(dir, f.rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return created, fmt.Errorf("create dir for %s: %w", f.path, err)
return created, fmt.Errorf("create dir for %s: %w", f.rel, err)
}
if err := os.WriteFile(full, []byte(f.content), 0o644); err != nil {
return created, fmt.Errorf("write %s: %w", f.path, err)
return created, fmt.Errorf("write %s: %w", f.rel, err)
}
created = append(created, f.path)
created = append(created, f.rel)
}
sort.Strings(created)
return created, nil
}
Loading