← texastoast

API reference

texastoast v0.5.0, every public class, with the signatures the engine actually ships. Try any of it in the playground.

install

pip install texastoast
pip install "texastoast[sprites]"    # Pillow, for sprite sheets
pip install "texastoast[hardware]"   # smbus2, for I2C on Raspberry Pi
pip install "texastoast[audio]"      # pygame-ce, for a real sound mixer

No extra is required; keyboard-only games work with none of them, and both the hardware and audio layers degrade rather than fail when their extra is absent. Everything below is re-exported from the top-level package:

from texastoast import (
    Config, GameLoop, Game, Scene, SceneStack,
    CanvasRenderer, Camera, SpriteSheet, Renderer, UISurface,
    TileMap, Entity, EntityGroup, AABB,
    InputState, KeyboardInput, MagmaHubInput, CompositeInput,
    InputRecorder, ReplayInput, Player, PlayerManager,
    I2CBus, MagmaHub, ControllerState, HubStats,
    SimBus, simulated_hub, KeyboardHubDriver, HubPoller, scan_buses_async,
    DialogueBox, Menu, HUD, Theme, DEFAULT_THEME, Mixer,
)
Imports are lazy: naming the package does not import tkinter or touch hardware until you actually use a class that needs it.

Game

Owns the window, the canvas, and the game lifecycle.

Game(title="texastoast", width=640, height=480, fps=30,
     config=None, root=None, max_consecutive_errors=10)
memberdescription
.canvasThe tkinter Canvas the renderer draws on.
.rootThe Tk window (or the widget passed as root).
.loopThe GameLoop, once start() has run.
set_update(fn)Register fn(dt), called once per frame with seconds elapsed.
set_render(fn)Register fn(), called after each update.
start()Start the loop and enter the main loop. Blocks until the window closes.
quit()Stop the loop, run teardown callbacks, destroy the window.
on_close(fn)Register a cleanup callback for quit(), e.g. game.on_close(keyboard.destroy).
bind_key(key, cb)Bind a raw tkinter key sequence, e.g. "<Key>".
bind_key_release(key, cb)Same, for key release.

Passing root embeds the game in an existing tkinter app; the caller then owns the main loop and quit() leaves the window standing.

Config & GameLoop

Config

fielddefaultdescription
title"texastoast"Window title.
width / height640 / 480Canvas size in pixels.
fps30Target frame rate.
tile_size16Default tile size.
bg_color"#1a1a2e"Canvas background.
grid_color"#16213e"Debug grid color.
debugFalseDebug flag for your own use.

GameLoop

Game builds one for you; you rarely construct it directly.

Two behaviours worth knowing: dt is clamped to MAX_DT = 0.1 so a background tab or a slow frame cannot explode your physics, and after max_consecutive_errors failing frames in a row the loop stops and re-raises from start() instead of logging the same traceback thirty times a second.

scenes

Modality as a stack instead of a pile of flags. Pushing a scene freezes the scenes below it by construction, so a pause menu needs no paused global and no early-return chain. New in 0.5.0.

Scene

There is no base class to subclass. A scene needs update(dt) and render(); everything else is optional and detected by presence.

class WorldScene:
    def update(self, dt): ...
    def render(self): ...

    # all optional:
    def on_enter(self): ...      # pushed onto the stack
    def on_exit(self): ...       # removed from it
    def on_pause(self): ...      # covered by a push
    def on_resume(self): ...     # re-exposed by a pop
    def handle_key(self, event): ...   # only while this scene is on top

    update_below = True          # the scene underneath keeps updating
    render_below = True          # the scene underneath keeps rendering
Scene is a typing.Protocol for type checking only; at runtime a plain class, or even a SimpleNamespace, works.

SceneStack

stack = SceneStack()

stack.push(scene)          # old top gets on_pause, new scene on_enter
stack.pop()                # top gets on_exit, the exposed scene on_resume
stack.replace(scene)       # exit + enter, no pause/resume
stack.clear()              # on_exit top-down

stack.update(dt)           # applies pending ops, then the active slice
stack.render()             # the visible slice, bottom-to-top
stack.dispatch_key(event)  # -> bool; top scene only

stack.top                  # the current top scene, or None
stack.scenes               # bottom-to-top snapshot
len(stack), bool(stack), scene in stack

Wire it yourself, the stack is a system, not a framework:

game.set_update(stack.update)
game.set_render(stack.render)
game.bind_key("<Key>", stack.dispatch_key)
Every stack operation is deferred to the start of the next update(). One rule, two payoffs: a scene can pop itself mid-update without corrupting the frame, and an op issued from a key event, which tkinter delivers between frames, lands before that frame renders, so Escape shows the pause menu the same frame.

