Skip to content

proposal: Reactive references, async channels, and networking #13

Description

@PyXiion

Summary

Introduce four composable primitives:

ref              local reactive value
net.shared       network-synchronized ref

async.channel    local async channel
net.channel      network-backed async channel

The local primitives define the behavior. Network primitives extend them without exposing packet formats, batching, compression, or synchronization internals.

Sub-issues

  • Add async.channel
  • Add ref library
  • Add network object registry and handshake
  • Add net.channel
  • Add net.shared
  • Add documentation and tests

Design Goals

  • Simple Lua API with strong composability.
  • net.channel should feel like async.channel.
  • net.shared should feel like ref.
  • Network transport details remain internal to Ignis.
  • State updates are batched at the end of the tick.
  • Player binding controls routing and visibility.
  • Client-provided values are never implicitly trusted as gameplay authority.

ref

ref provides local reactive values:

local ref = require "ref"

local count = ref.new(0)

print(count:get())
count:set(10)

count:update(function(old)
  return old + 1
end)

local disconnect = count:watch(function(value, previous)
  print("Changed:", previous, "->", value)
end)

disconnect()

API:

ref.new(value) -> Ref

ref:get() -> value
ref:set(value)
ref:update(function(value) -> new_value)
ref:watch(function(new_value, old_value) -> disconnect)

Semantics:

  • Watchers run only after changes.
  • Watchers do not run during registration.
  • set replaces the complete value.
  • update derives and returns the next value.
  • Nested table mutations outside set or update are not tracked.
  • Recursive proxy-table magic is not required.
  • Watchers return cleanup functions.

async.channel

async.channel provides local coroutine communication:

local async = require "async"

local ch = async.channel({
  capacity = 32,
})

ch:send("hello")
ch:trySend("world")

for value in ch do
  print(value)
end

API:

async.channel(options?) -> Channel

ch:send(value)
ch:trySend(value) -> boolean
ch:recv() -> value
ch:tryRecv() -> value | nil

ch.handler = function(value)
end

for value in ch do
end

ch:close()
ch:isClosed() -> boolean

Semantics:

  • send suspends when the queue is full.
  • trySend never suspends.
  • recv suspends while the queue is empty.
  • tryRecv never suspends.
  • Messages are FIFO.
  • capacity counts messages.
  • Iteration ends after close and queue drain.
  • A channel has one consumer.
  • handler and iteration are alternative consumption modes.
  • Closing wakes suspended operations.

net.shared

net.shared behaves like a network-backed ref.

Server:

local net = require "net"

local hud = net.shared("hud", {
  title = "Spawn",
  visible = true,
}, {
  player = player,
  player_write = false,
})

Client:

local hud = net.shared("hud")

hud:ready(function(state)
  render_hud(state:get())
end)

hud:watch(function(value, previous)
  render_hud(value)
end)

API:

net.shared(id, initial_value?, options?) -> SharedRef

shared:get() -> value
shared:set(value)
shared:update(function(value) -> new_value)
shared:watch(function(new_value, old_value) -> disconnect)
shared:ready(function(shared) -> disconnect)
shared:await() -> shared

Scope options:

{
  player = player,
  player_write = false,
}

player binds the value to one player. It controls routing and visibility, not authorization. A missing or nil player means server-wide state.

With player_write = false, the client receives a read-only mirror. With player_write = true, the bound client may submit replacements or updates. Such values remain untrusted input from a gameplay perspective.

Synchronization behavior:

  • The server owns shared values.
  • Client lookup before registration returns a pending handle rather than failing.
  • ready runs after initial synchronization.
  • watch runs only for later value changes.
  • Multiple updates during one tick are coalesced.
  • Synchronization occurs at the end of the tick.
  • The public API exposes values, not snapshots, patches, or compression.
  • Ignis may use snapshots, patches, compression, or another internal strategy.

net.channel

net.channel is a network-backed async.channel:

local net = require "net"

local events = net.channel("events", {
  player = player,
  capacity = 32,
})

events:send({ kind = "open" })
events:trySend({ kind = "ping" })

events.handler = function(value, sender)
  print(value.kind, sender)
end

API:

net.channel(id, options?) -> NetworkChannel

ch:send(value)
ch:trySend(value) -> boolean
ch:recv() -> value, sender
ch:tryRecv() -> value, sender | nil
ch.handler = function(value, sender)
end
for value, sender in ch do
end
ch:close()
ch:isClosed() -> boolean

Routing:

  • Player-bound server send goes to the bound client.
  • Player-bound client send goes to the server.
  • Server-wide server send broadcasts to all clients.
  • Server-wide client send goes to the server.
  • There is no direct client-to-client communication.
  • Server-side receives include the sending player.

Delivery:

  • Reliable and ordered by default.
  • FIFO ordering is preserved per sender and receiver.
  • Messages are transient and not replayed to late subscribers.
  • Sent values are snapshotted when sent.
  • Serialization failures reject the send.
  • capacity counts messages.
  • Server-wide channels use independent per-player queues so one slow client cannot block everyone.
  • send suspends when full.
  • trySend returns false when full, disconnected, or closed.
  • Handler and iterator are alternative consumers.

Network Handle Lifecycle

Network objects use this lifecycle:

pending -> ready -> closed

While pending:

  • Watchers and ready callbacks are retained.
  • await suspends until readiness.
  • send waits for readiness.
  • trySend returns false.
  • Reads do not silently suspend.

After disconnect:

  • Pending and active objects become closed.
  • Suspended operations resume with a closed or disconnect error.
  • trySend returns false.
  • Iterators finish after queued messages drain.
  • Reconnection creates a new network session.

Serialization and Safety

Supported values should initially be ordinary serializable Lua values:

  • nil
  • booleans
  • numbers
  • strings
  • tables containing serializable values

Reject functions, threads, userdata, metatables, cyclic tables, excessively deep values, and values exceeding configured size limits.

Client-provided data must always be treated as untrusted, including player_write values and channel messages.

Non-Goals for the First Version

  • Exposing packet IDs or transport packets
  • Exposing compression or patch formats
  • Unreliable delivery
  • Direction flags
  • Server-side mutator registries
  • Path-based update syntax
  • Automatic deep proxy mutation tracking
  • Persistent storage across server restarts

Acceptance Criteria

  • async.channel and ref work independently of networking.
  • net.channel follows the async.channel API and semantics.
  • net.shared follows the ref API and semantics.
  • Player-bound values and channels cannot be observed by other clients.
  • Shared updates batch at tick boundaries.
  • Network channels provide bounded queues and backpressure.
  • trySend is always non-blocking.
  • Pending network handles do not fail merely because registration has not arrived yet.
  • Disconnect and cleanup behavior is deterministic.
  • Documentation includes local, server-wide, and player-bound examples.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions