Steering a dot
Everything on the board at once, in the serial monitor. The stick moves an @ round a 21 × 9 field, a diamond button nudges it one step, KEY drops a mark, U10 clears the marks and U11 puts the dot back in the middle. Ten frames a second, no library, and the only rules are the ones in this book.
The rules
Each frame, ten a second, the sketch does four things in order.
The stick moves the dot. Each axis goes through the centre and dead zone from the last article, so it is a number from −100 to 100. Divided by 50, whole cells only, that is how far the dot moves: two cells at full tilt, one past half, none below.
A button acts once. The sketch remembers which button it saw last frame and acts only when that changes to a button. Holding U7 moves the dot one cell left, not one per frame. The diamond steps one cell its own way; U10 clears the marks; U11 puts the dot back in the middle.
KEY drops a mark. A click leaves a + where the dot is. It is its own
pin, so it works while the stick is tilted or a button is held.
Then it draws. Nine lines of 21 characters: @ for the dot, + for a
mark, . for everything else.
Why the buttons do what they do
The jobs follow the ladder. Only one button reads at a time, so the diamond can only ever step in one direction; the stick does diagonals. U11 beats everything, so it gets the job you never combine with anything: start again. And KEY, the one input that can be held with a button, gets the job you want to do while moving.
When it goes the wrong way
The sketch cannot know which way your stick rises; the drawing does not say,
and it depends on how you hold the board. If the dot goes left when you push
right, set FLIP_X to true, and the same for up and down with FLIP_Y.
A dot that drifts with the stick let go means the centre was read with a thumb on it. Let go and reset.
The code
A dot in a 21 by 9 field of dots, printed ten times a second. The stick moves it, up to two cells a frame; the diamond nudges it one; KEY marks a spot; U10 clears the marks; U11 recentres.
/*
Slim Joystick - steering a dot TK21 / /p/tk21
Wiring. Parts up, header along the top. Count from the square pad,
which is GND at the right-hand end, leftwards:
GND -> GND
3V3 -> 3V3 on every board, the Uno included (it is printed 3V3)
BTNS -> A2 on an Uno, GPIO 32 on an ESP32, GPIO 6 on an
ESP32-S3, GP28 on a Raspberry Pi Pico (analog)
X -> A0, GPIO 34, GPIO 4, GP26 (same order, analog)
Y -> A1, GPIO 35, GPIO 5, GP27 (analog)
KEY -> D2, GPIO 25, GPIO 7, GP15 (digital)
Arduino IDE
Tools > Board your board, e.g. ESP32S3 Dev Module
Tools > Port the one that appears when you plug in
Tools > USB CDC On Boot Enabled (ESP32-S3 only)
No library needed.
*/
// The pins X, Y, BTNS and KEY are wired to.
// Uno: A0 A1 A2 2. ESP32: 34 35 32 25. ESP32-S3: 4 5 6 7. Pico: 26 27 28 15.
const int X_PIN = 4;
const int Y_PIN = 5;
const int BTNS_PIN = 6;
const int KEY_PIN = 7;
// Uno and Pico only: the ADC's full scale, in mV.
// Uno: 5000. Pico: 3300.
const float FULL_SCALE_MV = 5000.0;
// The buttons in ladder order, and the line under each one's level,
// in mV with 3V3 on the header.
const int LIMIT_MV[] = {2475, 1375, 963, 743, 605, 275};
const int U11 = 0, U10 = 1, U5 = 2, U7 = 3, U8 = 4, U6 = 5;
const int DEAD_ZONE = 10; // per cent either side of the centre
const float EDGE_GUESS_MV = 1000; // the ends, until the stick shows them
const int FIELD_W = 21;
const int FIELD_H = 9;
const int STEP_PER = 50; // per cent of tilt per cell a frame
const int FRAME_MS = 100;
const bool FLIP_X = false; // true if the dot goes the wrong way
const bool FLIP_Y = false;
float centreX, loX, hiX;
float centreY, loY, hiY;
int dotX = FIELD_W / 2;
int dotY = FIELD_H / 2;
bool mark[FIELD_H][FIELD_W];
int lastButton = -1;
int lastKey = LOW;
float readMv(int pin) {
#if defined(ARDUINO_ARCH_ESP32)
return analogReadMilliVolts(pin); // calibrated in the chip
#else
return analogRead(pin) * FULL_SCALE_MV / 1023.0;
#endif
}
float averageMv(int pin) {
float sum = 0;
for (int i = 0; i < 16; i++) sum += readMv(pin);
return sum / 16;
}
int axis(float mv, float centre, float &lo, float &hi) {
if (mv < lo) lo = mv;
if (mv > hi) hi = mv;
float p;
if (mv >= centre) p = (mv - centre) * 100.0 / (hi - centre);
else p = (mv - centre) * 100.0 / (centre - lo);
int out = constrain((int)p, -100, 100);
if (abs(out) < DEAD_ZONE) out = 0; // the dead zone
return out;
}
// 0 to 5 in ladder order, or -1 for none. One button at a time.
int whichButton() {
float mv = readMv(BTNS_PIN);
for (int i = 0; i < 6; i++) {
if (mv > LIMIT_MV[i]) return i;
}
return -1;
}
void press(int b) {
if (b == U11) { // back to the middle
dotX = FIELD_W / 2;
dotY = FIELD_H / 2;
}
if (b == U10) memset(mark, 0, sizeof(mark)); // clear the marks
if (b == U5) dotY = min(dotY + 1, FIELD_H - 1); // down
if (b == U7) dotX = max(dotX - 1, 0); // left
if (b == U8) dotY = max(dotY - 1, 0); // up
if (b == U6) dotX = min(dotX + 1, FIELD_W - 1); // right
}
void draw() {
Serial.println();
for (int y = 0; y < FIELD_H; y++) {
for (int x = 0; x < FIELD_W; x++) {
if (x == dotX && y == dotY) Serial.print('@');
else if (mark[y][x]) Serial.print('+');
else Serial.print('.');
}
Serial.println();
}
}
void setup() {
Serial.begin(115200);
pinMode(KEY_PIN, INPUT); // R1 on the board pulls it down
delay(200); // hands off the stick
centreX = averageMv(X_PIN);
centreY = averageMv(Y_PIN);
loX = centreX - EDGE_GUESS_MV;
hiX = centreX + EDGE_GUESS_MV;
loY = centreY - EDGE_GUESS_MV;
hiY = centreY + EDGE_GUESS_MV;
}
void loop() {
int sx = axis(averageMv(X_PIN), centreX, loX, hiX);
int sy = axis(averageMv(Y_PIN), centreY, loY, hiY);
if (FLIP_X) sx = -sx;
if (FLIP_Y) sy = -sy;
dotX = constrain(dotX + sx / STEP_PER, 0, FIELD_W - 1);
dotY = constrain(dotY - sy / STEP_PER, 0, FIELD_H - 1);
int b = whichButton();
if (b != lastButton && b >= 0) press(b); // once per press
lastButton = b;
int key = digitalRead(KEY_PIN); // HIGH while clicked
if (key == HIGH && lastKey == LOW) mark[dotY][dotX] = true;
lastKey = key;
draw();
delay(FRAME_MS);
}Buttons act once per press: the sketch remembers last frame's button and acts only when it changes. If the dot goes the wrong way, set FLIP_X or FLIP_Y. Keep your thumb off the stick while the board starts.
The same game in MicroPython, for an ESP32, an ESP32-S3 or a Pico: the stick moves the @, the diamond nudges it, KEY marks, U10 clears, U11 recentres.
"""
Slim Joystick - steering a dot, MicroPython TK21 / /p/tk21
Wiring. Parts up, header along the top. Count from the square pad,
which is GND at the right-hand end, leftwards:
GND -> GND
3V3 -> 3V3 (the header is printed 3V3; never 5V)
BTNS -> GPIO 32 on an ESP32, GPIO 6 on an ESP32-S3,
GP28 on a Raspberry Pi Pico (analog)
X -> GPIO 34, GPIO 4, GP26 (same order, analog)
Y -> GPIO 35, GPIO 5, GP27 (analog)
KEY -> GPIO 25, GPIO 7, GP15 (digital)
Thonny
Run > Configure interpreter MicroPython (ESP32) or
MicroPython (Raspberry Pi Pico)
Save it to the board as main.py to run it on every power-up.
Nothing to install: machine, sys and time are built in.
"""
import sys
import time
from machine import ADC, Pin
# The GPIO numbers X, Y, BTNS and KEY are wired to.
# ESP32: 34 35 32 25. ESP32-S3: 4 5 6 7. Pico: 26 27 28 15.
X_PIN, Y_PIN, BTNS_PIN, KEY_PIN = 4, 5, 6, 7
# The buttons in ladder order, and the line under each one's level.
BUTTON_NAME = ("U11", "U10", "U5", "U7", "U8", "U6")
LIMIT_MV = (2475, 1375, 963, 743, 605, 275)
DEAD_ZONE = 10 # per cent either side of the centre
EDGE_GUESS_MV = 1000 # the ends, until the stick shows them
FIELD_W, FIELD_H = 21, 9
STEP_PER = 50 # per cent of tilt per cell a frame
FRAME_MS = 100
FLIP_X = False # True if the dot goes the wrong way
FLIP_Y = False
ESP = sys.platform == "esp32" # ESP32 and ESP32-S3
def analog(pin):
adc = ADC(Pin(pin))
if ESP:
adc.atten(ADC.ATTN_11DB) # the full range, to about 3.1 V
return adc
def millivolts(adc):
if ESP:
return adc.read_uv() / 1000 # calibrated in the chip
return adc.read_u16() * 3300 / 65535
def average_mv(adc):
return sum(millivolts(adc) for _ in range(16)) / 16
class Axis:
def __init__(self, pin):
self.adc = analog(pin)
self.centre = average_mv(self.adc) # hands off the stick
self.lo = self.centre - EDGE_GUESS_MV
self.hi = self.centre + EDGE_GUESS_MV
def read(self):
mv = average_mv(self.adc)
self.lo = min(self.lo, mv)
self.hi = max(self.hi, mv)
if mv >= self.centre:
p = (mv - self.centre) * 100 / (self.hi - self.centre)
else:
p = (mv - self.centre) * 100 / (self.centre - self.lo)
p = max(-100, min(100, int(p)))
return 0 if abs(p) < DEAD_ZONE else p # the dead zone
btns = analog(BTNS_PIN)
key = Pin(KEY_PIN, Pin.IN) # R1 on the board pulls it down
time.sleep_ms(200)
x_axis = Axis(X_PIN)
y_axis = Axis(Y_PIN)
def which_button():
mv = millivolts(btns)
for i, limit in enumerate(LIMIT_MV):
if mv > limit:
return BUTTON_NAME[i]
return None
def clamp(v, top):
return max(0, min(top, v))
dot_x, dot_y = FIELD_W // 2, FIELD_H // 2
marks = set()
last_button = None
last_key = 0
while True:
sx, sy = x_axis.read(), y_axis.read()
if FLIP_X:
sx = -sx
if FLIP_Y:
sy = -sy
dot_x = clamp(dot_x + int(sx / STEP_PER), FIELD_W - 1)
dot_y = clamp(dot_y - int(sy / STEP_PER), FIELD_H - 1)
b = which_button()
if b is not None and b != last_button: # once per press
if b == "U11":
dot_x, dot_y = FIELD_W // 2, FIELD_H // 2
elif b == "U10":
marks.clear()
elif b == "U5":
dot_y = clamp(dot_y + 1, FIELD_H - 1) # down
elif b == "U7":
dot_x = clamp(dot_x - 1, FIELD_W - 1) # left
elif b == "U8":
dot_y = clamp(dot_y - 1, FIELD_H - 1) # up
elif b == "U6":
dot_x = clamp(dot_x + 1, FIELD_W - 1) # right
last_button = b
k = key.value() # 1 while clicked
if k and not last_key:
marks.add((dot_x, dot_y))
last_key = k
print()
for y in range(FIELD_H):
row = ""
for x in range(FIELD_W):
if (x, y) == (dot_x, dot_y):
row += "@"
elif (x, y) in marks:
row += "+"
else:
row += "."
print(row)
time.sleep_ms(FRAME_MS)There is no Uno here: an Uno cannot run MicroPython. Thonny's shell scrolls like the serial monitor, so watch the bottom of it. Keep your thumb off the stick when you press Run, and stop it with Ctrl-C.
When it does not work
Your stick's axes rise the other way from the sketch's guess. Set FLIP_X or FLIP_Y to true, or both, and upload again. It depends on how you hold the board, so there is no single right answer.
The centre was measured with a thumb on the stick. Let go and press reset. If it still drifts, the dead zone is too small for your stick; raise DEAD_ZONE from 10 to 15.
The sketch acts only when the button it reads changes, once per frame, which is slower than any bounce. Two steps means two frames saw a change: the reading dropped to none and came back. Press firmly and squarely; a tact switch pressed at an angle can make and break.
The serial monitor has no way to move the cursor, so each frame prints below the last. Watch the bottom of the window, or narrow it so one field fills it. A terminal program that understands escape codes can redraw in place.
The short list of reasons the numbers look wrong, and where to look first.
When a reading is wrong →Edit this page — content/books/slim-joystick/steering-a-dot.mdx
Questions about this product
See what other owners have asked, and read their solutions.
Slim Joystick
Loading discussions…
Discuss this article
Ask about this page. The answer stays here, on the page it belongs to, for whoever hits the same wall next.