world

TileMap

TileMap(grid, tile_size=16, solid_tiles=None)
TileMap.from_file(path, tile_size=None, solid_tiles=None)
memberdescription
.gridThe rows of tile ids.
.tile_sizePixels per tile.
.rows / .colsMap size in tiles.
.width / .heightMap size in pixels.
.solid_tilesThe set of ids that block movement.
get(col, row) / set(col, row, id)Read or write one tile.
is_solid(col, row)Solidity by grid position.
is_solid_at(wx, wy)Solidity by world pixel position.
to_grid_coords(wx, wy)World pixels → (col, row).
save(path, solid_tiles=None)Write the map as JSON.

Entity

Entity(x=0, y=0, width=16, height=16, speed=1.0)
memberdescription
.x / .yTop-left position in world pixels.
.center_x / .center_yCenter point, what you hand the camera.
.speedPixels per second, not per frame.
.aabbThe entity's bounding box.
move(dx, dy, dt, tilemap=None)Move by a direction vector. With a tilemap, collision is sub-stepped: the entity slides along walls and stops flush against them. Diagonals are normalized.
collides_with(other)AABB overlap test against another entity.
.aliveSet False inside the entity's own update() and an EntityGroup culls it after the pass, no back-reference to the group needed. New in 0.5.0.

EntityGroup

The update loop, plus tags and painter's-order iteration. Membership is duck-typed: anything with update(dt) qualifies, so timers and particles fit without inheriting from Entity. The group never draws; rendering stays yours. New in 0.5.0.

entities = EntityGroup()
player = entities.add(Entity(x=60, y=60), "player")   # returns the entity
entities.add(npc, "npc", "vendor")                    # tags live in the group

entities.update(dt)                  # calls update(dt) on every member
entities.remove(npc)                 # or npc.alive = False
entities.by_tag("npc")               # -> list
entities.select(lambda e: e.x > 80)  # -> list
entities.sorted_by_y()               # by feet line (y + height)
entities.clear()
len(entities), iter(entities), npc in entities
Adds and removes during update() are deferred until the pass ends. Mutating the member list mid-iteration skips the removed entity's neighbour, the classic first bug of every entity system.

rendering

CanvasRenderer

CanvasRenderer(canvas, width, height)
memberdescription
.cameraThe Camera this renderer draws through.
clear()Erase the canvas; call it first in every render().
draw_tilemap(tilemap, tile_colors, skip_tiles=None)Draw the visible region only. Tile ids absent from tile_colors stay transparent.
draw_rect(x, y, w, h, color, tag="")A world-space rectangle.
draw_image(x, y, image, anchor="nw", tag="")A world-space image.
draw_text(x, y, text, **kw)World-space text (scrolls with the camera).
draw_hud_text(x, y, text, **kw)Screen-space text (ignores the camera).

Camera

Camera(width=640, height=480, x=0.0, y=0.0, smoothing=0.1)
camera.follow(target_x, target_y, map_width=0, map_height=0, dt=dt)

Passing map_width and map_height clamps the view to the map edges. smoothing is a per-frame factor calibrated at 30 fps, converted to a time constant and integrated over dt, so the camera lags by the same distance at any frame rate.

Changed in 0.5.0: dt is required. Omitting it raises TypeError (it warned throughout 0.4.x). The no-dt path applied smoothing once per frame, so the camera converged twice as fast at 60 fps as at 30. dt stays last in the signature, so correct 0.4.x calls still work unchanged.

SpriteSheet

Crops frames out of a sprite sheet image. Requires the [sprites] extra (Pillow).

Not available in the browser playground; sprites and image drawing need a desktop Python install.

input

Every input source returns the same InputState snapshot, so game code never knows whether it is reading a keyboard or a controller.

InputState

Booleans up down left right a b start select, plus derived .dx / .dy (−1, 0, or 1) and .is_any_direction.

KeyboardInput

keyboard = KeyboardInput(game.root)
state = keyboard.poll()      # a copy, so you can diff against last frame
keysbutton
Arrows / WASDup, down, left, right
Z, Entera, talk, confirm
X, Backspaceb, cancel
Escape, Pstart, pause
Shiftselect

Also is_pressed(button) and destroy(), which removes every binding it installed; pair it with game.on_close(keyboard.destroy).

MagmaHubInput & CompositeInput

MagmaHubInput reads a Magma Hub controller over I2C. CompositeInput merges several sources, so a game can accept the controller when it is plugged in and the keyboard when it is not:

inputs = CompositeInput(MagmaHubInput(hub), KeyboardInput(game.root))

InputRecorder & ReplayInput

Record any input source to a .ttrec file and play it back. The format stores the I2C protocol's button bitmask, so one recording replays two ways: through the engine as an input source, or through the full hardware stack via the simulator. New in 0.4.0.

recorder = InputRecorder(controls, "session.ttrec")   # transparent wrapper
recorder.start()
game.on_close(recorder.stop)

replay = ReplayInput("session.ttrec")
replay.advance(dt)      # deterministic manual clock — for tests
replay.start()          # or wall-clock playback
replay.poll()           # -> InputState

players

Seats for multi-controller games: join by button press, hotplug handling, and a returning controller that reclaims its own seat. New in 0.5.0.

manager = PlayerManager(max_players=4,
                        join_buttons=("a", "start"),
                        on_join=lambda p: ...,
                        on_leave=lambda p: ...)

manager.add_source(keyboard)   # the keyboard is claimable like any pad
manager.add_hub(poller)        # one seat candidate per hub controller

def update(dt):
    manager.update()                       # join scan + hotplug watch
    for player in manager.joined_players:
        state = player.poll()              # a Player IS an InputSource
memberdescription
player.indexSeat number, 0-based.
player.joinedA source has claimed this seat.
player.activeJoined and currently connected.
player.poll()The seat's InputState, idle while inactive, never the buttons held at the moment of disconnect.
manager.playersEvery seat, joined or not.
manager.joined_playersOnly the claimed ones.
manager.release(player)Manual drop-out; the source returns to the pool.
Joining is edge-triggered; a fresh press claims one seat, so holding the button through a join screen does not claim four. When a controller disconnects its seat goes inactive and polls idle; when it answers again the same seat reactivates, because a bounced cable must not reshuffle who is P1 and who is P2.

ui widgets

All three are frame-driven: call render() every frame and they draw nothing while inactive. Each owns a canvas tag and redraws only its own items.

DialogueBox

DialogueBox(canvas, width=640, height=480, box_height=100,
            padding=12, font=("Courier", 12), speed=0.03)

Menu

Menu(canvas, width=640, height=480, font=("Courier", 14),
     selected_color="#e94560", normal_color="#ffffff",
     disabled_color="#555555", item_padding=8)
menu.show(items, on_select=None, on_cancel=None, title="", selected=0)

HUD

hud = HUD(game.canvas, width=320, height=240, padding=8)
hud.add_stat("health", "HP", value=80, max_value=100, color="#e94560")
hud.add_text("score", "Score: 0", 290, 8, fill="#fdd835")
hud.set_stat("health", 55)
hud.set_text("score", "Score: 120")

Also remove_text(key) and clear(). Each stat draws a labelled bar with a value readout; each text is free-floating at the coordinates you give it.

Theme

All three widgets take a theme=. Before 0.5.0 the palette was string literals scattered across the widget files; now it is one frozen dataclass.

from dataclasses import replace
from texastoast import DEFAULT_THEME, Theme

ocean = replace(DEFAULT_THEME, primary="#4fc3f7", selection_fill="#112233")
dialogue = DialogueBox(renderer, theme=ocean)
menu = Menu(renderer, theme=ocean)
hud = HUD(renderer, theme=ocean)

ocean.font(10, "bold")   # -> ("Courier", 10, "bold") in the theme's family

Fields: primary, text, dim_text, label_text, disabled, box_fill, box_outline, outline_width, selection_fill, bar_fill, bar_outline, font_family.

DEFAULT_THEME carries exactly the pre-0.5.0 hardcoded values, so a game that never mentions themes renders identically. Explicit style kwargs still beat the theme. Layout metrics are deliberately not theme fields; layout is not theme.

audio

Sound with the same degradation contract as the rest of the engine: no method ever raises into your frame, and a machine with no audio device runs the game identically, just silently. New in 0.5.0.

mixer = Mixer()                    # best backend available; never raises
game.on_close(mixer.close)

mixer.load("jump", "assets/jump.wav")
mixer.load("theme", "assets/theme.wav", volume=0.6)
mixer.play_music("theme")          # one music slot; loops; replaces previous
mixer.play("jump")                 # fire-and-forget SFX
mixer.play("jump", volume=0.4)     # per-play override
mixer.set_master_volume(0.8)       # effective = master x load x override
mixer.stop_music(); mixer.stop_all()
mixer.backend_name                 # which tier won
backendgets you
pygameReal mixing, seamless loops, per-channel volume. Needs the [audio] extra.
winsoundWindows built-in. One sound at a time, SFX-grade.
aplay / afplayLinux/Pi and macOS command players. A process per sound; loops respawn with an audible seam.
nullEvery call is a silent no-op.
WAV is the guaranteed format on every tier. A missing file logs a warning at load and plays as silence; an absent asset must not kill the game. Inject a fake backend with Mixer(backend=...) to test audio without a device.

