Skip to content

MicroPython Programming Guide

unknown edited this page Aug 3, 2026 · 1 revision

中文 · English

Writing Python In-Game — A Getting-Started Guide

For players who already know a bit of Python / MicroPython. This skips the language basics and covers just two things: how this differs from the Python you already know, and how to drive the components in the game (screens, keyboards, machinery, AE2 networks, and so on).


1. Overview

What runs in the game is a MicroPython implemented purely in Java. The bytecode format is compatible with standard MicroPython and the core language is almost the same, but it runs inside a controlled sandbox, designed specifically to be "saveable and rate-limited per tick".

The parts you can treat as ordinary Python: integers (including arbitrary-precision big integers), floats, the full set of string methods, list/dict/set/tuple, functions/closures/ classes/inheritance/decorators, generators (yield/send/yield from), exceptions, with, comprehensions, f-strings, exec/eval, isinstance/sorted/enumerate/ zip/map/filter, and so on.

Three key differences to keep in mind up front:

  1. There is no real operating system layer: files, screens, keyboards and machinery are not part of the Python standard library; you reach game components through the injected global objects component and computer (see section 3).
  2. Rate-limited per tick: your script can only execute a fixed number of instructions per game tick (see section 5). An infinite loop will not freeze the server, but spinning idly will get you throttled. You need to actively yield.
  3. Saveable at any moment: the entire running state can be frozen into the save file and resumes in place after loading a save. This means you don't have to worry about losing progress when you quit the game, but it also means some things (see section 6) behave differently from ordinary Python.

2. A checklist of differences from ordinary Python

Available, but worth noting

  • Both the import statement and dynamic imports via __import__("x") work. Dynamic import is a first-class function: you can store it in a variable, use it in a loop, and select a module by variable name: m = __import__(name).
  • exec / eval are available. There are also the non-standard exec_sandbox / eval_sandbox: reads fall through to the outermost globals, writes stay local (the shell's per-command isolation is implemented on top of this). Plain exec has the standard isolation semantics.
  • The full set of set operations: difference a - b, intersection a & b, union a | b, symmetric difference a ^ b are all supported (they return a new set and do not modify the original).
  • Generator expressions can be passed straight to built-in functions: ",".join(str(x) for x in xs), sum(x*2 for x in xs), sorted(x for x in xs), set(...) all work; no need to convert to a list first.
  • bool is not a subclass of int, range compares by identity, and dict/set are insertion-ordered. These match MicroPython (not standard CPython).
  • Slicing is fully available: reading (including strides, xs[::2]/xs[::-1]), assignment (xs[a:b] = [...], length may change; strided assignment requires equal lengths), and deletion (del xs[a:b]); range can be sliced too (returning a new range).
  • property is complete: @x.setter / @x.deleter / property(fget, fset, fdel) are all supported.
  • Function attributes are readable and writable: f.attr = x and f.__name__ both work (functools.wraps relies on this).
  • bytes.join is available: b",".join([b"a", b"bc"]) (items may be bytes or bytearray).
  • list.sort(key=..., reverse=...) supports keyword arguments just like sorted; sequence repetition works in both directions ([0] * 3 and 3 * [0]); x in range(...) is an O(1) arithmetic test, and x in b"abc" supports both int membership and substrings.
  • An uncaught SystemExit at the top level is a clean exit (the same as standard Python): it is not treated as an error and prints no traceback; inside a thread it only ends that thread (_thread.exit() is implemented exactly this way).

Not available

  • frozenset, memoryview (no need to implement them).
  • Standard open / input (file access goes through os or component, see below).
  • Networking, preemptive threads, hyperbolic/special math functions.

Added built-ins / types

  • callable(x), id(x), hash(x), __import__(name, ...).
  • bytearray: a mutable byte sequence backed by a Java array. Constructors: bytearray() / bytearray(n) / bytearray(b"..") / bytearray([65,66]) / bytearray(i for i in range(4)) / bytearray("AB","utf-8"); mutable indexing ba[0]=90, slicing, del ba[i], append/extend/pop/clear/copy/decode/hex; == can compare across types with bytes.

Available standard library modules (24)

_thread   binascii  cmath     collections  deflate  functools  gc       hashlib  heapq
io        jasyncio  json      machine      math     micropython  os     pickle   random
re        struct    sys       threading    time     traceback

Most modules behave the same as standard MicroPython. re is more capable than the official tiny-re (it supports {n,m} quantifiers and groups()/span()/start()/ end()). functools provides reduce/partial/lru_cache (with cache_info/ cache_clear)/wraps/cmp_to_key. os has a complete filesystem under mpyos (see the "Working with files" section).

_thread is MicroPython's low-level thread interface (start_new_thread / allocate_lock / LockType / get_ident / exit / stack_size), which makes it easy to bring existing MicroPython code over. It is not a separate mechanism from threading_thread is just a thin layer on top of threading, both share the same thread pool, and they can be mixed. The behaviour matches upstream: get_ident() returns each thread's own real ident; an uncaught exception in a thread prints the official-format Unhandled exception in thread started by ... plus a traceback, takes down only that thread, and leaves the others alone; stack_size() returns the old value per the official contract (the value is recorded but has no actual effect — threads here do not have separate stacks). Of the official tests/thread suite, 32 of 33 tests pass (the remaining one requires a real stdin, which upstream likewise skips on ports that don't support it).

machine only provides the parts that can be given real semantics: disable_irq() / enable_irq(state). In this VM the "interrupt" is the scheduler itself, so the semantics are exact — while IRQs are disabled, only the thread/main program that disabled them runs, everything else gives way, and the state token can be nested. You can use it to build a hard critical section across threads (heavier than a Lock; use it with care: while interrupts are off, even coroutines and other threads' timers stop advancing). Anything that cannot be given real semantics — pins, timers, reset and the like — is simply absent rather than faked.

micropython provides schedule(func, arg), which queues a callback to run at the next scheduling point rather than immediately — handy when you want to hand work off to the event loop from anywhere. As upstream, the queue holds at most 8 entries; when it is full it raises RuntimeError: schedule queue full (which gives you backpressure when production outruns consumption). heap_lock / heap_unlock / mem_info exist but do nothing: this VM runs on Java, and there is no interpreter heap to lock.

The following two differ substantively from the standard library modules you know, so they get their own sections.

jasyncio — not standard asyncio; play by its rules

This is the project's own coroutine runtime. It is not CPython's asyncio, and it is not MicroPython's uasyncio either. Most of the API names will look familiar, but scheduling is driven by the host's per-tick ops budget, and there are several key differences:

  • The import name is literally jasyncio (not asyncio): import jasyncio.
  • What it has: run(coro), create_task(coro), gather(*aws, return_exceptions=False), wait(aws, timeout=, return_when=), wait_for(aw, seconds) / wait_for_ms, sleep(s) / sleep_ms(ms), to_thread(func, ...), current_task(), Future, Task, Event, Lock, Loop, get_event_loop() / new_event_loop() / set_event_loop(), CancelledError / InvalidStateError / TimeoutError.
  • create_task gives you a Task object: you can call t.done() to see whether it has finished, t.cancel() to cancel it, t.cancelled() to see whether it was cancelled, and await t to get its return value. Cancellation is cooperative: once the flag is set, CancelledError is raised at the line where the coroutine next suspends, so you can use try / except CancelledError to clean up.
  • Scheduling is cooperative round-robin: every run() has its own private coroutine pool (tasks created by create_task join the pool of whoever called it; calling create_task directly in the main program or in a thread puts the task into a shared default pool). When a coroutine's turn comes within a pool, it runs until it awaits (or finishes) before the next one gets a turn; to the VM, the whole pool is one scheduling unit. There is no preemption — a coroutine stuck in an infinite loop will not be forcibly interrupted, but it will burn through the budget its pool was allotted for that tick.
  • sleep_ms(ms) uses real wall-clock time (System.currentTimeMillis), but only advances while the VM is alive and being driven: time that passes while the world sits in a save file does not count — on loading a save, each timer's baseline is reset to "how much is left". So a sleep(5) across a save-and-quit will not wake up immediately just because you were away for an hour.
  • run() runs the top-level coroutine to completion and returns its result; gather runs a group concurrently and collects the results in order; to_thread schedules a synchronous blocking function as an independent (thread-style) task and returns an awaitable.
  • You can have several run()s going: one in the main program and one in each thread is fine — each run() is an independent event loop (with an independent coroutine pool), and they do not interfere with each other. Calling run() from inside a coroutine is still an error (RuntimeError), just as in asyncio.
  • When run() returns, any tasks in that loop that have not finished are cancelled (the same as CPython's asyncio.run): each cancelled task receives a CancelledError at the point where it was suspended, except/finally get a chance to clean up, and only then does run() return. If you want a task to outlive run(), hand it to a thread.
  • Threads and coroutine pools live in the same VM task pool, but they are not equals: each threading.Thread / to_thread thread is an independent task in the pool and interleaves freely at budget boundaries; a group of coroutines (one run, or the default pool) collectively occupies a single slot. The way you write the code differs too — see the next section.
  • Coroutines in the same pool never see each other's intermediate state: switching only happens at await, and the whole group of coroutines is one scheduling unit — even if one coroutine in the group is interrupted by the budget between two awaits, or goes off to make a component call, what is paused is the entire group, and the other coroutines in it still cannot slip in. This is a structural guarantee, not a matter of luck. (Threads have no such guarantee; a thread may see someone else's work half-done at any moment — that is exactly what thread semantics mean. The same goes for two coroutine groups belonging to different run()s: to each other they behave like threads.)

Threads or coroutines — which should you use? Stackful vs stackless

From a scheduling standpoint the two are the same thing; the difference is where you can suspend from:

threading threads are "stackful": the entire call stack is saved, so you can suspend inside an ordinary function at any nesting depth. This works:

import threading, time

def deep():
    time.sleep(0.5)        # sleep right inside a plain function: the whole thread suspends, other threads keep running
def middle(): deep()
def worker(): middle()

threading.Thread(target=worker).start()

Three levels of ordinary functions, worker → middle → deep; when deep sleeps, the whole stack is preserved as-is, and when its turn comes round it continues from that line. You do not have to change a function's signature just because it "might suspend".

jasyncio coroutines are "stackless": only an await inside an async def is a suspension point. The ability to suspend does not propagate through ordinary functions — if you want deeply nested code to suspend, every layer between it and the coroutine must be an async def, and every layer must await. This is what is usually called "function colouring":

async def deep():      # every layer must be async
    await jasyncio.sleep(0.5)
async def middle(): await deep()      # every layer must await
async def worker(): await middle()

Which to pick:

  • To put existing synchronous code (library functions, business logic with layers of calls) into the background — use threading; no need to rework the call chain.
  • To write an explicit concurrency flow, where you want "where it can suspend" to be visible at a glance in the code — use jasyncio; await is that marker.
  • The two can be mixed: a thread can start its own run(), and a coroutine can use to_thread.

A small trap: if you write await inside an ordinary function, the MicroPython compiler does not report an error (CPython raises SyntaxError); it quietly turns that function into a generator, so calling it just gives you a generator object and the code inside never runs. So don't count on the compiler to catch this for you — remember that "await is only valid inside async def".

A simple case:

import jasyncio

async def worker(n):
    await jasyncio.sleep(0.5)
    return n * 2

async def main():
    results = await jasyncio.gather(worker(1), worker(2), worker(3))
    print(results)          # [2, 4, 6]

jasyncio.run(main())

pickle — a private format, not interchangeable with CPython

Standard MicroPython has no pickle at all; this one is provided additionally by this project. But note:

  • Its byte format is private (underneath it is a Java binary writer, not CPython's pickle opcode stream; the protocol version is deliberately set to the invalid value -1 so that CPython cannot misread it). So what it dumps cannot be fed to real CPython pickle, and vice versa — it is only self-consistent inside this VM; don't use it to exchange data with external programs.
  • What can be pickled: None, bool, int, float, complex, str, bytes, tuple, list, dict, set, range, and namedtuple. Shared and circular references are correctly restored via back-references.
  • What cannot be pickled: functions, classes, generators. To save "live execution state", use a VM snapshot (the save file), not pickle.
  • The API is just dumps(obj) / loads(data).
import pickle
blob = pickle.dumps({"hp": 20, "pos": (1, 2, 3)})
data = pickle.loads(blob)

3. Driving game components

This is the biggest difference from ordinary Python, and it is what you actually want to do. There are two injected global objects, usable directly without importing: component and computer.

component: accessing external devices

# 1) List the addresses of components of a given type
for addr in component.list("filesystem"):
    print(addr)

# 2) "Primary component" shorthand: component.<type> gives you a proxy for the first component of that type
gpu = component.gpu
gpu.set(1, 1, "Hello")            # call its methods directly
w, h = gpu.getResolution()

# 3) Get a proxy by address
disk = component.proxy("hdd-1")   # or any address from component.list
disk.makeDirectory("/data")

# 4) Direct invoke(address, method name, args...)
component.invoke("hdd-1", "exists", "/init.py")

# 5) Exploring: which methods does a component have
component.methods("hdd-1")        # list of method names
component.doc("hdd-1", "read")    # docstring of a given method

Arguments are just ordinary Python values: numbers, strings, booleans. For parameters that need a Lua table, pass a dict or list directly — it is converted to a Lua table automatically:

me = component.me_interface
items = me.getItemsInNetwork({"name": "minecraft:iron_ingot"})   # dict = Lua table

Return values: mostly ordinary values/lists/dicts. If a method returns an iterable OC "userdata" (AE2's getItemsInNetwork, for example), you get a wrapper object, and you can just for-iterate over it:

for stack in items:          # lazy iteration: only one item is converted to a Python object at a time
    print(stack)

This is lazy — each pass through the loop converts and processes exactly one element, rather than pulling the whole network into memory at once. So even if the AE2 network holds thousands of item types, peak memory is a single item, and you can break out early at any time (elements that are never consumed are never converted).

If you really do want a complete Python list (to call len(), sort, or iterate multiple times), use .to_list() to pull everything in one go (use with care on large networks: it converts every item into a Python object and takes memory); there is also .count() if you just want to ask for the total first, and .getAll() to get the raw table.

n = items.count()            # check the size first
all_items = items.to_list()  # pull everything at once (use with care on large networks)

computer: the machine itself

computer.uptime()          # seconds since boot (use this for timeouts, not time; see below)
computer.address()         # this machine's address
computer.beep(1000, 0.2)   # make a beep
computer.pullSignal(1)     # wait for a signal, blocking at most 1 second (keyboard/timers etc. are all signals)
computer.pushSignal("my_event", 1, 2)
computer.shutdown(True)    # True = reboot
computer.ops()             # how much instruction budget is left this tick (see section 5)

Working with files: the os module

mpyos fits the standard os module with a filesystem API; just import os and use it — you never have to touch the low-level component.filesystem. Relative paths are resolved against os's own working directory, and /mnt/<disk name>/... is routed automatically to other mounted disks.

import os

os.getcwd()                       # current working directory
os.chdir("/data")                 # change directory
os.listdir("/")                   # directory contents (bare names, no slashes)
os.mkdir("/data")                 # create a directory (OSError if it already exists)
os.makedirs("/a/b/c", exist_ok=True)  # create each level
os.rmdir("/data")                 # remove an [empty] directory
os.remove("/data/x.txt")          # remove a [file] (use rmdir for directories)
os.rename("/a.txt", "/b.txt")     # rename/move
st = os.stat("/data/x.txt")       # (mode, ..., size, atime, mtime, ctime)
                                  # mode: 0x4000 directory / 0x8000 file; times are in seconds
os.statvfs("/")                   # space info (1-byte blocks: total/free/...)

# os.path is here too
os.path.join("/data", "logs", "a.log")   # POSIX semantics: a later absolute segment resets the path
os.path.exists(p) / isdir(p) / isfile(p) / getsize(p)
os.path.dirname(p) / basename(p) / split(p) / splitext(p) / abspath(p)

Two deliberate tightenings that match CPython: os.remove only removes files, never directories; os.rmdir only removes empty directories. This blocks OC's low-level remove from deleting recursively — recursive deletion is the shell rm's job, not os's.

os.urandom / os.uname are still the originals (from the frozen module) and have not been overridden.

Networking: requests / urllib / socket

Install OC's internet card and mpyos can go online. All three libraries are bundled by default — they will import fine even with no card installed; it is only at the moment you actually send a request that OSError("no internet card installed") is raised. So a script can put import requests at the top of the file as usual, and use try/except to determine whether a card is present.

import requests

r = requests.get("https://example.com/api", params={"q": "苦力怕"})
print(r.status_code, r.reason, r.ok)   # 200 OK True
print(r.headers.get("content-type"))   # header names are case-insensitive
print(r.text)                          # decoded per charset; r.content is bytes
data = r.json()

requests.post("https://h/x", json={"a": 1})          # sets Content-Type automatically
requests.post("https://h/x", data={"a": "1 2"})      # form-encoded
requests.get(url, headers={"X-Token": "..."}, timeout=10)

# Don't read a large file into memory all at once:
with requests.get(url, stream=True) as r:
    for chunk in r.iter_content(4096):
        ...

urllib is the standard library's version, where urlopen returns a file-like object:

from urllib.request import urlopen
from urllib.parse import urlencode, urlparse, urljoin, quote
import urllib.error

try:
    with urlopen("https://example.com/") as f:
        print(f.status, f.getheader("content-type"))
        print(f.read(100))
except urllib.error.HTTPError as e:
    print("HTTP", e.code, e.msg)

urllib.parse is pure Python and touches no components, so you can use it to build URLs even without a network card.

Raw TCP goes through socket (only SOCK_STREAM; OC's card does not support UDP):

import socket

s = socket.create_connection(("example.com", 80), timeout=5)
s.sendall(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
print(s.recv(1024))
s.close()

A few OC limitations you must know about:

  • Non-2xx responses have no body. OC's underlying implementation discards the body of an error response outright, so on a 404/500 you still get r.status_code / r.reason / r.headers, but r.content is always b"". If you want an exception, call r.raise_for_status().
  • A timeout is mandatory. requests defaults to 30 seconds (in-game time) rather than CPython's "wait forever" — otherwise one dead server could hang the computer indefinitely. While waiting, it yields per tick, and time during which the chunk is not loaded does not count.
  • HTTP / TCP can be turned off by server configuration, in which case you get a requests.ConnectionError or an OSError, with a message from OC such as "http requests are unavailable".
  • socket.send() may write only part of the data (just like a real socket), so you have to loop; sendall() already does the looping for you.

There is a ready-made wget on the command line:

wget https://example.com/data.json
wget https://example.com/x.bin /mnt/a1b/x.bin

4. Multiple return values from components

OC's methods were originally designed for Lua and often return several values at once. The bridging layer's rules are:

Method returns What you get
Several values A Python tuple, which you can unpack directly
Exactly one value That value itself (not a 1-element tuple)
Nothing None
ch, fg, bg = gpu.get(1, 1)          # 3 values → unpack
w, h = gpu.getResolution()          # 2 values → unpack
addr = computer.address()           # 1 value → it's just the string; don't write addr, = ...
#ch, fg = gpu.get(1, 1)             # you can't discard values like in Lua  ValueError: too many values to unpack
#ch, fg, bg, more = gpu.get(1, 1)   # missing values are not padded with None like in Lua  ValueError: not enough values to unpack
nothing = computer.beep(1000, 0.2)  # this gives you None

Lua's failure convention: many methods return two values (None, "error message") on failure, and only one on success. So to be strict you have to check first:

r = fs.open("/nope", "r")
if isinstance(r, tuple):            # failure: (None, error message)
    handle, err = r
else:
    handle = r                      # success: a single handle

The other kind of failure: raising an exception. The above covers a method "returning a failure value normally"; separately, a component may report an error directly, in which case a standard Python exception is raised, classified by cause (matching how OpenComputers classifies the same kinds of errors), so you can catch it precisely:

Cause of failure Exception Typical message
Bad argument ValueError the component's message, defaulting to bad argument
Index out of bounds IndexError index out of bounds
No such method AttributeError no such method
Operation not supported NotImplementedError unsupported operation
File does not exist OSError file not found
Permission denied OSError access denied
Read/write failure OSError the component's message, defaulting to i/o error
Anything else RuntimeError the component's message, defaulting to unknown error
try:
    component.invoke(addr, "someMethod", 1, 2)
except OSError as e:        # file / permission / IO
    print("IO failed:", e)
except ValueError as e:     # bad argument
    print("bad argument:", e)

In Lua, some of these two categories are "return nil, reason". Python has no such idiom; the equivalent is exceptions — so we raise uniformly, but split by type, instead of mashing everything into a single error.

Calling too often is not an error: when a component's per-tick call budget is exceeded, the system automatically defers the call to the next safe opportunity and retries it, and the script never notices (consistent with OpenOS).

lua(): the lazy way, unpacking Lua-style

If checking as above is too much trouble, or you are not sure how many values a method returns, wrap it in the special built-in lua() — when unpacking, missing values are filled in with None and extra ones are dropped, so a count mismatch never raises:

a, b, c = lua(gpu.get(1, 1))        # 3 values → fine
a, b, c = lua(computer.address())   # single value → a=address, b=c=None
a, b, c = lua(comp.noret())         # no return → all None
a, b    = lua(gpu.get(1, 1))        # 3 values unpacked into 2 → the 3rd is dropped

lua()'s leniency applies only to values it has wrapped; what lua returns is a special tuple. Unpacking an ordinary tuple with the wrong count still raises.


5. Example: screen and keyboard

The screen goes through the GPU component and the keyboard through signals. The one thing relevant to ordinary Python is that GPU methods often return several values at once (see the "Multiple return values from components" section); just unpack them directly.

gpu = component.gpu
gpu.setForeground(0xFFFFFF)
gpu.set(1, 1, "white text")

# Many GPU methods return several values at once; just unpack
w, h = gpu.getResolution()
ch, fg, bg = gpu.get(1, 1)

Keyboard events arrive as signals. The key_down signal is a 4-field tuple:

sig = computer.pullSignal(5)      # wait at most 5 seconds
if sig is not None:
    name = sig[0]
    if name == "key_down":
        _, kb_addr, char, code = sig   # 4-tuple: name, keyboard address, character code, scan code
        if char == 9:                  # Tab
            ...

If you are writing command scripts in MPYOS's shell, the system also injects the more convenient tty / sh / args (tty.print(...), sh.fs.read_file(...), and args for command-line arguments), so you don't have to touch the GPU yourself.


6. Per-tick rate limiting: you have to yield

Your script only executes a fixed number of instructions per game tick (this budget is called ops, and by CPU tier it is 500 / 2000 / 16000 instructions per tick). Unused budget accumulates (up to 10 times the per-tick amount), so when idle you are encouraged to yield rather than spin — once you have banked enough, you can burst and run for a long time.

How this tick's ops are divided

The total budget for a tick (this tick's amount plus what has been banked, capped at 10x) is divided inside the VM like this:

  • When there are no runnable background tasks: the whole budget goes to the main program (your main script).
  • When there are runnable background tasks: it is split evenly by seats — each of the n runnable tasks takes one seat, and the main program also takes only one seat (n+1 seats in total). The main program no longer has any privilege: with two busy threads running, your main script gets only about a third. Whatever a task does not use spills over to the main program to continue using; sleeping tasks do not occupy a seat.
  • What counts as a seat: each thread is one seat; a group of coroutines (one run, or the default pool) is one seat collectively, and within the group the group's scheduler rotates among the coroutines.
  • While jasyncio.run(coro) is blocking: the main program is stuck waiting on that loop, so the entire budget goes into advancing the background tasks until it produces a result, which is then handed back to the main program to continue.

To check how much is left:

  • computer.ops() — the total remaining for this step of this tick (including the bank).
  • computer.ops_share() — the remaining amount of the current share (the main program's share, or the share allotted to a given coroutine; with no async running the two are equal).
  • The command line has an ops command to look at it directly.

The key point: don't spin, yield

A bare while True: pass will burn ops continuously and be throttled hard. When you need to wait for something, use computer.pullSignal(timeout) or time.sleep(...) — they yield the rest of the tick, letting the unused ops accumulate in the bank so that you can run longer next time.

import time
while True:
    do_one_step()
    time.sleep(0.05)      # yield this tick; it doesn't really sleep the full time, it's a cooperative yield

Use computer.uptime() for timing; don't rely on the absolute value of time.time() — the game clock and the real-world clock are not the same thing:

deadline = computer.uptime() + 3      # 3 seconds from now
while computer.uptime() < deadline:
    time.sleep(0.05)

The compilation backend is transparent to you

Source code has to be compiled to .mpy before it can run. There are two compilers (the built-in pure-Java one and the bundled mpy-cross), switchable under Mods → mpy4oc → Config, so this choice does not affect how you write Python, only "who does the compiling".

Two kinds of CPU: synchronous vs asynchronous

The mpy processor has two execution models, corresponding to four items (CPU and APU each come in synchronous and asynchronous versions, in 3 tiers each):

  • Synchronous CPU / APU: the VM runs entirely on the main server thread. The benefit is that every component call has zero latency (it executes directly, without waiting for the next tick), and that execution is guaranteed every tick — choose this for scripts that are timing-sensitive and have to do work every tick (real-time displays, control logic with precise timing). The cost is that all computation consumes main-thread tick time.
  • Asynchronous CPU / APU: the VM runs on OC's compute thread and only returns to the main thread when it hits a non-direct component call. The benefit is that pure computation does not occupy the main thread, easing the load on server TPS; it suits scripts that "compute a lot, call components rarely, and are not strict about timing". The cost is that a non-direct call has a round-trip latency of up to one tick (the same as vanilla Lua: each tick digests a limited number of non-direct calls, more at higher tiers).

The Python code for the two is exactly the same; the ops budget, the bank, and yielding via pullSignal/sleep all apply equally — the only difference is which CPU item is installed.

You do not have to do anything for the asynchronous case: which component methods must run back on the main thread is determined automatically by the system (it checks the method's direct flag on each call), and whether you use component.invoke, component.proxy(addr).method(), component.<type>.method(), or make the call from inside a coroutine, it is all handled correctly. Asynchrony only affects speed, not how you write code. Most scripts should just use the synchronous version; it is only worth switching to the asynchronous one when you have a computation-heavy background script that you don't want to slow the server down. The APU versions come with a built-in graphics card (saving a GPU slot); the asynchronous APU is especially good value, because graphics card calls (gpu.set and friends) are direct and therefore run right on the compute thread with no round trip.


7. Save-file caveats

The entire VM state is stored in the save file and fully restored when the save is loaded: variables, the call stack, generators, coroutines, and the component proxies and wrapped Values you obtained from components — all of it is frozen, and after loading a save it continues in place from the moment you saved, rather than starting over.

This brings one thing to keep in mind when writing scripts: do not assume your script runs from the beginning. If you want some initialization to happen exactly once, on the "genuine first startup", you have to determine that yourself (for example by leaving a marker file on disk), because loading a save will not re-run it — it picks up from where it was suspended. Conversely, this also means you don't have to worry about losing progress when you quit the game: a long task that was half-finished will run to completion after the save is loaded.

Time is "frozen with the save" too, and no timer counts the time you were offline:

  • time.sleep / time.ticks_ms / time.time run off the server tick clock (computer.uptime), which does not advance while saved, while the chunk is unloaded, or while the server is down;
  • jasyncio.sleep and the coroutine pool's timers are accounted for in "milliseconds remaining" and only decrement while the VM is actually being driven.

So if time.sleep(5) is half done when you save and quit, and you come back an hour later, it will finish the remaining part — it will neither wake up immediately nor wait any extra. Do not try to use sleep to measure real-world time.


8. A complete small example

A script that shows a clock on screen and quits when you press Q. It brings together primary components, unpacking multiple return values, a signal loop, and yielding:

gpu = component.gpu
w, h = gpu.getResolution()
gpu.setForeground(0x00FF00)

running = True
while running:
    # Draw the current uptime
    text = "uptime: {:.1f}s   (press Q to quit)".format(computer.uptime())
    gpu.set(1, 1, text + " " * (w - len(text)))

    # Wait for a signal, at most 1 second; we yield meanwhile, so ops accumulate
    sig = computer.pullSignal(1)
    if sig is not None and sig[0] == "key_down":
        _, _, char, code, *_ = sig      # trailing *_ : the signal may carry a player name
        if char == ord("q") or char == ord("Q"):
            running = False

gpu.set(1, 2, "bye")