texastoast v0.5.0, every public class, with the signatures the engine actually ships. Try any of it in the playground.
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,
)
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)
| member | description |
|---|---|
.canvas | The tkinter Canvas the renderer draws on. |
.root | The Tk window (or the widget passed as root). |
.loop | The 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.
| field | default | description |
|---|---|---|
title | "texastoast" | Window title. |
width / height | 640 / 480 | Canvas size in pixels. |
fps | 30 | Target frame rate. |
tile_size | 16 | Default tile size. |
bg_color | "#1a1a2e" | Canvas background. |
grid_color | "#16213e" | Debug grid color. |
debug | False | Debug flag for your own use. |
Game builds one for you; you rarely construct it directly.
.fps, measured frame rate, updated once a second..frame_count, frames since the last measurement..error, the exception that stopped the loop, if one did.start() / stop(), the loop reschedules itself via after().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.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.
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.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)
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.TileMap(grid, tile_size=16, solid_tiles=None)
TileMap.from_file(path, tile_size=None, solid_tiles=None)
| member | description |
|---|---|
.grid | The rows of tile ids. |
.tile_size | Pixels per tile. |
.rows / .cols | Map size in tiles. |
.width / .height | Map size in pixels. |
.solid_tiles | The 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(x=0, y=0, width=16, height=16, speed=1.0)
| member | description |
|---|---|
.x / .y | Top-left position in world pixels. |
.center_x / .center_y | Center point, what you hand the camera. |
.speed | Pixels per second, not per frame. |
.aabb | The 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. |
.alive | Set 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. |
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
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.CanvasRenderer(canvas, width, height)
| member | description |
|---|---|
.camera | The 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(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.
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.set_position(x, y), snap, no smoothing.world_to_screen(wx, wy) / screen_to_world(sx, sy)is_visible(x, y, w, h), cull test.Crops frames out of a sprite sheet image. Requires the [sprites] extra (Pillow).
Every input source returns the same InputState snapshot, so game code never
knows whether it is reading a keyboard or a controller.
Booleans up down left right a b start select, plus derived
.dx / .dy (−1, 0, or 1) and .is_any_direction.
keyboard = KeyboardInput(game.root)
state = keyboard.poll() # a copy, so you can diff against last frame
| keys | button |
|---|---|
| Arrows / WASD | up, down, left, right |
| Z, Enter | a, talk, confirm |
| X, Backspace | b, cancel |
| Escape, P | start, pause |
| Shift | select |
Also is_pressed(button) and destroy(), which removes every binding
it installed; pair it with game.on_close(keyboard.destroy).
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))
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
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
| member | description |
|---|---|
player.index | Seat number, 0-based. |
player.joined | A source has claimed this seat. |
player.active | Joined and currently connected. |
player.poll() | The seat's InputState, idle while inactive, never the buttons held at the moment of disconnect. |
manager.players | Every seat, joined or not. |
manager.joined_players | Only the claimed ones. |
manager.release(player) | Manual drop-out; the source returns to the pool. |
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(canvas, width=640, height=480, box_height=100,
padding=12, font=("Courier", 12), speed=0.03)
show(text, speaker="", on_complete=None), start the typewriter.update(dt), advance it; call from update().dismiss(), finish the line, or close the box if already finished..active, .waiting, .displayed, state and the text so far.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)
move_up() / move_down(), skip disabled entries automatically.confirm() / cancel(), fire the callbacks.set_enabled(index, enabled), hide(), .active, .selected_index.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.
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.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
| backend | gets you |
|---|---|
pygame | Real mixing, seamless loops, per-channel volume. Needs the [audio] extra. |
winsound | Windows built-in. One sound at a time, SFX-grade. |
aplay / afplay | Linux/Pi and macOS command players. A process per sound; loops respawn with an audible seam. |
null | Every call is a silent no-op. |
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.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.
I2CBus, a thin smbus2 wrapper. probe(addr) checks one address;
backend= injects a simulator.MagmaHub, the controller hub on the bus. scan_buses() probes only
the four candidate addresses; stats reports poll counts and latency.ControllerState, one controller's raw button state.HubPoller, polls a hub on a daemon thread and duck-types its read surface,
so poll() never blocks a frame on a loose wire.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.
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
SimBus.play_recording() as a regression test that runs on any machine.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.
| call | returns |
|---|---|
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)
}
player.x in a script is
the same attribute as in Python and player.speed = 220 writes through.