i2c hardware

For magmacrunch's Raspberry Pi cabinets. Requires the [hardware] extra (smbus2) and real hardware; on any other machine the classes import fine and simply report nothing connected, which is what lets CompositeInput fall back to the keyboard.

hardware dev kit

You should not need the hardware to build for the hardware. SimBus implements the smbus2 surface that I2CBus calls, so injecting it runs the real stack, protocol handshake, hub, ControllerState parsing, input adapters, against imaginary controllers. New in 0.4.0.

from texastoast import simulated_hub
from texastoast.i2c.protocol import BTN_A

hub, sim = simulated_hub()      # a real MagmaHub over a simulated bus
sim.press(BTN_A)
hub.poll()[0].a                 # True — no wires involved

sim.fail_next_reads(3)          # a loose wire, on demand
sim.set_read_delay(0.05)        # a slow bus
sim.disconnect_hub(0x08)        # a hotplug
sim.reconnect_hub(0x08)

The simulator is deliberately strict about the protocol: a block read with no preceding select-write raises, exactly as the Pico firmware refuses it. A lenient fake would keep passing even if the engine stopped sending the handshake.

texastoast-bench

A console script installed with the package, live per-controller button state, raw protocol bytes, connection status, poll latency and read-error rates.

texastoast-bench            # scan for hubs; simulator mode if none answer
texastoast-bench --sim      # force the simulator (keyboard drives controller 0)
texastoast-bench --record session.ttrec
A session recorded against real firmware replays through SimBus.play_recording() as a regression test that runs on any machine.

magmascript binding

Installing texastoast alongside magmascript 3.2+ registers the engine as the texastoast domain, aliased tt. Neither package depends on the other; discovery is through an entry point.

callreturns
tt.game(opts)Game, keys: title, width, height, fps, max_consecutive_errors
tt.renderer(game, w, h)CanvasRenderer
tt.keyboard(game)KeyboardInput
tt.tilemap(grid, tile_size, solid)TileMap
tt.entity(opts)Entity, keys: x, y, width, height, speed
tt.dialogue(game, opts)DialogueBox, keys: width, height, box_height, padding, speed, theme
tt.menu(game, opts)Menu, keys: width, height, selected_color, normal_color, disabled_color, item_padding, theme
tt.hud(game, opts)HUD, keys: width, height, padding, theme
tt.scenes()SceneStack, wire g.set_update(s.update) yourself
tt.entities()EntityGroup
tt.theme(opts)Theme, any of the 12 theme fields; omitted ones keep the defaults
tt.sprite_sheet(path, fw, fh)SpriteSheet, frames via get_frame(g.root, col, row)
tt.mixer()Mixer, wire g.on_close(m.close)
tt.players(opts)PlayerManager, keys: max_players, join_buttons
tt.hub(opts) / tt.hubs(opts)A MagmaHub, or every hub found by a scan
tt.sim_hub(opts)A simulated hub, the SimBus rides along as h.sim
tt.hub_input(hub, i) / tt.composite(kb, pad)The input adapters
tt.poller(hub, opts)HubPoller, wire g.on_close(p.stop)
tt.recorder(src, path) / tt.replay(path, opts)Record and replay a session
tt.version()The installed version string.
g = tt.game({"title": "hello from magmascript", "width": 400, "height": 300, "fps": 30})
r = tt.renderer(g, 400, 300)
kb = tt.keyboard(g)
world = tt.tilemap([[1,1,1,1,1],[1,0,0,0,1],[1,1,1,1,1]], 20, [1])
player = tt.entity({"x": 40, "y": 30, "width": 14, "height": 14, "speed": 100})

update = fn(dt) {
    s = kb.poll()
    player.move(s.dx, s.dy, dt, world)
    if s.a { player.speed = 220 } else { player.speed = 100 }
    r.camera.follow(player.center_x, player.center_y, world.width, world.height, dt)
}
Two deliberate differences from the Python API: options arrive as a dict rather than keyword arguments, because MagmaScript has no keyword-argument syntax, and an unknown key raises rather than silently defaulting, since a typo is otherwise invisible. Engine objects are returned bare, so player.x in a script is the same attribute as in Python and player.speed = 220 writes through.