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
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.
Summary
Introduce four composable primitives:
The local primitives define the behavior. Network primitives extend them without exposing packet formats, batching, compression, or synchronization internals.
Sub-issues
async.channelreflibrarynet.channelnet.sharedDesign Goals
net.channelshould feel likeasync.channel.net.sharedshould feel likeref.refrefprovides local reactive values:API:
Semantics:
setreplaces the complete value.updatederives and returns the next value.setorupdateare not tracked.async.channelasync.channelprovides local coroutine communication:API:
Semantics:
sendsuspends when the queue is full.trySendnever suspends.recvsuspends while the queue is empty.tryRecvnever suspends.capacitycounts messages.handlerand iteration are alternative consumption modes.net.sharednet.sharedbehaves like a network-backedref.Server:
Client:
API:
Scope options:
{ player = player, player_write = false, }playerbinds the value to one player. It controls routing and visibility, not authorization. A missing ornilplayer means server-wide state.With
player_write = false, the client receives a read-only mirror. Withplayer_write = true, the bound client may submit replacements or updates. Such values remain untrusted input from a gameplay perspective.Synchronization behavior:
readyruns after initial synchronization.watchruns only for later value changes.net.channelnet.channelis a network-backedasync.channel:API:
Routing:
Delivery:
capacitycounts messages.sendsuspends when full.trySendreturnsfalsewhen full, disconnected, or closed.Network Handle Lifecycle
Network objects use this lifecycle:
While pending:
awaitsuspends until readiness.sendwaits for readiness.trySendreturnsfalse.After disconnect:
trySendreturnsfalse.Serialization and Safety
Supported values should initially be ordinary serializable Lua values:
nilReject 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_writevalues and channel messages.Non-Goals for the First Version
Acceptance Criteria
async.channelandrefwork independently of networking.net.channelfollows theasync.channelAPI and semantics.net.sharedfollows therefAPI and semantics.trySendis always non-blocking.