
From Root-Me to SaaS builder - My journey into code, security and autonomy
At 16, I wanted to break into systems. Today, I build them. This is how a hacker mindset, a frustrating job, and a Python minesweeper led me to be self-employed.

Valentin Chmara
It all started with Root-Me
Back in December 2014, I completed my first CTF challenge on Root-Me.org. I was 16, curious, and fascinated by the idea of being a “hacker”. I didn’t even know what software engineering really was, just that I wanted to understand how systems worked... and how to break them.
But it wasn’t just fun. Root-Me lit a fire in me. It taught me logic, security, and how to think like a machine. Even now, security is baked into how I design every system, because I started by trying to break them first.
My first project: DEMISN
During my final year of high school, I chose a specialty called ISN (Informatique et Sciences du Numérique). That’s where I built my first “real” project: Demisn, a Python & Tkinter version of Minesweeper.
Was it messy? Absolutely.
Did it work? Somehow.
Did it teach me a ton? For sure.
The code is chaotic by today’s standards, but it’s a beautiful snapshot of how every dev starts: by building something that kinda-sorta works and being ridiculously proud of it. (you can found the code in the notes of this post.)
Engineering school & reality check
I passed the entry exams and joined ISTY, an engineering school in France. After 2 years of general studies, I picked mechatronics because I was curious about both software and hardware.
I landed an apprenticeship at Stellantis, a major automotive group. On paper, it sounded like a dream: safety engineering, big teams, real-world impact.
But I quickly learned something else: I hated it.
Too many meetings.
Too little action.
Everything moved at the speed of paperwork.
But something good came out of it.
My first "automation win"
One day, I saw a tedious process being handled by an external company. It took weeks. I asked if I could try automating it.
A few days later, I had a working script.
Suddenly, what took weeks and multiple people became a 5-minute internal task. It saved time, saved money, and gave me a taste of what I really loved:
Building useful tools that make life easier.
From employee to indie
After that, I knew I didn’t want a traditional career. I wanted freedom, speed, and ownership.
I didn’t leap into freelancing with a masterplan. It was messy. Emotional. Unstructured. But it was the beginning of something real.
Back then, I had two things:
- Some savings from my apprenticeship (even if I didn’t enjoy the work, I still believe apprenticeships are the best way to start a career)
- A lucky investment in $MATIC that turned into a x30 profit - unrealized it peaked at x60
At first, I naively thought I could become a trader. I genuinely believed I could make a living predicting the price of Bitcoin. I even open-sourced a project on GitLab using an RNN to forecast BTC prices. Spoiler: it didn’t work out.
Then came the NFT wave. I started building my first dApps for minting NFTs. But I didn’t know how to price my work, how to write a proper quotation, how to sell or convert. I fell into all the classic traps:
- Working for free on vague promises
- Clients who ghosted after delivery
- Over-engineering solutions for people who didn’t need them
At the same time, I tried launching a proptech startup. One of my biggest (and most expensive) mistakes?
Buying a domain name worth $8,000 in crypto… for a project that didn’t even exist yet.
Looking back, I’m grateful for all of it. These failures weren’t signs to stop, they were exactly what I needed to grow. They taught me how to:
- Say no
- Qualify clients
- Build only what matters
- Charge what I’m worth
Today, the contrast is night and day. I can go from idea to working MVP in just a few days. I’ve built a system, refined my processes, and learned how to stay lean and focused. Most importantly, I’m building for the long term with autonomy, speed, and intention.
What’s Next
Through this blog, I’ll share:
- My wins and failures
- What I build and how I build it
- My learnings from freelancing, entrepreneurship, and solo product development
Thanks for reading and if any of this resonates, feel free to reach out or follow the journey.
Notes
You want to see the code of my first project? Here it is:
Please, don't judge me too hard!!
"""
Programme principal du code du démineur.
Tous droits réservés Demisn © 2016
menubar.py, ainsi que score.py sont essentiels au bon fonctionnement du programme.
"""
try:
# Python2
#!/usr/bin/python2.7
# -*-coding:Latin-1 -*
from Tkinter import *
from tkMessageBox import *
except ImportError:
#!/usr/bin/python3.5
# -*-coding:Latin-1 -*
# Python3
from tkinter import *
from tkinter.messagebox import *
import random,math,string
from score import *
from time import *
from menubar import *
from os import path
def create_image():
"""Défini les images nécéssaire"""
global picBomb,picFlag
picBomb=PhotoImage(file="img/bomb.gif")
picFlag=PhotoImage(file="img/flag1.gif")
def damier():
"""Crée un damier lors du lancement du jeu"""
a=0
for i in range(c):
for j in range(h):
labels.append(Label(can, text=v,bd=1,justify=CENTER,relief=SUNKEN,font=("Helvetica", 9),image="",width=1,height=1,padx=9,pady=5))
labels[a].grid(column=i,row=j)
b.append(Button(can,text="",image="",padx=8,pady=1))
b[a].grid(column=i,row=j)
b[a].bind("<Button-3>",lambda i,ref=a: afficheFlag(ref))
b[a].bind("<Button-1>",lambda i,ref=a: click(ref))
b[a].config(relief=RAISED)
a+=1
bombes()
def bombes(charger=False):
"""Si c'est une partie chargée cette fonction positionne les bombes à leurs emplacements sinon elle crée de nouvelles bombes"""
create_image()
global presentBomb
if charger:
for i in presentBomb:
labels[i].config(text=v,image=picBomb,relief=GROOVE,bd=3,width=0,height=0)
chiffres(i)
else:
presentBomb=[]
for i in range(nbBombs):
a=random.randint(0,maxi)
while a in presentBomb:
a=random.randint(0,maxi)
labels[a].config(text=v,image=picBomb,relief=GROOVE,bd=3,width=0,height=0)
presentBomb.append(a)
chiffres(a)
def chiffres(ref):
"""Positionne les chiffres autour des bombes"""
if ref in top:
for i in [ref-h,ref-(h-1),ref+h,ref+(h+1),ref+1]: #On parcours les positions à côter de la bombe pour mettre si possible les bombes à proximités pour le HAUT
if i>=0 and i<=maxi:
if labels[i].cget("text")==" ":
v="1"
elif labels[i].cget("text") in maxBomb:
v=str(int(labels[i].cget("text"))+1)
labels[i].config(text=v)
elif ref in down:
for i in [ref-h,ref-(h+1),ref+h,ref+(h-1),ref-1]: #On parcours les positions à côter de la bombe pour mettre si possible les bombes à proximités pour le BAS
if i>=0 and i<=maxi:
if labels[i].cget("text")==" ":
v="1"
elif labels[i].cget("text") in maxBomb:
v=str(int(labels[i].cget("text"))+1)
labels[i].config(text=v)
else:
for i in [ref-h,ref-(h+1),ref-(h-1),ref+h,ref+(h-1),ref+(h+1),ref-1,ref+1]: #On parcours les positions à côter de la bombe pour mettre si possible les bombes à proximités pour les autres
if i>=0 and i<=maxi:
if labels[i].cget("text")==" ":
v="1"
elif labels[i].cget("text") in maxBomb:
v=str(int(labels[i].cget("text"))+1)
labels[i].config(text=v)
def click(ref):
"""Vérifie les conditions de jeu, retourne la fonction perdu si c'est la position d'une bombe sinon on appelle la fonction 'Explore'"""
global clique,partie_terminée
if clique==0:
temps()
partie_terminée=False
clique+=1
b[ref].grid_forget()
verifGagne()
if labels[ref].cget("image")!="":
perdu(ref)
if labels[ref].cget("image")=="" and labels[ref].cget("text")==v:
explore(ref)
def explore(ref):
"""Contient toutes les fonctions qui doivent être appellées quand on clique sur un bouton"""
enlever_ranger(ref)
enlever_colonne(ref,True)
posForget.append(ref)
verif()
verifGagne()
def verifGagne():
"""Vérifie si la partie est gagnée"""
global partie_terminée
posFlag,posBou=[],[]
nbBou=0
for i in range(c*h):
if labels[i].cget("image")!="" and b[i].grid_info()!={}:
posBou.append(i)
if b[i].cget("image")!="" and b[i].grid_info()!={}:
posFlag.append(i)
if labels[i].cget("image")=="" and b[i].grid_info()=={}:
nbBou+=1
posFlag.sort()
posBou.sort()
presentBomb.sort()
if posFlag==presentBomb and posBou==presentBomb and nbBou==(c*h)-nbBombs:
partie_terminée=True
time.after_cancel(timafter)
if pseudo!="":
menubar.delete_save(str(pseudo))
showwarning('Attention', "Votre sauvegarde est supprimée, vous avez terminé votre partie !")
showinfo('Bien joué', "Vous avez réussi à ne pas exploser !\nBravo")
for i in range(c*h):
b[i].unbind("<Button-3>")
b[i].unbind("<Button-1>")
b[i].config(state='disabled')
if connecter():
if askokcancel('Score','Voulez-vous enregistrer votre score ?'):
def on_button():
niveau=0
pseudo=entree.get()
chiffre=0
if c==9 and nbBombs==10:
niveau=1
elif c==16 and nbBombs==40:
niveau=2
elif c==30 and nbBombs==99:
niveau=3
else:
showerror('Erreur', "C'est une partie personnalisée votre score ne peut être pris en compte")
t.destroy()
bool=False
for i in pseudo:
if i not in string.printable[0:62]:
bool=True
if i in string.printable[0:10]:
chiffre+=1
if chiffre==len(pseudo):
bool=True
if bool:
showerror('Erreur', 'Veuillez choisir un autre pseudo')
entree.delete(0,len(pseudo))
else:
ajoute_score(pseudo, sec, niveau)
showinfo('Sauvegarde', 'Votre score a été enregistré '+str(pseudo)+' !')
t.destroy()
t=Toplevel()
t.grab_set()
t.title('Enregistre ton score')
t.iconbitmap('img/bomb.ico')
t.resizable(width=False, height=False)
l = LabelFrame(t, text="Votre pseudo :", padx=12, pady=12)
l.pack(fill="both", expand="yes")
l.pack(side=LEFT)
entree = Entry(l)
entree.pack()
bou=Button(t,text='Annuler',command=t.destroy)
bou.pack(side=BOTTOM)
bou=Button(t,text='Enregistre ton score !',command=on_button)
bou.pack(side=BOTTOM)
entree.pack()
t.mainloop()
else:
showwarning('Erreur', "Vous n'êtes pas connecté à internet")
def afficheFlag(ref):
"""Afiiche ou enleve le drapeau lorsque que l'on fait un clic droit"""
if b[ref].cget("image")=="":
b[ref].config(image=picFlag)
verifGagne()
compteur_bombe()
else:
b[ref].config(image="")
compteur_bombe(False)
def verif(bool=False):
"""Permet de verifier si toutes les cases ont bien été enlevées"""
for ref in range(c*h):
if ref in top and b[ref].grid_info()=={} and labels[ref].cget("text")==v and labels[ref].cget("image")=="":
for i in [ref-h,ref-(h-1),ref+h,ref+(h+1),ref+1]:
if i>=0 and i<=maxi and labels[i].cget("text") in maxBomb and labels[i].cget("image")=="":
b[i].grid_forget()
elif i>=0 and i<=maxi and labels[i].cget("text")==v and labels[i].cget("image")=="" and bool and i not in posForget:
b[i].grid_forget()
enlever_ranger(ref)
enlever_colonne(i,True)
posForget.append(i)
verif()
elif ref in down and b[ref].grid_info()=={} and labels[ref].cget("text")==v and labels[ref].cget("image")=="":
for i in [ref-h,ref-(h+1),ref+h,ref+(h-1),ref-1]:
if i>=0 and i<=maxi and labels[i].cget("text") in maxBomb and labels[i].cget("image")=="" :
b[i].grid_forget()
elif i>=0 and i<=maxi and labels[i].cget("text")==v and labels[i].cget("image")=="" and bool and i not in posForget:
b[i].grid_forget()
enlever_ranger(ref)
enlever_colonne(i,True)
posForget.append(i)
verif()
elif b[ref].grid_info()=={} and labels[ref].cget("text")==v and labels[ref].cget("image")=="":
for i in [ref-h,ref-(h+1),ref-(h-1),ref+h,ref+(h-1),ref+(h+1),ref-1,ref+1]:
if i>=0 and i<=maxi and labels[i].cget("text") in maxBomb and labels[i].cget("image")=="":
b[i].grid_forget()
elif i>=0 and i<=maxi and labels[i].cget("text")==v and labels[i].cget("image")=="" and bool and i not in posForget:
b[i].grid_forget()
enlever_ranger(ref)
enlever_colonne(i,True)
posForget.append(i)
verif()
def enlever_ranger(pos):
"""Permet d'enlever les boutons d'une rangée de droite et de gauche jusqu'à ce qu'on rencontre un chiffe dans un label"""
left=pos-h
right=pos+h
while left>=0 and labels[left].cget("text")==" ":
if b[left].grid_info()!={}:
b[left].grid_forget()
enlever_colonne(left,True)
left-=h
while right<=maxi and labels[right].cget("text")==" ":
if b[right].grid_info()!={}:
b[right].grid_forget()
enlever_colonne(right,True)
right+=h
def enlever_colonne(pos, bool=False):
"""Permet d'enlever les boutons d'une colonne du bas et du haut jusqu'à ce qu'on rencontre un chiffre dans un label"""
d=pos-1
up=pos+1
while d not in down and labels[d].cget("text")==" " and labels[d].cget("image")=="" and d>=0 and d<maxi:
if b[d].grid_info()!={}:
b[d].grid_forget()
if bool:
enlever_ranger(d)
d-=1
while up not in top and labels[up].cget("text")==" " and labels[up].cget("image")=="" and up>=0 and up<maxi:
if b[up].grid_info()!={}:
b[up].grid_forget()
if bool:
enlever_ranger(up)
up+=1
def perdu(ref):
"""Fonction intervient lorsque l'on perd"""
global partie_terminée
partie_terminée=True
labels[ref].config(bg="red")
timer(True)
if pseudo!="":
menubar.delete_save(str(pseudo))
showwarning('Attention', "Votre sauvagarde est supprimé, vous avez terminé votre partie !")
for i in range(c*h):
if i in presentBomb:
b[i].grid_forget()
b[i].unbind("<Button-3>")
b[i].unbind("<Button-1>")
b[i].config(state='disabled')
showinfo('Perdu', 'Vous avez fait exploser la mine')
def supprimer():
"""Supprime les infos de la partie précédente."""
global b,labels
for i in range(len(b)):
b[i].grid_remove()
b[i].destroy()
labels[i].grid_remove()
labels[i].destroy()
b=[]
labels=[]
def nouveau(charger=False,bombe=[],pos=[],bouton=[],flag=[],name=""):
""" Permet de creer un nouveau damier avec de nouvelle bombes, de plus si le paramètre charger est True
alors on charge la sauvegarde grâce à la fonction charge() de menubar.py"""
global presentBomb,posForget,sec,compteur_bombes,pseudo,partie_terminée,clique,top,down
partie_terminée=True
clique = 0
pseudo=name
compteur_bombes=nbBombs
compteur['text']='Bombes restantes : '+str(compteur_bombes)
time.after_cancel(timafter)
supprimer()
posForget=[]
presentBomb=[]
if charger:
presentBomb=bombe
posForget=pos
else:
sec=0
time['text'] = 'Temps : ' + strftime('%M : %S', gmtime(sec))
can.pack_forget()
top=[]
down=[]
top.append(0)
for i in range(h):
top.append((i+1)*h)
down.append(((i+1)*h)-1)
down.append(c*h)
a=0
for i in range(c):
for j in range(h):
labels.append(Label(can, text=v,bd=1,relief=SUNKEN,font=("Helvetica", 9),width=1,height=1,justify=CENTER,padx=9,pady=5))
labels[a].grid(column=i,row=j)
b.append(Button(can,text="",image="",padx=8,pady=1))
b[a].grid(column=i,row=j)
b[a].bind("<Button-3>",lambda i,ref=a: afficheFlag(ref))
b[a].bind("<Button-1>",lambda i,ref=a: click(ref))
b[a].config(text="",image="")
a+=1
if charger:
for i in bouton:
b[i].grid_forget()
bombes(True)
else:
bombes()
can.pack(side=TOP,expand=True)
if charger:
for i in flag:
afficheFlag(i)
timer()
partie_terminée=False
clique=1
def recup_info_partie():
"""Récupère les informations de la partie en cours """
b_forget=[]
flag=[]
taille_interface=c
for i in range(c*h):
if b[i].grid_info()=={}:
b_forget.append(i)
if b[i].cget("image")!="":
flag.append(i)
return presentBomb,posForget,b_forget,flag,taille_interface,sec
def option(nbmines, taille_interface,bool=True,s=0):
""" Permet de définir les options définis par la commande option du menu. """
global c,maxi,nbBombs,top,down,h,sec
sec=s
c = taille_interface
maxi=(c*c)-1
h=c
if taille_interface==30:
h=16
maxi=(c*16)-1
nbBombs = int(nbmines)
if bool:
nouveau()
def timer(bool=False):
"""Arrête ou démarre le timer en fonction de la variable bool"""
global timafter
if bool:
time.after_cancel(timafter)
else:
timafter = time.after(1000, temps)
def temps():
"""Affiche le temps dans la fenètre principale"""
global sec,timafter
sec += 1
time['text'] = 'Temps : ' + strftime('%M : %S', gmtime(sec))
timafter = time.after(1000, temps)
def compteur_bombe(bool=True):
"""Affiche dans la fenètre principale le nombres de bombes encore à trouver"""
global compteur_bombes
if bool:
compteur_bombes-=1
elif bool==False and compteur_bombes+1<=nbBombs:
compteur_bombes+=1
compteur['text']='Bombes restantes : '+str(compteur_bombes)
if compteur_bombes<=0:
compteur['text']='Bombes restantes : 0'
def pseudo_partie():
"""Retourne la valeur pseudo pour menubar.py"""
return pseudo
def etat_partie():
"""Retourne l'état de la partie,
terminée = True
non terminée = False
fonction créée pour menubar.py"""
return partie_terminée
######Programme ######
c=9
nbBombs=10
w=200
h=200
compteur_bombes=nbBombs
h=c
posForget=[]
maxBomb=["1","2","3","4","5","6","7","8"]
presentBomb=[]
maxi=(c*h)-1
labels=[]
b = []
v = " "
pseudo=""
partie_terminée=True
clique=0
top=[]
down=[]
top.append(0)
for i in range(h):
top.append((i+1)*c)
down.append(((i+1)*c)-1)
down.append(c*h)
sec=0
fen = Tk()
menubar = MenuBar(fen,nouveau,recup_info_partie,option,timer,etat_partie,pseudo_partie)
fen.title('Démineur')
fen.resizable(width=False, height=False)
fen.config(menu=menubar)
frame= LabelFrame(fen,bd=2)
frame.pack(side=TOP,fill=X)
can = Canvas(fen, width=w, height=h, background='white')
time = Label(frame, fg='Black')
time.pack(side=LEFT)
time['text'] = 'Temps : ' + strftime('%M : %S', gmtime(sec))
timafter = time.after(1000, temps)
time.after_cancel(timafter)
compteur = Label(frame, fg='Black')
compteur.pack(side=RIGHT)
compteur['text']='Bombes restantes : '+str(compteur_bombes)
create_image()
damier()
fen.iconbitmap('img/bomb.ico')
can.pack(side=BOTTOM,expand=True)
fen.mainloop()
Comparing modern JavaScript bundlers for Nuxt 3: Rspack, Vite, and Webpack
An in-depth comparison of Rspack, Vite, and Webpack, exploring their performance, features, and use cases to help you choose the right tool for your project.
How SupaCodeur got its first users — From internal tool to niche SaaS
I built SupaCodeur to solve my own frustrations with Codeur.com. Here's how I turned it into a real SaaS, got my first users, and what I learned along the way.