TUTORIAL.md raw

Learning Moxie

A hands-on introduction. Each section builds on the last.

1. Hello Moxie

package main

func main() {
    println("hello, moxie")
}

Save as hello.mx. Build and run:

export MOXIEROOT=/path/to/moxie
moxie build -o hello .
./hello

.mx files are Moxie source. The compiler produces a static binary with no dependencies.


2. Variables and Types

package main

func main() {
    x := 42              // int (always 32-bit in Moxie)
    var y float64 = 3.14
    z := x + 1
    println(x, y, z)
}

Moxie has the same declaration syntax you'd expect: var, :=, const, type.

Numeric types: int8, int16, int32, int64, uint8-uint64, float32, float64, byte, bool.

int and uint are always 32-bit. There is no complex64/128. uintptr is only available in packages that import "unsafe".


3. Text

string and []byte are the same type in Moxie. They have identical layout (pointer, length, capacity) and are interchangeable. Use string in signatures for readability, []byte when the byte-level nature matters.

package main

import "bytes"

func main() {
    name := "moxie"
    greeting := "hello " | name | "!"   // | concatenates text
    println(greeting)

    // bytes package works on strings (they're []byte)
    upper := bytes.ToUpper(name)
    println(upper)

    // direct byte access
    println(name[0])    // 109 (ASCII 'm')
}

Key rules:


4. Functions

package main

func add(a, b int32) int32 {
    return a + b           // + on numbers is fine
}

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}

func main() {
    println(add(3, 4))
}

Multiple return values, variadic parameters, closures, and method receivers all work as expected.


5. Structs and Methods

package main

type Point struct {
    X, Y float64
}

func (p Point) Dist() float64 {
    return p.X*p.X + p.Y*p.Y
}

func main() {
    p := Point{X: 3, Y: 4}
    println(p.Dist())       // 25
}

Allocate on the heap with &:

    p := &Point{X: 1, Y: 2}    // *Point

There is no new(). Use &T{} or var x T; p := &x.


6. Interfaces

package main

type Shape interface {
    Area() float64
}

type Rect struct {
    W, H float64
}

func (r Rect) Area() float64 {
    return r.W * r.H
}

func printArea(s Shape) {
    println(s.Area())
}

func main() {
    printArea(Rect{W: 3, H: 4})   // 12
}

Type assertions and type switches work as expected:

    switch v := s.(type) {
    case Rect:
        println("rect:", v.W, v.H)
    }

Interfaces work freely within a domain. They cannot cross a spawn boundary — see section 11.


7. Collections

Slices

package main

func main() {
    s := []int32{1, 2, 3}
    s = append(s, 4, 5)

    // size literals (alternative to make)
    buf := []byte{:1024}           // 1024 zero bytes
    table := []int32{:0:100}       // len=0, cap=100

    // equality works on slices
    a := []int32{1, 2}
    b := []int32{1, 2}
    println(a == b)                // true

    // concatenation
    c := a | b                     // [1, 2, 1, 2]
    println(len(c))                // 4
}

Maps

package main

func main() {
    m := map[string]int32{
        "alpha": 1,
        "beta":  2,
    }
    m["gamma"] = 3
    v, ok := m["alpha"]
    println(v, ok)                 // 1 true
    delete(m, "beta")
}

Slices can be map keys (if the element type is comparable).


8. Control Flow

package main

func main() {
    // if
    x := 10
    if x > 5 {
        println("big")
    } else {
        println("small")
    }

    // for (all three forms)
    for i := 0; i < 5; i++ {
        println(i)
    }

    for x > 0 {
        x--
    }

    items := []string{"a", "b", "c"}
    for i, v := range items {
        println(i, v)
    }

    // switch (no fallthrough — use comma-separated cases)
    switch x {
    case 0:
        println("zero")
    case 1, 2, 3:
        println("small")
    default:
        println("other")
    }
}

break, continue, goto, and labeled loops all work.


9. Channels and Select — The Dispatch System

This is the heart of Moxie. There are no goroutines. All concurrency within a domain is expressed through channels and select.

Unbuffered Channels — Synchronous Dispatch

An unbuffered channel send transfers execution to the waiting select case. Think of it as a function call via the channel:

package main

func main() {
    tick := chan struct{}{}         // unbuffered dispatcher
    done := chan struct{}{}

    // In a real program, these sends would come from
    // I/O callbacks, timers, or child domain messages.

    // Event loop
    select {
    case <-tick:
        println("tick received")
    case <-done:
        println("shutting down")
    }
}

Buffered Channels — Message Queues

Buffered channels enqueue messages for the next select iteration:

package main

func main() {
    msgs := chan string{10}        // buffer of 10

    msgs <- "first"
    msgs <- "second"

    // Process buffered messages
    for {
        select {
        case m := <-msgs:
            println(m)
        default:
            println("queue empty")
            return
        }
    }
}

Building an Event Dispatcher

The pattern: create channels for each event type, run a select loop.

package main

import (
    "fmt"
    "os"
)

type Server struct {
    requests chan string
    quit     chan struct{}
}

func newServer() *Server {
    return &Server{
        requests: chan string{100},
        quit:     chan struct{}{},
    }
}

func (s *Server) run() {
    for {
        select {
        case req := <-s.requests:
            fmt.Println("handling:", req)
        case <-s.quit:
            fmt.Println("server stopped")
            return
        }
    }
}

func main() {
    s := newServer()
    s.requests <- "GET /index"
    s.requests <- "GET /about"
    close(s.quit)
    s.run()
}

This is the fundamental execution pattern. Every Moxie program is a tree of event loops connected by channels.


10. Timers and I/O

The runtime integrates with epoll (Linux) / kqueue (macOS). I/O operations yield to the event loop and resume when data is ready.

package main

import (
    "fmt"
    "net"
)

func main() {
    ln, err := net.Listen("tcp", ":8080")
    if err != nil {
        panic(err)
    }
    fmt.Println("listening on :8080")
    for {
        conn, err := ln.Accept()
        if err != nil {
            continue
        }
        // Handle synchronously — one connection at a time per domain.
        // Use spawn for concurrent connection handling.
        buf := []byte{:4096}
        n, _ := conn.Read(buf)
        conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"))
        conn.Close()
        _ = buf[:n]
    }
}

Within a single domain, I/O is sequential. For concurrent I/O handling, spawn child domains.


11. Domains and Spawn — Process Isolation

spawn creates a child domain: a separate OS process with its own heap and event loop. The parent and child communicate through IPC channels.

Basic Spawn

package main

func worker() {
    println("hello from child domain")
}

func main() {
    done := spawn(worker)
    _ = done
    println("parent continues immediately")
}

Passing Data with Codec Types

Data crossing the spawn boundary must implement moxie.Codec. The moxie package provides codec wrappers for all primitive types:

package main

import "moxie"

func compute(id moxie.Int32, scale moxie.Float64) {
    result := float64(id) * float64(scale)
    println(result)
}

func main() {
    spawn(compute, moxie.Int32(1), moxie.Float64(2.5))
    spawn(compute, moxie.Int32(2), moxie.Float64(3.0))
    // Both children run concurrently as separate processes
}

IPC Channels

Pass channels to spawn for bidirectional communication. Channel element types must implement moxie.Codec:

package main

import "moxie"

func producer(out chan moxie.Int32) {
    for i := int32(0); i < 10; i++ {
        out <- moxie.Int32(i)
    }
    close(out)
}

func main() {
    ch := chan moxie.Int32{}
    spawn(producer, ch)

    // Receive results from child domain
    for {
        v, ok := <-ch
        if !ok {
            break
        }
        println(v)
    }
}

Custom Codec Types

Define EncodeTo and DecodeFrom for your own types:

package main

import (
    "io"
    "moxie"
)

type Point struct {
    X moxie.Float64
    Y moxie.Float64
}

func (p Point) EncodeTo(w io.Writer) error {
    if err := p.X.EncodeTo(w); err != nil {
        return err
    }
    return p.Y.EncodeTo(w)
}

func (p *Point) DecodeFrom(r io.Reader) error {
    if err := p.X.DecodeFrom(r); err != nil {
        return err
    }
    return p.Y.DecodeFrom(r)
}

func plotWorker(pt Point) {
    println(float64(pt.X), float64(pt.Y))
}

func main() {
    spawn(plotWorker, Point{X: 1.5, Y: 2.7})
}

Move Semantics

Values passed to spawn are moved — they cannot be used afterward:

    data := computeData()
    spawn(worker, data)
    println(data)          // compile error: ownership moved to child

Constants and channels are exempt.

What Cannot Cross the Boundary

RejectedReason
Raw int32, bool, etc.Use moxie.Int32, moxie.Bool, etc.
PointersCannot share memory across processes.
FunctionsCannot serialize code.
InterfacesType erasure prevents serialization.

12. Managing Spawned Processes

Fan-Out Pattern

Spawn multiple workers, collect results:

package main

import "moxie"

func worker(id moxie.Int32, results chan moxie.Int32) {
    // Simulate work
    results <- moxie.Int32(int32(id) * int32(id))
}

func main() {
    n := 4
    results := chan moxie.Int32{}

    for i := int32(0); i < int32(n); i++ {
        spawn(worker, moxie.Int32(i), results)
    }

    for i := 0; i < n; i++ {
        v := <-results
        println(v)
    }
}

Pipeline Pattern

Chain domains together:

package main

import "moxie"

func stage1(out chan moxie.Int32) {
    for i := int32(0); i < 5; i++ {
        out <- moxie.Int32(i)
    }
    close(out)
}

func stage2(in chan moxie.Int32, out chan moxie.Int32) {
    for {
        v, ok := <-in
        if !ok {
            close(out)
            return
        }
        out <- moxie.Int32(int32(v) * 2)
    }
}

func main() {
    ch1 := chan moxie.Int32{}
    ch2 := chan moxie.Int32{}

    spawn(stage1, ch1)
    spawn(stage2, ch1, ch2)

    for {
        v, ok := <-ch2
        if !ok {
            break
        }
        println(v)      // 0, 2, 4, 6, 8
    }
}

Lifecycle Channels

spawn returns chan struct{} that closes when the child exits. Use it to wait for completion or detect failure:

    done := spawn(worker, args...)

    select {
    case <-done:
        println("worker finished")
    case <-timeout:
        println("worker took too long")
    }

13. The JS Target — Service Workers and the Browser

Moxie compiles to JavaScript for browser deployment. Each domain maps to a BroadcastChannel-isolated context.

Building for the Browser

moxie build -target js/wasm -o output/ .

This produces ES modules in the output directory, including a $runtime/ folder and $entry.mjs.

Service Worker Lifecycle

The JS runtime provides bridge packages for browser APIs:

package main

// JS bridge packages provide typed access to browser APIs.
// Import paths under jsbridge/ map directly to runtime modules.

func main() {
    // DOM operations via bridge
    // WebSocket connections via bridge
    // Service Worker lifecycle via bridge
    // IndexedDB transactions via bridge
}

Bridge packages available: dom (elements, events), ws (WebSocket), sw (Service Worker lifecycle, caching, SSE), localstorage, idb (IndexedDB), crypto (secp256k1), subtle (SubtleCrypto).

How Channels Map to the Browser

Within the JS target:

The compiler automatically marks functions as async when they contain channel operations, propagating transitively up the call chain.

14. Literal Syntax Summary

MoxieEquivalentNotes
"hello"[]byte("hello")String literals produce []byte
a \| bconcat(a, b)Slice concatenation (any matching slice type)
[]T{:n}Slice with length nmake() is not available
[]T{:n:c}Slice with length n, capacity c
chan T{}Unbuffered channel
chan T{n}Buffered channel with capacity n
&T{...}(heap allocation)Replaces new(T)

15. What Moxie Removes

RemovedAlternativeWhy
go f()spawn + channelsHidden execution paths.
new(T)&T{}Redundant.
+ on text\|Unifies slice concatenation.
fallthroughcase A, B:Implicit control flow.
complex64/128Separate float fieldsRarely needed.
uintptrExplicit pointersRaw pointer arithmetic is unsafe only.
import "strings"import "bytes"string = []byte makes strings redundant.
interface{} at spawnmoxie.Codec typesSerialization requires concrete types.

Next Steps