Package github.com/matjam/bunyip/save
save
Package save stores a game's files in the platform's own data directory: settings, save slots and anything else worth keeping between runs, written as JSON through a synced temporary file and renamed into place to avoid exposing partial JSON.
To open a store, call Open with the application name. It picks Application Support on macOS, AppData on Windows and XDG data on Linux. OpenAt takes any directory, for tests. A Store's Write and Read take any value encoding/json handles, and Load reads a value with defaults for the fields a file does not have, which is how settings survive new versions. List names the files present for a load menu, Delete removes one, and Exists checks before overwriting. Files are written to a temporary name and renamed into place, so a reader sees the old file or the new one where the filesystem supports atomic replacement. The containing directory is not synced, so power-loss durability is not guaranteed.
Write returns once the file is synced to the drive, which takes milliseconds. To autosave from the game loop without missing a frame, call WriteAsync, which encodes the value at once and writes it on a background goroutine, and call Flush before the game exits. For whole ECS worlds, ecs.World.Save produces the bytes and this package stores them.
Index
func Dir(app string) (string, error)- type Store
func Open(app string) (*Store, error)func OpenAt(dir string) (*Store, error)func (s *Store) Delete(name string) errorfunc (s *Store) Exists(name string) boolfunc (s *Store) Flush()func (s *Store) List() ([]string, error)func (s *Store) Load[T any](name string, defaults T) (T, error)func (s *Store) Path() stringfunc (s *Store) Read(name string, v any) errorfunc (s *Store) Write(name string, v any) errorfunc (s *Store) WriteAsync(name string, v any) <-chan error
Examples
Example
package main
import (
"fmt"
"os"
"github.com/matjam/bunyip/save"
)
type Settings struct {
Volume float32
Fullscreen bool
}
func main() {
// In a game: store, err := save.Open("my-game"), which picks the
// platform's data directory. A temporary directory keeps this example
// self-contained.
dir, _ := os.MkdirTemp("", "save-example")
defer os.RemoveAll(dir)
store, err := save.OpenAt(dir)
if err != nil {
panic(err)
}
// Load settings over defaults: a missing file yields the defaults.
settings, _ := store.Load("settings", Settings{Volume: 0.8})
fmt.Println(settings.Volume, settings.Fullscreen)
settings.Fullscreen = true
store.Write("settings", settings)
again, _ := store.Load("settings", Settings{Volume: 0.8})
fmt.Println(again.Fullscreen)
names, _ := store.List()
fmt.Println(names)
}
0.8 false true [settings]
Functions
Dir source
func Dir(app string) (string, error)
Dir returns the per-user data directory for an app: Application Support on macOS, XDG data on Linux, AppData on Windows. The BUNYIP_DATA_DIR environment variable overrides the base directory; app is appended to that base. The app argument is a trusted application directory name, not unvalidated user input.
Types
type Store source
type Store struct {
// contains filtered or unexported fields
}
Store reads and writes named JSON documents in one directory. Names omit the .json extension and must be nonempty leaf names without slashes; "." and ".." are rejected. A Store is safe for concurrent use. Writes to one name, from Write and WriteAsync, land in the order they were called; writes to different names run independently.
Open source
func Open(app string) (*Store, error)
Open creates the app's data directory if needed and returns a store for it.
OpenAt source
func OpenAt(dir string) (*Store, error)
OpenAt opens a store on an explicit directory.
Delete source
func (s *Store) Delete(name string) error
Delete removes name.json, after any pending writes to name have finished; a missing file is not an error.
Exists source
func (s *Store) Exists(name string) bool
Exists reports whether name.json is present, after any pending writes to name have finished.
Flush source
func (s *Store) Flush()
Flush waits until every write that WriteAsync or Write started before the call has finished. To make sure autosaves reach the disk, call it before the game exits. Errors go to each write's own channel.
List source
func (s *Store) List() ([]string, error)
List returns the names of the documents in the store, sorted.
Load source
func (s *Store) Load[T any](name string, defaults T) (T, error)
Load reads name.json over a copy of defaults, so fields the file lacks keep their default values and a missing file yields the defaults without error. Use it for settings. Defaults are copied through JSON even when the file is missing, so maps and slices are independent. Invalid JSON defaults return an error.
Read source
func (s *Store) Read(name string, v any) error
Read decodes name.json into v, after any pending writes to name have finished. A missing file returns an error that satisfies errors.Is(err, os.ErrNotExist).
Write source
func (s *Store) Write(name string, v any) error
Write stores v as name.json through a synced temporary file, replacing the previous file with os.Rename. Atomic replacement depends on the host filesystem. Failures before rename leave the old file intact; this method does not sync the containing directory.
Write returns once the data is on disk, and the sync waits for the storage device: several milliseconds for a small file on macOS, where a sync is a full flush of the drive's cache. To save from the game loop without missing a frame, call WriteAsync instead. Write waits for earlier WriteAsync calls for the same name to finish first.
WriteAsync source
func (s *Store) WriteAsync(name string, v any) <-chan error
WriteAsync stores v as name.json as Write does, but returns before the file is written. To autosave from the game loop, call it and check the returned channel on a later frame. v is encoded before WriteAsync returns, so the caller may change it straight away; the write, sync and rename run on a background goroutine. The channel receives one value when the write has finished: nil once the new file is in place, or the error. An invalid name or a value that cannot be encoded is reported on the channel at once, and nothing is written.
Writes to one name land in the order they were called, and Read, Load, Exists and Delete for a name wait for its pending writes. A process that exits with writes pending loses them; call Flush before exiting.
Source files
async_test.go bench_test.go defaults_test.go example_test.go review_test.go save.go save_test.go