Check out our latest project ✨ OpenChapter.io: free ebooks the way its meant to be πŸ“–

Jodotai

An asset by ayayron
The page banner background of a mountain and forest
Jodotai hero image

Quick Information

0 ratings
Jodotai icon image
ayayron
Jodotai

Reactive atomic state for Godot 4, inspired by Jotai.

Supported Engine Version
4.0
Version String
1.0.0
License Version
MIT
Support Level
community
Modified Date
23 hours ago
Git URL
Issue URL

Jodotai

Reactive atomic state for Godot 4. Jotai-style atoms with automatic dependency tracking β€” derived values recompute when their dependencies change, with no manual signal wiring.

var count   := Jodotai.atom(0)
var doubled := Jodotai.derived(func(): return count.get_value() * 2)

count.set_value(5)
print(doubled.get_value())   # 10 β€” recomputed automatically

Features

  • Atoms β€” observable values with a changed signal and subscribe() helper.
  • Automatic dependency tracking β€” derived() captures every atom you read and re-runs when any of them change. No subscription bookkeeping.
  • Writable derived atoms β€” two-way computed values with explicit read/write logic.
  • Optional runtime typing β€” Atom.bool(true), Atom.float(10.0), … assert their type in debug builds, stripped from release exports.
  • Persisted atoms β€” atom_with_storage() mirrors a value to user://.
  • Atom families β€” memoized per-key atom factories for per-entity state.
  • Reactive collections β€” ReactiveArray / ReactiveDict emit on mutation so atoms holding them propagate changes in place.
  • Pure GDScript, no dependencies. Tested on Godot 4.6.

Installation

Asset Library / manual: copy the addons/jodotai/ folder into your project's addons/ directory, then enable Jodotai in Project β†’ Project Settings β†’ Plugins.

Enabling the plugin registers the Jodotai autoload singleton automatically. That singleton is the entry point for everything below.

Quick start

# Create a writable atom
var hp := Jodotai.atom(100)

# Subscribe (returns an unsubscribe Callable β€” call it to disconnect)
var unsub := hp.subscribe(func(old, new_v): print("hp: %d -> %d" % [old, new_v]))

hp.set_value(80)        # prints "hp: 100 -> 80"
hp.set_value(80)        # no-op β€” equal value, no signal
unsub.call()            # stop listening

Core concepts

Atoms

var a := Jodotai.atom(0)
a.get_value()              # read
a.set_value(10)            # write + emit `changed(old, new)`
a.reset()                  # restore the initial value

set_value short-circuits when the new value == the old one, so equal writes emit nothing.

Derived atoms (auto-tracked)

derived() runs your closure once, records every get_value() call as a dependency, and re-runs whenever any dependency changes. Read-only.

var first := Jodotai.atom("RΓ©my")
var last  := Jodotai.atom("Ratatouille")
var full  := Jodotai.derived(func(): return "%s %s" % [first.get_value(), last.get_value()])

first.set_value("Linguini")
full.get_value()           # "Linguini Ratatouille"

Writable derived atoms

Two-way computed values β€” read is tracked like derived(), write runs your logic (typically delegating to base atoms).

var celsius := Jodotai.atom(0.0)
var fahrenheit := Jodotai.writable(
    func(): return celsius.get_value() * 9.0 / 5.0 + 32.0,
    func(v): celsius.set_value((v - 32.0) * 5.0 / 9.0),
)
fahrenheit.set_value(100.0)
celsius.get_value()        # β‰ˆ 37.78

Persisted atoms

Writes through to user://jodotai_store.cfg on every change (no debouncing β€” fine for settings, costly for high-frequency values).

var volume := Jodotai.atom_with_storage("audio/volume", 1.0)
volume.set_value(0.5)      # persisted

Atom families

Memoized atom factory keyed by an arbitrary param β€” same key returns the same atom.

var enemy_hp := Jodotai.atom_family(func(_id): return Jodotai.atom(100))

enemy_hp.get("goblin_1").set_value(80)
enemy_hp.get("goblin_1").get_value()   # 80 β€” same atom
enemy_hp.get("goblin_2").get_value()   # 100 β€” new atom
enemy_hp.remove("goblin_1")            # evict (e.g. on death)

Optionally auto-evict with set_should_remove(func(param, atom) -> bool), run before each get().

Optional runtime typing

Typed atoms enforce a type via is_instance_of on construction and every set_value. The check uses assert, so it catches mismatches in debug builds and is stripped from release exports β€” zero cost in shipped games.

var alive := Atom.bool(true)
alive.set_value(false)         # ok
# alive.set_value(3)           # assert fails in debug: "Atom type mismatch"

var speed := Atom.float(10.0)  # TYPE_FLOAT also accepts ints

var pos := Atom.typed(Vector2.ZERO, TYPE_VECTOR2)      # any Variant.Type
var inv := Atom.typed(ReactiveArray.new(), ReactiveArray)  # native class or Script

GDScript has no generics, so this validates writes only β€” get_value() still returns Variant. Cast at the read site for editor checking:

var is_dead := not bool(alive.get_value())     # primitive: constructor cast
var items   := inv.get_value() as ReactiveArray # object: `as`

Reactive collections

Plain Array/Dictionary mutations can't be observed, so an atom holding one won't re-emit when you push or erase. ReactiveArray / ReactiveDict wrap them and emit a mutated signal on every change; an atom holding one bridges mutated β†’ changed automatically, so derived atoms refresh.

var items := ReactiveArray.new(["bread", "tomato"])
var inv   := Jodotai.atom(items)
var count := Jodotai.derived(func(): return inv.get_value().size())

items.push("cheese")
count.get_value()          # 3 β€” recomputed via mutated β†’ changed

Use the mutation methods (push, erase, set_at, put, merge, …) β€” arr[i] = x can't be intercepted on custom classes in Godot 4. .data exposes the backing collection by reference for reads only; mutating it directly bypasses mutated.

API reference

Jodotai (autoload)

Method Description
atom(initial = null) -> Atom Writable atom.
derived(compute: Callable) -> DerivedAtom Read-only auto-tracked computed atom.
writable(read, write: Callable) -> WritableAtom Two-way computed atom.
atom_with_storage(key: String, initial = null) -> Atom Atom persisted to user://.
atom_family(factory: Callable) -> AtomFamily Memoized atom factory keyed by param.

Atom

Member Description
signal changed(old_value, new_value) Emitted on value change.
get_value() Read; registers as a dependency inside a derived frame.
set_value(new_value) Write + emit; no-op on equal value; asserts type on typed atoms.
reset() Restore the initial value.
subscribe(cb: Callable) -> Callable Connect cb; returns an unsubscribe Callable.
static bool/int/float/string(initial) Typed atoms locked to that type.
static typed(initial, type) Typed atom for any Variant.Type, native class, or Script.

AtomFamily

Method Description
get(param) -> Atom Cached atom for param, created on first call.
remove(param) / clear() Evict one / all. Held references stay valid.
has(param) -> bool Whether param is cached (does not run eviction predicate).
params() -> Array Currently cached keys.
set_should_remove(pred: Callable) Eviction predicate func(param, atom) -> bool.

ReactiveArray / ReactiveDict

signal mutated, read-only data view, and mutation methods that emit on change.

  • Array: push, push_front, pop, pop_front, insert, remove_at, erase, set_at, clear, sort, sort_custom, reverse, resize, append_array, fill, assign β€” plus reads get_at, size, is_empty, has, find, rfind, slice, duplicate. Supports for item in reactive_array.
  • Dict: put, erase, clear, merge β€” plus reads get_at, has, has_all, keys, values, size, is_empty, find_key, duplicate. Supports for key in reactive_dict.

Caveats

  • Equality short-circuit uses ==. set_value skips emitting when new_value == _value. For objects this is identity, so in-place mutations don't fire unless the value is a ReactiveArray/ReactiveDict. Also: changing an untyped atom across ==-incomparable types (e.g. int β†’ String) throws at the comparison; bridge through null or stay within a comparable type family.
  • Typed checks are debug-only. assert-based, stripped from release exports. Don't rely on them to validate untrusted/external data β€” use an explicit guard for that.
  • Typed atoms reject null. is_instance_of(null, type) is false; leave an atom untyped if it needs to be nullable.
  • Atom.bool/int/float shadow the built-in casts inside the Atom hierarchy. Code inside Atom/DerivedAtom/WritableAtom writing int(x) calls the factory, not the conversion. (string is safe β€” the cast is String.) Outside the hierarchy they behave normally.
  • Clean up subscriptions. Capture the Callable from subscribe() and call it in _exit_tree() (or NOTIFICATION_PREDELETE). Don't capture an atom inside its own subscription lambda β€” that's a refcount cycle GDScript won't collect; use the (old, new) callback args or a weakref.
  • atom_with_storage keys share one [atoms] section in the store; duplicate keys overwrite each other, and every write round-trips through ConfigFile.save.
  • atom_family holds strong refs. Cached atoms aren't freed until remove()/clear() and all external refs drop. Family-membership changes don't re-run derived atoms that only read get() results β€” track active IDs separately if you need that.

Testing

A GUT suite lives under test/jodotai/. Run headless:

godot --headless -d -s addons/gut/gut_cmdln.gd -gdir=res://test/jodotai -gexit

License

Jodotai is provided under the MIT license. License is in addons/jodotai/LICENSE.md.

Reactive atomic state for Godot 4, inspired by Jotai.

Reviews

0 ratings

Your Rating

Headline must be at least 3 characters but not more than 50
Review must be at least 5 characters but not more than 500
Please sign in to add a review

Quick Information

0 ratings
Jodotai icon image
ayayron
Jodotai

Reactive atomic state for Godot 4, inspired by Jotai.

Supported Engine Version
4.0
Version String
1.0.0
License Version
MIT
Support Level
community
Modified Date
23 hours ago
Git URL
Issue URL

Open Source

Released under the AGPLv3 license

Plug and Play

Browse assets directly from Godot

Community Driven

Created by developers for developers