diff --git a/README.md b/README.md index f5d2529..7d90b4a 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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 ` | Set the active game (persisted in `~/.asobi`) | | `asobi create [--size xs\|s\|m\|l] [--game ]` | Create an environment | @@ -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 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 diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index d7fb66e..84ce3ce 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -96,8 +96,9 @@ Usage: asobi whoami Show current credential info asobi init [dir] Scaffold a starter Lua game asobi init [dir] --template - 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 ] Run a local backend for your Lua game (Docker) asobi games List your tenant's games @@ -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 { @@ -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) } @@ -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 (list yours: asobi games)") step("asobi create (e.g. asobi create prod)") step("asobi deploy 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") diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index f60f516..5001768 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -1,133 +1,85 @@ +// 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 # list yours: asobi games -asobi create # e.g. asobi create prod -asobi deploy 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) } @@ -135,14 +87,15 @@ func Init(dir string) ([]string, error) { 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 } diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index 7ccf935..1cc8d74 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -9,7 +9,7 @@ import ( func TestInitCreatesWorkingGame(t *testing.T) { dir := t.TempDir() - created, err := Init(dir) + created, err := Init(dir, "") if err != nil { t.Fatalf("Init: %v", err) } @@ -67,7 +67,7 @@ func TestInitRefusesToOverwrite(t *testing.T) { t.Fatal(err) } - if _, err := Init(dir); err == nil { + if _, err := Init(dir, ""); err == nil { t.Fatal("expected error when match.lua already exists") } data, _ := os.ReadFile(existing) @@ -75,3 +75,71 @@ func TestInitRefusesToOverwrite(t *testing.T) { t.Fatalf("existing match.lua was overwritten: %q", data) } } + +func TestInitUnknownGenre(t *testing.T) { + if _, err := Init(t.TempDir(), "roguelike"); err == nil { + t.Fatal("expected error for unknown genre") + } +} + +// Every genre scaffolds a lua/match.lua declaring match_size plus the callbacks +// its bridge requires, and a README that runs locally and deploys. +func TestEveryGenreScaffoldsARunnableGame(t *testing.T) { + genres := Genres() + if len(genres) < 5 { + t.Fatalf("expected the genre set to include basic + 4 starters, got %v", genres) + } + for _, g := range []string{"arena", "basic", "chat", "turn-based", "world"} { + if !IsGenre(g) { + t.Fatalf("expected %q to be a known genre", g) + } + } + + for _, g := range genres { + t.Run(g, func(t *testing.T) { + dir := t.TempDir() + created, err := Init(dir, g) + if err != nil { + t.Fatalf("Init(%q): %v", g, err) + } + hasLua, hasReadme := false, false + for _, f := range created { + if f == filepath.Join("lua", "match.lua") { + hasLua = true + } + if f == "README.md" { + hasReadme = true + } + } + if !hasLua || !hasReadme { + t.Fatalf("%q scaffold missing lua/match.lua or README.md: %v", g, created) + } + + lua, err := os.ReadFile(filepath.Join(dir, "lua", "match.lua")) + if err != nil { + t.Fatalf("read match.lua: %v", err) + } + needed := []string{"match_size", "function init(", "function join(", "function leave(", "function handle_input(", "function get_state("} + if g == "world" { + needed = append(needed, `game_type = "world"`, "function spawn_position(", "function zone_tick(", "function post_tick(") + } else { + needed = append(needed, "function tick(") + } + for _, cb := range needed { + if !strings.Contains(string(lua), cb) { + t.Fatalf("%q match.lua missing %q", g, cb) + } + } + + readme, err := os.ReadFile(filepath.Join(dir, "README.md")) + if err != nil { + t.Fatalf("read README.md: %v", err) + } + for _, hint := range []string{"asobi dev", "asobi deploy"} { + if !strings.Contains(string(readme), hint) { + t.Fatalf("%q README.md missing %q", g, hint) + } + } + }) + } +} diff --git a/internal/scaffold/templates/arena/README.md b/internal/scaffold/templates/arena/README.md new file mode 100644 index 0000000..fada1dc --- /dev/null +++ b/internal/scaffold/templates/arena/README.md @@ -0,0 +1,36 @@ +# My Asobi arena game + +A real-time arena on [Asobi](https://github.com/widgrensit/asobi): players +move each tick and shoot the nearest enemy. The server owns every position and +hit, so clients only send inputs (`dx`, `dy`, `shoot`) and render the state +they get back. + +The logic lives in `lua/match.lua`: + +- `handle_input` applies movement and a range-checked hit, server-side. +- `tick` runs the simulation loop (~10 Hz). +- `match_size` starts a match at 2 players; `max_players` lets it fill to 8. + +## 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 + +```bash +asobi login +asobi use # list yours: asobi games +asobi create # e.g. asobi create prod +asobi deploy lua +``` + +## Next + +- Add bots to fill empty slots: https://hexdocs.pm/asobi/lua-bots.html +- Connect a client - Defold quickstart: https://github.com/widgrensit/asobi-defold diff --git a/internal/scaffold/templates/arena/lua/match.lua b/internal/scaffold/templates/arena/lua/match.lua new file mode 100644 index 0000000..6f25fcb --- /dev/null +++ b/internal/scaffold/templates/arena/lua/match.lua @@ -0,0 +1,68 @@ +-- match.lua - a real-time arena game mode on Asobi. +-- Players move every tick and can shoot; the server owns all positions and hp, +-- so no client can cheat its own coordinates or a hit. See +-- https://github.com/widgrensit/asobi_lua for the full game.* API. + +match_size = 2 +max_players = 8 + +function init(config) + return { players = {}, tick_count = 0 } +end + +function join(player_id, state) + state.players[player_id] = { x = 0, y = 0, hp = 100 } + return state +end + +function leave(player_id, state) + state.players[player_id] = nil + return state +end + +-- handle_input moves the player and, on a shoot input, damages the nearest +-- living enemy in range. Movement and damage are both applied server-side. +function handle_input(player_id, input, state) + local me = state.players[player_id] + if not me or me.hp <= 0 or not input then + return state + end + + me.x = me.x + (input.dx or 0) + me.y = me.y + (input.dy or 0) + + if input.shoot then + local target = nearest_enemy(player_id, me, state.players) + if target and distance(me, target) < 200 then + target.hp = math.max(0, target.hp - 25) + end + end + return state +end + +function tick(state) + state.tick_count = (state.tick_count or 0) + 1 + return state +end + +function get_state(player_id, state) + return state +end + +function nearest_enemy(id, me, players) + local best, best_d + for pid, p in pairs(players) do + if pid ~= id and p.hp > 0 then + local d = distance(me, p) + if not best_d or d < best_d then + best, best_d = p, d + end + end + end + return best +end + +function distance(a, b) + local dx, dy = a.x - b.x, a.y - b.y + return math.sqrt(dx * dx + dy * dy) +end diff --git a/internal/scaffold/templates/basic/README.md b/internal/scaffold/templates/basic/README.md new file mode 100644 index 0000000..6ccf616 --- /dev/null +++ b/internal/scaffold/templates/basic/README.md @@ -0,0 +1,57 @@ +# 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 # list yours: asobi games +asobi create # e.g. asobi create prod +asobi deploy 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" +} +``` + +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 diff --git a/internal/scaffold/templates/basic/lua/match.lua b/internal/scaffold/templates/basic/lua/match.lua new file mode 100644 index 0000000..9bedc0c --- /dev/null +++ b/internal/scaffold/templates/basic/lua/match.lua @@ -0,0 +1,45 @@ +-- 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, ...). + +-- Players needed before a match starts. Required. +match_size = 2 + +-- 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 diff --git a/internal/scaffold/templates/chat/README.md b/internal/scaffold/templates/chat/README.md new file mode 100644 index 0000000..eaf9964 --- /dev/null +++ b/internal/scaffold/templates/chat/README.md @@ -0,0 +1,38 @@ +# My Asobi chat room + +A real-time chat room on [Asobi](https://github.com/widgrensit/asobi): everyone +who joins shares one room, and messages are relayed by the server so the author +can't be forged. + +The logic lives in `lua/match.lua`: + +- `handle_input` takes `{say = "hello"}` and broadcasts it as a `chat` event, + stamping the author from `player_id`. +- `join` / `leave` announce arrivals and departures. +- `match_size` opens the room at 2 players; `max_players` lets it fill to 50. + +Clients listen for the `chat` event and send `{say = "..."}` inputs. + +## 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 + +```bash +asobi login +asobi use # list yours: asobi games +asobi create # e.g. asobi create prod +asobi deploy lua +``` + +## Next + +- Persist history with `game.chat.send`: https://hexdocs.pm/asobi/lua-scripting.html +- Connect a client - Defold quickstart: https://github.com/widgrensit/asobi-defold diff --git a/internal/scaffold/templates/chat/lua/match.lua b/internal/scaffold/templates/chat/lua/match.lua new file mode 100644 index 0000000..877e5f7 --- /dev/null +++ b/internal/scaffold/templates/chat/lua/match.lua @@ -0,0 +1,40 @@ +-- match.lua - a real-time chat room on Asobi. +-- Everyone who joins shares one room. A {say = "..."} input is broadcast to all +-- connected players as a "chat" event. The author is taken from player_id, so a +-- client can't forge who said what. See +-- https://github.com/widgrensit/asobi_lua for the full game.* API. + +match_size = 2 +max_players = 50 + +function init(config) + return { players = {} } +end + +function join(player_id, state) + state.players[player_id] = true + game.broadcast("chat", { system = true, text = player_id .. " joined" }) + return state +end + +function leave(player_id, state) + state.players[player_id] = nil + game.broadcast("chat", { system = true, text = player_id .. " left" }) + return state +end + +-- handle_input turns a {say = "..."} message into a broadcast to the room. +function handle_input(player_id, input, state) + if input and type(input.say) == "string" and #input.say > 0 then + game.broadcast("chat", { from = player_id, text = input.say }) + end + return state +end + +function tick(state) + return state +end + +function get_state(player_id, state) + return state +end diff --git a/internal/scaffold/templates/turn-based/README.md b/internal/scaffold/templates/turn-based/README.md new file mode 100644 index 0000000..329ea94 --- /dev/null +++ b/internal/scaffold/templates/turn-based/README.md @@ -0,0 +1,38 @@ +# My Asobi turn-based game + +A turn-based game on [Asobi](https://github.com/widgrensit/asobi): two players +alternate claiming cells on a 3x3 board. The server enforces turn order, so an +out-of-turn or duplicate move is rejected rather than trusted from the client. + +The logic lives in `lua/match.lua`: + +- `handle_input` claims a cell only if it is the sender's turn and the cell is + free, then passes the turn and broadcasts a `move` event. +- `state.turn` and `state.board` are server-owned; clients send `{cell = 1..9}`. +- `match_size` starts the game at 2 players. + +There is no `tick` simulation here - the state only changes on a valid move. + +## 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 + +```bash +asobi login +asobi use # list yours: asobi games +asobi create # e.g. asobi create prod +asobi deploy lua +``` + +## Next + +- Detect a win and end the match by returning a `finished` flag from a callback. +- Connect a client - Defold quickstart: https://github.com/widgrensit/asobi-defold diff --git a/internal/scaffold/templates/turn-based/lua/match.lua b/internal/scaffold/templates/turn-based/lua/match.lua new file mode 100644 index 0000000..83708d8 --- /dev/null +++ b/internal/scaffold/templates/turn-based/lua/match.lua @@ -0,0 +1,50 @@ +-- match.lua - a turn-based game on Asobi. +-- Two players alternate claiming cells on a 3x3 board. The server owns whose +-- turn it is and which cells are taken, so an out-of-turn or duplicate move is +-- rejected server-side rather than trusted from the client. See +-- https://github.com/widgrensit/asobi_lua for the full game.* API. + +match_size = 2 + +function init(config) + return { order = {}, turn = 1, board = {} } +end + +function join(player_id, state) + table.insert(state.order, player_id) + return state +end + +function leave(player_id, state) + return state +end + +-- handle_input claims a cell for the current player. It ignores the move unless +-- it is this player's turn and the cell (1-9) is free, then passes the turn. +function handle_input(player_id, input, state) + if player_id ~= state.order[state.turn] or not input then + return state + end + + local cell = input.cell + if type(cell) ~= "number" or cell < 1 or cell > 9 or state.board[cell] then + return state + end + + state.board[cell] = player_id + state.turn = (state.turn % #state.order) + 1 + game.broadcast("move", { + by = player_id, + cell = cell, + next = state.order[state.turn] + }) + return state +end + +function tick(state) + return state +end + +function get_state(player_id, state) + return state +end diff --git a/internal/scaffold/templates/world/README.md b/internal/scaffold/templates/world/README.md new file mode 100644 index 0000000..6cbb620 --- /dev/null +++ b/internal/scaffold/templates/world/README.md @@ -0,0 +1,40 @@ +# My Asobi world + +A persistent shared world on [Asobi](https://github.com/widgrensit/asobi): +players share zones of a larger map instead of a fixed-size match. Setting +`game_type = "world"` routes the script through the world bridge. + +World mode has a different callback shape than a match: + +- `spawn_position` places a joining player in the world. +- `zone_tick` advances one zone's entities each tick. +- `handle_input` returns the zone's `entities` table, not the whole state. +- `post_tick` runs once per world tick after every zone has ticked. +- `tick_rate` is the world tick interval in ms (50 = 20 Hz). + +The logic lives in `lua/match.lua`. Zone spawning and terrain are available via +`game.zone.*` and `game.terrain.*` once you build the world out. + +## 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 + +```bash +asobi login +asobi use # list yours: asobi games +asobi create # e.g. asobi create prod +asobi deploy lua +``` + +## Next + +- World server guide: https://hexdocs.pm/asobi/world-server.html +- Connect a client - Defold quickstart: https://github.com/widgrensit/asobi-defold diff --git a/internal/scaffold/templates/world/lua/match.lua b/internal/scaffold/templates/world/lua/match.lua new file mode 100644 index 0000000..8f5b42c --- /dev/null +++ b/internal/scaffold/templates/world/lua/match.lua @@ -0,0 +1,56 @@ +-- match.lua - a persistent shared world on Asobi. +-- game_type = "world" routes this script through the world bridge: players share +-- zones of a larger map, the server ticks each zone, and every position is +-- server-owned. World mode has a different callback shape than a match: it uses +-- spawn_position, zone_tick and post_tick, and handle_input returns the zone's +-- entities table (not the whole state). See +-- https://github.com/widgrensit/asobi_lua for the full world.* / spatial.* API. + +game_type = "world" +match_size = 1 +max_players = 100 +tick_rate = 50 + +function init(config) + return { tick_count = 0 } +end + +function join(player_id, state) + return state +end + +function leave(player_id, state) + return state +end + +-- spawn_position places a joining player somewhere in the world. +function spawn_position(player_id, state) + return { x = 0, y = 0 } +end + +-- zone_tick advances one zone each tick. entities is everything currently in the +-- zone; return it (optionally mutated) plus the zone state. +function zone_tick(entities, zone_state) + return entities, zone_state +end + +-- handle_input moves a player's entity. In world mode it returns the entities +-- table, not the whole match state. +function handle_input(player_id, input, entities) + local me = entities[player_id] + if me and input then + me.x = (me.x or 0) + (input.dx or 0) + me.y = (me.y or 0) + (input.dy or 0) + end + return entities +end + +-- post_tick runs once per world tick, after every zone has ticked. +function post_tick(tick, state) + state.tick_count = (state.tick_count or 0) + 1 + return state +end + +function get_state(player_id, state) + return state +end